diff --git a/.gitignore b/.gitignore index 749c3cfa012..f3ebed374cf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ app/src/server/graphql/schema/generated crates/command-signatures-v2/js/build crates/command-signatures-v2/js/node_modules .worktrees/ +/.superpowers/ /target-check # For testing changes to the channel versions file channel_versions_test.json @@ -79,3 +80,5 @@ official-website/ docs/openwarp-cloud-removal-plan.md .omx/ .tmp/ +.codegraph/ +.cursor/ diff --git a/app/src/ai/agent/api.rs b/app/src/ai/agent/api.rs index f78a16b3c3f..3f079091552 100644 --- a/app/src/ai/agent/api.rs +++ b/app/src/ai/agent/api.rs @@ -55,7 +55,11 @@ impl ServerConversationToken { } pub fn debug_link(&self) -> String { - format!("{}://debug/maa/{}", ChannelState::url_scheme(), self.as_str()) + format!( + "{}://debug/maa/{}", + ChannelState::url_scheme(), + self.as_str() + ) } pub fn conversation_link(&self) -> String { diff --git a/app/src/ai/agent/api_tests.rs b/app/src/ai/agent/api_tests.rs index 84aafd9941c..870caf0899e 100644 --- a/app/src/ai/agent/api_tests.rs +++ b/app/src/ai/agent/api_tests.rs @@ -14,9 +14,9 @@ use crate::cloud_object::model::persistence::ObjectStoreModel; use crate::cloud_object::model::view::ObjectStoreViewModel; use crate::cloud_object::update_manager::UpdateManager; use crate::cloud_object::{StoredObjectMetadata, StoredObjectPermissions}; -use crate::server_time::ServerTimestamp; use crate::notebooks::manager::NotebookManager; use crate::server::ids::SyncId; +use crate::server_time::ServerTimestamp; use crate::settings::AISettings; use crate::system::SystemStats; use crate::workspaces::user_profiles::UserProfiles; diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index cd97c904fc3..e30556c4433 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -154,7 +154,11 @@ fn run_shell_command_tool() -> api::message::tool_call::Tool { }) } -fn tool_call_message_with_tool(id: &str, call_id: &str, tool: api::message::tool_call::Tool) -> api::Message { +fn tool_call_message_with_tool( + id: &str, + call_id: &str, + tool: api::message::tool_call::Tool, +) -> api::Message { api::Message { id: id.to_string(), task_id: "root-task".to_string(), @@ -475,11 +479,7 @@ fn test_cli_subagent_serialized_block_preserves_block_id_and_metadata() { api::Task { id: "root-task".to_string(), messages: vec![ - tool_call_message_with_tool( - "tool-call-1", - "call-1", - run_shell_command_tool(), - ), + tool_call_message_with_tool("tool-call-1", "call-1", run_shell_command_tool()), tool_call_result_message_with_result( "tool-result-1", "call-1", diff --git a/app/src/ai/agent_providers/active_ai/mod.rs b/app/src/ai/agent_providers/active_ai/mod.rs index d9fa358a537..ca95fe0f2c1 100644 --- a/app/src/ai/agent_providers/active_ai/mod.rs +++ b/app/src/ai/agent_providers/active_ai/mod.rs @@ -172,7 +172,10 @@ pub mod prompt_suggestions { } } }; - let system = render("prompt_suggestions_system.j2", context! { language => language }); + let system = render( + "prompt_suggestions_system.j2", + context! { language => language }, + ); let user = render( "prompt_suggestions_user.j2", context! { @@ -433,9 +436,12 @@ pub mod next_command { .into_iter() .map(|(name, content)| UserRuleCtx { name, content }) .collect(); - let system = render("next_command_system.j2", context! { - user_rules => user_rule_ctxs, - }); + let system = render( + "next_command_system.j2", + context! { + user_rules => user_rule_ctxs, + }, + ); let user = render( "next_command_user.j2", context! { diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index cade8b5d8d2..8a60ac721b0 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -1518,9 +1518,7 @@ impl AgentDriver { fn handle_terminal_driver_event(&mut self, event: &TerminalDriverEvent) { match event { TerminalDriverEvent::SlowBootstrap => { - eprintln!( - "Warning: Terminal session is slow to bootstrap." - ); + eprintln!("Warning: Terminal session is slow to bootstrap."); } } } diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 1ef9c8cd5af..06981ca3837 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -177,9 +177,7 @@ fn run_agent( Ok(()) } AgentCommand::Profile(sub) => profiles::run(ctx, global_options, sub), - AgentCommand::List(_) => Err(anyhow::anyhow!( - "Agent skill listing is disabled in Zap" - )), + AgentCommand::List(_) => Err(anyhow::anyhow!("Agent skill listing is disabled in Zap")), } } diff --git a/app/src/ai/agent_tips.rs b/app/src/ai/agent_tips.rs index cd5f366a9cd..7328b954782 100644 --- a/app/src/ai/agent_tips.rs +++ b/app/src/ai/agent_tips.rs @@ -140,7 +140,6 @@ static DEFAULT_TIPS: LazyLock> = LazyLock::new(|| { action: None, kind: AgentTipKind::Context, }, - AgentTip { description: crate::t!("agent-tip-agent-profiles"), link: Some("".to_string()), @@ -411,10 +410,7 @@ pub fn get_agent_tips(ctx: &AppContext) -> Vec { { tips.push(AgentTip { description: crate::t!("agent-tip-voice-input"), - link: Some( - "" - .to_string(), - ), + link: Some("".to_string()), binding_name: Some("FN"), action: None, kind: AgentTipKind::General, diff --git a/app/src/ai/blocklist/action_model/execute/read_skill.rs b/app/src/ai/blocklist/action_model/execute/read_skill.rs index 59eb59a3d0b..094003af3b1 100644 --- a/app/src/ai/blocklist/action_model/execute/read_skill.rs +++ b/app/src/ai/blocklist/action_model/execute/read_skill.rs @@ -1,14 +1,14 @@ use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput}; #[cfg(feature = "local_fs")] use crate::ai::agent::AIAgentActionResultType; -use crate::ai::skills::{SkillManager, SkillTelemetryEvent}; #[cfg(feature = "local_fs")] use crate::ai::skills::extract_skill_parent_directory; +use crate::ai::skills::{SkillManager, SkillTelemetryEvent}; use crate::send_telemetry_from_ctx; use ai::agent::action_result::AnyFileContent; -use ai::skills::SkillReference; #[cfg(feature = "local_fs")] use ai::skills::parse_skill; +use ai::skills::SkillReference; use std::path::Path; use warpui::{ModelContext, SingletonEntity}; @@ -112,21 +112,17 @@ impl ReadSkillExecutor { return ActionExecution::new_async( async move { parse_skill(&path) }, move |parsed, _app| match parsed { - Ok(skill) => AIAgentActionResultType::ReadSkill( - ReadSkillResult::Success { - content: FileContext::new( - skill.path.to_string_lossy().into_owned(), - AnyFileContent::StringContent(skill.content.clone()), - skill.line_range.clone(), - None, - ), - }, - ), - Err(err) => AIAgentActionResultType::ReadSkill( - ReadSkillResult::Error(format!( - "Skill not found: {skill_ref_for_async:?} ({err})" - )), - ), + Ok(skill) => AIAgentActionResultType::ReadSkill(ReadSkillResult::Success { + content: FileContext::new( + skill.path.to_string_lossy().into_owned(), + AnyFileContent::StringContent(skill.content.clone()), + skill.line_range.clone(), + None, + ), + }), + Err(err) => AIAgentActionResultType::ReadSkill(ReadSkillResult::Error( + format!("Skill not found: {skill_ref_for_async:?} ({err})"), + )), }, ); } @@ -160,7 +156,9 @@ impl ReadSkillExecutor { /// /// 抽出 helper 是为了让 `ActionExecution` 的泛型 `T` 在 `success_execution` /// 和 `new_async` 两条路径里推导到相同类型(否则 Rust 会要求函数显式声明返回类型)。 -fn success_execution(skill: &ai::skills::ParsedSkill) -> ActionExecution> { +fn success_execution( + skill: &ai::skills::ParsedSkill, +) -> ActionExecution> { let content = FileContext::new( skill.path.to_string_lossy().into_owned(), AnyFileContent::StringContent(skill.content.clone()), diff --git a/app/src/ai/blocklist/action_model/execute/read_skill_tests.rs b/app/src/ai/blocklist/action_model/execute/read_skill_tests.rs index 812c13aa649..06280186414 100644 --- a/app/src/ai/blocklist/action_model/execute/read_skill_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/read_skill_tests.rs @@ -238,7 +238,9 @@ fn test_read_skill_executor_fallback_returns_error_when_file_missing() { on_complete, } = execution else { - panic!("Legal-shaped skill path should still produce Async execution before disk check"); + panic!( + "Legal-shaped skill path should still produce Async execution before disk check" + ); }; let async_result = execute_future.await; diff --git a/app/src/ai/blocklist/agent_view/zero_state_block.rs b/app/src/ai/blocklist/agent_view/zero_state_block.rs index d8bf3dc1c7f..4cebd055429 100644 --- a/app/src/ai/blocklist/agent_view/zero_state_block.rs +++ b/app/src/ai/blocklist/agent_view/zero_state_block.rs @@ -583,7 +583,9 @@ fn render_title_and_description(props: HeaderProps, app: &AppContext) -> Vec Box { body_color, Default::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .set_selectable(true); let mut action = diff --git a/app/src/ai/blocklist/inline_action/inline_action_header.rs b/app/src/ai/blocklist/inline_action/inline_action_header.rs index 8d33b8b02ef..742d66950a0 100644 --- a/app/src/ai/blocklist/inline_action/inline_action_header.rs +++ b/app/src/ai/blocklist/inline_action/inline_action_header.rs @@ -241,7 +241,9 @@ impl HeaderConfig { text_color, Default::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .set_selectable(self.is_text_selectable); if !self.soft_wrap_title { element = element.with_no_text_wrapping(); diff --git a/app/src/ai_assistant/panel.rs b/app/src/ai_assistant/panel.rs index f1f72943e79..99bdde34658 100644 --- a/app/src/ai_assistant/panel.rs +++ b/app/src/ai_assistant/panel.rs @@ -69,7 +69,6 @@ const PANEL_HORIZONTAL_PADDING: f32 = 6.; const EDITOR_MARGIN: f32 = 16.; const LOGO_SIZE: f32 = 20.; - const ZERO_STATE_HELP_TEXT: &str = "Shift + ctrl + space a block or text selection to ask Zap AI."; const SCRIPT_ZERO_STATE_PROMPT: &str = "Write a script to connect to an AWS EC2 instance."; const GIT_ZERO_STATE_PROMPT: &str = "How do I undo the most recent commits in git?"; diff --git a/app/src/ai_assistant/transcript.rs b/app/src/ai_assistant/transcript.rs index 134a7c443d4..d149cbb7b6f 100644 --- a/app/src/ai_assistant/transcript.rs +++ b/app/src/ai_assistant/transcript.rs @@ -9,10 +9,10 @@ use warpui::units::Pixels; use warpui::{ elements::{ Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, - Container, CornerRadius, CrossAxisAlignment, EventHandler, Fill, Flex, + Container, CornerRadius, CrossAxisAlignment, EventHandler, Expanded, Fill, Flex, FormattedTextElement, HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentAnchor, ParentElement, Radius, SavePosition, ScrollbarWidth, - Expanded, Shrinkable, Text, Wrap, + Shrinkable, Text, Wrap, }, keymap::Keystroke, platform::Cursor, @@ -49,7 +49,6 @@ const TERMINAL_INPUT_SVG_PATH: &str = "bundled/svg/terminal-input.svg"; const USER_ICON_SVG_PATH: &str = "bundled/svg/user.svg"; const SAVE_WORKFLOW_ICON_PATH: &str = "bundled/svg/workflow.svg"; - const PANEL_LEFT_MARGIN: f32 = 15.; const DETAILS_BOTTOM_MARGIN: f32 = 12.; @@ -634,7 +633,9 @@ impl Transcript { theme.main_text_color(theme.surface_2()).into_solid(), highlighted_hyperlink.clone(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_inline_code_properties( Some(theme.nonactive_ui_text_color().into()), Some(inline_code_bg_color), diff --git a/app/src/ai_assistant/utils.rs b/app/src/ai_assistant/utils.rs index f2ceb635855..a3cddd05c7b 100644 --- a/app/src/ai_assistant/utils.rs +++ b/app/src/ai_assistant/utils.rs @@ -18,7 +18,6 @@ use crate::{appearance::Appearance, ui_components::blended_colors}; use super::{panel::AIAssistantAction, requests::Requests, transcript::CodeBlockMouseStateHandles}; - const SQUARE_ALERT_SVG_PATH: &str = "bundled/svg/alert-square.svg"; const TRIANGLE_ALERT_SVG_PATH: &str = "bundled/svg/alert-triangle.svg"; diff --git a/app/src/app_state.rs b/app/src/app_state.rs index e1fa16a467e..4738b752a87 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -14,6 +14,7 @@ use crate::ai::blocklist::InputConfig; use crate::ai::blocklist::SerializedBlockListItem; use crate::code::editor_management::CodeSource; use crate::drive::ZapDriveObjectSettings; +use crate::project_organization::domain::RepositoryWorkspaceId; use crate::root_view::quake_mode_window_id; use crate::server::ids::SyncId; use crate::settings_view::SettingsSection; @@ -45,6 +46,8 @@ pub struct PersistedAgentManagementFilters { pub struct WindowSnapshot { pub tabs: Vec, pub active_tab_index: usize, + pub active_repository_workspace_id: Option, + pub repository_workspace_states: Vec, pub bounds: Option, pub fullscreen_state: FullscreenState, pub quake_mode: bool, @@ -64,6 +67,7 @@ pub struct WindowSnapshot { #[derive(Clone, Debug, PartialEq)] pub struct TabSnapshot { + pub repository_workspace_id: Option, pub custom_title: Option, pub root: PaneNodeSnapshot, pub default_directory_color: Option, @@ -72,6 +76,12 @@ pub struct TabSnapshot { pub right_panel: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RepositoryWorkspaceWindowStateSnapshot { + pub repository_workspace_id: RepositoryWorkspaceId, + pub active_tab_index: usize, +} + impl TabSnapshot { pub(crate) fn color(&self) -> Option { self.selected_color.resolve(self.default_directory_color) @@ -317,6 +327,7 @@ pub enum CodeReviewPaneSnapshot { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum LeftPanelDisplayedTab { + ProjectTree, FileTree, GlobalSearch, ZapDrive, @@ -329,6 +340,7 @@ pub enum LeftPanelDisplayedTab { impl From for LeftPanelDisplayedTab { fn from(view: ToolPanelView) -> Self { match view { + ToolPanelView::ProjectTree => LeftPanelDisplayedTab::ProjectTree, ToolPanelView::ProjectExplorer => LeftPanelDisplayedTab::FileTree, ToolPanelView::GlobalSearch { .. } => LeftPanelDisplayedTab::GlobalSearch, ToolPanelView::ZapDrive => LeftPanelDisplayedTab::ZapDrive, diff --git a/app/src/appearance.rs b/app/src/appearance.rs index 5f97b162dbe..5deebf575f9 100644 --- a/app/src/appearance.rs +++ b/app/src/appearance.rs @@ -23,8 +23,8 @@ use macos_app_icon::*; use crate::{ settings::{ active_theme_kind, font::heading_font_size_multipliers_from_settings, FontSettings, - FontSettingsChangedEvent, MonospaceFontSize, Settings, ThemeSettings, UI_FONT_SIZE_MIN, - UI_FONT_SIZE_MAX, + FontSettingsChangedEvent, MonospaceFontSize, Settings, ThemeSettings, UI_FONT_SIZE_MAX, + UI_FONT_SIZE_MIN, }, themes::theme::{ThemeKind, WarpTheme}, ASSETS, @@ -485,8 +485,9 @@ fn build_appearance(ctx: &mut AppContext) -> Appearance { let ui_font_family = if ui_font_name.is_empty() { load_default_ui_font_family(ctx).expect("unable to load default ui font family") } else { - get_or_load_font_family(&ui_font_name, ctx) - .unwrap_or_else(|| load_default_ui_font_family(ctx).expect("unable to load default ui font family")) + get_or_load_font_family(&ui_font_name, ctx).unwrap_or_else(|| { + load_default_ui_font_family(ctx).expect("unable to load default ui font family") + }) }; let am_font_family_from_settings = get_or_load_font_family(&am_font_name, ctx); diff --git a/app/src/autoupdate/linux.rs b/app/src/autoupdate/linux.rs index 020790e9863..02bbc2b331c 100644 --- a/app/src/autoupdate/linux.rs +++ b/app/src/autoupdate/linux.rs @@ -147,18 +147,12 @@ mod appimage { if downloaded - last_reported >= REPORT_BYTES_THRESHOLD || last_reported_at.elapsed() >= REPORT_TIME_THRESHOLD { - on_progress(DownloadProgress { - downloaded, - total, - }); + on_progress(DownloadProgress { downloaded, total }); last_reported = downloaded; last_reported_at = Instant::now(); } } - on_progress(DownloadProgress { - downloaded, - total, - }); + on_progress(DownloadProgress { downloaded, total }); // openWarp:在覆盖原 AppImage 之前先对临时文件做 SHA-256 校验, // 防御 CDN 中间人 / 网络损坏。其他 channel 跳过(有自家流程)。 @@ -395,9 +389,7 @@ impl PackageManager { .output(); let output = match output { Ok(o) => o, - Err(err) => { - return Err(err).context("Failed to run package manager detection script") - } + Err(err) => return Err(err).context("Failed to run package manager detection script"), }; // exit 1 = 这个候选名没被任何 PM 识别;不是错,继续下一个候选。 diff --git a/app/src/autoupdate/mac.rs b/app/src/autoupdate/mac.rs index 367c8663a34..7f5f6d5edf5 100644 --- a/app/src/autoupdate/mac.rs +++ b/app/src/autoupdate/mac.rs @@ -192,9 +192,8 @@ fn oss_open_installer() -> Result<()> { let mut autoupdate_dir = warp_core::paths::cache_dir(); autoupdate_dir.push("autoupdate"); - let dmg = find_latest_dmg(&autoupdate_dir).ok_or_else(|| { - anyhow!("openWarp: 找不到已下载的 dmg(目录: {autoupdate_dir:?})") - })?; + let dmg = find_latest_dmg(&autoupdate_dir) + .ok_or_else(|| anyhow!("openWarp: 找不到已下载的 dmg(目录: {autoupdate_dir:?})"))?; log::info!("openWarp: 准备打开安装 dmg {dmg:?}"); @@ -796,18 +795,12 @@ async fn download_dmg( if downloaded - last_reported >= REPORT_BYTES_THRESHOLD || last_reported_at.elapsed() >= REPORT_TIME_THRESHOLD { - on_progress(DownloadProgress { - downloaded, - total, - }); + on_progress(DownloadProgress { downloaded, total }); last_reported = downloaded; last_reported_at = Instant::now(); } } - on_progress(DownloadProgress { - downloaded, - total, - }); + on_progress(DownloadProgress { downloaded, total }); file.sync_data().await?; log::info!("Wrote DMG to tempfile at {:?}", &dmg_file); @@ -867,9 +860,7 @@ fn update_url(channel: Channel, version: &str) -> String { release.tag_name ); } - return format!( - "https://github.com/zerx-lab/warp/releases/download/v{version}/{asset}" - ); + return format!("https://github.com/zerx-lab/warp/releases/download/v{version}/{asset}"); } format!( "{}/{}", diff --git a/app/src/autoupdate/mod.rs b/app/src/autoupdate/mod.rs index d734d7531a9..75cb1e19fca 100644 --- a/app/src/autoupdate/mod.rs +++ b/app/src/autoupdate/mod.rs @@ -468,12 +468,7 @@ impl AutoupdateState { }) => { // openWarp(Channel::Oss):走和官方一致的下载流程,平台 download_update_and_cleanup // 内部在 OSS 分支自己挑选合适的资产并跳过 codesign verify。 - self.download_new_update( - update_id.clone(), - request_type, - new_version.clone(), - ctx, - ); + self.download_new_update(update_id.clone(), request_type, new_version.clone(), ctx); // We report the update status after attempting to download the update. return; } @@ -551,8 +546,7 @@ impl AutoupdateState { // spawn_stream_local 收到后写入 self.download_progress 并 notify。 // 用 unbounded channel 避免下载阻塞在 send 上;UI 每次只读最新进度, // 中间 backlog 由 model 处理时直接覆盖,不会"卡帧"。 - let (progress_tx, progress_rx) = - futures::channel::mpsc::unbounded::(); + let (progress_tx, progress_rx) = futures::channel::mpsc::unbounded::(); let on_progress: ProgressCallback = Arc::new(move |p| { // 接收端断开(下载完成 / model 销毁)时忽略,不影响下载本体。 let _ = progress_tx.unbounded_send(p); diff --git a/app/src/autoupdate/windows.rs b/app/src/autoupdate/windows.rs index 5a38421fc99..ba2ec075ef7 100644 --- a/app/src/autoupdate/windows.rs +++ b/app/src/autoupdate/windows.rs @@ -116,18 +116,12 @@ pub(super) async fn download_update_and_cleanup( if downloaded - last_reported >= REPORT_BYTES_THRESHOLD || last_reported_at.elapsed() >= REPORT_TIME_THRESHOLD { - on_progress(DownloadProgress { - downloaded, - total, - }); + on_progress(DownloadProgress { downloaded, total }); last_reported = downloaded; last_reported_at = Instant::now(); } } - on_progress(DownloadProgress { - downloaded, - total, - }); + on_progress(DownloadProgress { downloaded, total }); } else { // 复用之前下载好的同名 installer:不再发起新请求,只补一次进度上报 // 让 UI 直接显示 100%。 diff --git a/app/src/bin/zap_oss.rs b/app/src/bin/zap_oss.rs index 0f294bf4578..edfcf9df9d3 100644 --- a/app/src/bin/zap_oss.rs +++ b/app/src/bin/zap_oss.rs @@ -5,17 +5,23 @@ use anyhow::Result; use warp_core::{ channel::{Channel, ChannelConfig, ChannelState}, - features::DEBUG_FLAGS, + features::{FeatureFlag, DEBUG_FLAGS}, AppId, }; -#[cfg(all(target_os = "windows", feature = "windows_high_performance_gpu_default"))] +#[cfg(all( + target_os = "windows", + feature = "windows_high_performance_gpu_default" +))] #[allow(non_upper_case_globals)] #[no_mangle] #[used] pub static NvOptimusEnablement: u32 = 1; -#[cfg(all(target_os = "windows", feature = "windows_high_performance_gpu_default"))] +#[cfg(all( + target_os = "windows", + feature = "windows_high_performance_gpu_default" +))] #[allow(non_upper_case_globals)] #[no_mangle] #[used] @@ -35,12 +41,14 @@ fn main() -> Result<()> { if cfg!(debug_assertions) { state = state.with_additional_features(DEBUG_FLAGS); } + // zap-oss 是本地人工测试入口; 显式启用仍处于 Dogfood 阶段的项目组织功能。 + // 其他发布通道继续只由其各自的 feature flag 列表决定是否开放。 + state = state.with_additional_features(&[FeatureFlag::RepositoryWorkspaces]); // 始终启用 IME marked-text 渲染:winit 的 IME 路径在 macOS / Windows 都支持, // 但若不在此处显式开启,Zap 会把 preedit / 输入合成更新整体丢弃,只剩 OS 的候选窗 // 可见 —— 在 Windows 上对日文 / 中文 / 韩文输入都属于实质性损坏。 #[cfg(any(target_os = "macos", target_os = "windows"))] { - use warp_core::features::FeatureFlag; state = state.with_additional_features(&[FeatureFlag::ImeMarkedText]); } ChannelState::set(state); diff --git a/app/src/cloud_object/mod.rs b/app/src/cloud_object/mod.rs index 686e110ddc4..5c2c4561912 100644 --- a/app/src/cloud_object/mod.rs +++ b/app/src/cloud_object/mod.rs @@ -27,9 +27,7 @@ use crate::{ appearance::Appearance, auth::UserUid, channel::ChannelState, - drive::{ - items::WarpDriveItem, ObjectTypeAndId, ZapDriveObjectArgs, ZapDriveObjectSettings, - }, + drive::{items::WarpDriveItem, ObjectTypeAndId, ZapDriveObjectArgs, ZapDriveObjectSettings}, persistence::ModelEvent, server::ids::{ClientId, HashableId, HashedSqliteId, ObjectUid, ServerId, SyncId, ToServerId}, server_time::ServerTimestamp, diff --git a/app/src/code/editor/find/view.rs b/app/src/code/editor/find/view.rs index a9ba53a88d6..496aea06726 100644 --- a/app/src/code/editor/find/view.rs +++ b/app/src/code/editor/find/view.rs @@ -454,12 +454,16 @@ impl CodeEditorFind { }, self.searcher.as_ref(app).match_count() ); - Text::new_inline(label, appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(blended_colors::text_sub( - appearance.theme(), - appearance.theme().surface_1(), - )) - .finish() + Text::new_inline( + label, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(blended_colors::text_sub( + appearance.theme(), + appearance.theme().surface_1(), + )) + .finish() } #[allow(clippy::too_many_arguments)] diff --git a/app/src/completer/mod.rs b/app/src/completer/mod.rs index 67a5337fd66..038ade6bcda 100644 --- a/app/src/completer/mod.rs +++ b/app/src/completer/mod.rs @@ -199,9 +199,7 @@ impl PathCompletionContext for SessionContext { // sends escape sequences to a raw remote shell. Return empty without // caching so we retry after the remote server handshake finishes. if let SessionType::WarpifiedRemote { host_id: None } = self.session.session_type() { - if FeatureFlag::SshRemoteServer.is_enabled() - && !self.session.is_legacy_ssh_session() - { + if FeatureFlag::SshRemoteServer.is_enabled() && !self.session.is_legacy_ssh_session() { return Arc::new(vec![]); } } diff --git a/app/src/crash_reporting/local_minidump.rs b/app/src/crash_reporting/local_minidump.rs index e23fb0ca5a8..16238d0d763 100644 --- a/app/src/crash_reporting/local_minidump.rs +++ b/app/src/crash_reporting/local_minidump.rs @@ -122,8 +122,7 @@ pub fn run_server(socket_path: &Path) -> anyhow::Result<()> { .join("crash-dumps"); std::fs::create_dir_all(&dump_dir)?; - let dump_path = - dump_dir.join(format!("zap-minidump-{}.dmp", Uuid::new_v4().simple())); + let dump_path = dump_dir.join(format!("zap-minidump-{}.dmp", Uuid::new_v4().simple())); let file = File::create(&dump_path)?; *self.pending_dump_path.lock() = Some(dump_path.clone()); Ok((file, dump_path)) diff --git a/app/src/drive/import/modal_body.rs b/app/src/drive/import/modal_body.rs index 064ca46ffaa..da2b8024335 100644 --- a/app/src/drive/import/modal_body.rs +++ b/app/src/drive/import/modal_body.rs @@ -38,8 +38,7 @@ pub(super) const IMPORT_FONT_SIZE: f32 = 14.; pub(super) const INDENT_MARGIN: f32 = 22.; pub(super) const BASE_INDENT: f32 = 30.; -const FILE_TYPE_DOCS_URL: &str = - ""; +const FILE_TYPE_DOCS_URL: &str = ""; const SUPPORTED_FILE_TYPE_TEXT: &str = "md, yaml, yml"; #[cfg(test)] diff --git a/app/src/integration_testing/sftp.rs b/app/src/integration_testing/sftp.rs index b8b3d6a0bad..323ec83e1e9 100644 --- a/app/src/integration_testing/sftp.rs +++ b/app/src/integration_testing/sftp.rs @@ -36,9 +36,7 @@ pub fn sftp_browser_view(app: &App, window_id: WindowId) -> ViewHandle (tempfile::TempDir, Arc) { +pub fn create_mock_backend(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Arc) { let temp_dir = tempfile::tempdir().expect("创建临时目录失败"); for (path, content) in files { let full_path = temp_dir.path().join(path); @@ -47,8 +45,8 @@ pub fn create_mock_backend( } std::fs::write(&full_path, content).expect("写入测试文件失败"); } - let backend = Arc::new(InMemorySftpBackend::new(temp_dir.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp_dir.path().to_path_buf())) as Arc; (temp_dir, backend) } @@ -61,11 +59,7 @@ pub fn open_sftp_pane_with_mock( app: &mut App, files: &[(&str, &[u8])], ) -> (WindowId, tempfile::TempDir) { - let window_id = app.read(|ctx| { - ctx.windows() - .active_window() - .expect("应有活跃窗口") - }); + let window_id = app.read(|ctx| ctx.windows().active_window().expect("应有活跃窗口")); let workspace = super::view_getters::workspace_view(app, window_id); app.update(|ctx| { diff --git a/app/src/integration_testing/ssh_manager/mod.rs b/app/src/integration_testing/ssh_manager/mod.rs index 5672678de07..cc9928545ed 100644 --- a/app/src/integration_testing/ssh_manager/mod.rs +++ b/app/src/integration_testing/ssh_manager/mod.rs @@ -2,4 +2,7 @@ mod assertions; mod step; pub use assertions::*; -pub use step::{create_folder_via_db, create_server_via_db, open_ssh_manager_panel, save_server, select_group_by_id}; +pub use step::{ + create_folder_via_db, create_server_via_db, open_ssh_manager_panel, save_server, + select_group_by_id, +}; diff --git a/app/src/integration_testing/ssh_manager/step.rs b/app/src/integration_testing/ssh_manager/step.rs index 50d201cf731..6f1c2d97264 100644 --- a/app/src/integration_testing/ssh_manager/step.rs +++ b/app/src/integration_testing/ssh_manager/step.rs @@ -6,10 +6,10 @@ use std::sync::{Arc, Mutex}; use warp_ssh_manager::{SshRepository, SshServerInfo}; -use warpui::SingletonEntity; -use warpui::TypedActionView; use warpui::integration::TestStep; use warpui::windowing::WindowManager; +use warpui::SingletonEntity; +use warpui::TypedActionView; use crate::ssh_manager::server_view::SshServerAction; use crate::workspace::{Workspace, WorkspaceAction}; diff --git a/app/src/launch_configs/launch_config_tests.rs b/app/src/launch_configs/launch_config_tests.rs index 98bd01f51d3..20c999c8b82 100644 --- a/app/src/launch_configs/launch_config_tests.rs +++ b/app/src/launch_configs/launch_config_tests.rs @@ -15,6 +15,7 @@ fn single_tab_snapshot(root: PaneNodeSnapshot) -> AppState { AppState { windows: vec![WindowSnapshot { tabs: vec![TabSnapshot { + repository_workspace_id: None, custom_title: None, default_directory_color: None, selected_color: SelectedTabColor::default(), @@ -23,6 +24,8 @@ fn single_tab_snapshot(root: PaneNodeSnapshot) -> AppState { right_panel: None, }], active_tab_index: 0, + active_repository_workspace_id: None, + repository_workspace_states: Vec::new(), bounds: None, quake_mode: false, universal_search_width: None, @@ -48,6 +51,8 @@ fn multi_tab_snapshot(active_tab_index: usize, tabs: Vec) -> AppSta windows: vec![WindowSnapshot { tabs, active_tab_index, + active_repository_workspace_id: None, + repository_workspace_states: Vec::new(), bounds: None, quake_mode: false, universal_search_width: None, @@ -233,6 +238,7 @@ fn test_config_with_active_tab_index() { 1, vec![ TabSnapshot { + repository_workspace_id: None, custom_title: None, default_directory_color: None, selected_color: SelectedTabColor::default(), @@ -275,6 +281,7 @@ fn test_config_with_active_tab_index_and_filtered_tabs() { 1, vec![ TabSnapshot { + repository_workspace_id: None, custom_title: None, default_directory_color: None, selected_color: SelectedTabColor::default(), @@ -298,6 +305,7 @@ fn test_config_with_active_tab_index_and_filtered_tabs() { right_panel: None, }, TabSnapshot { + repository_workspace_id: None, custom_title: None, default_directory_color: None, selected_color: SelectedTabColor::default(), @@ -339,6 +347,7 @@ fn test_config_with_active_tab_being_filtered() { 1, vec![ TabSnapshot { + repository_workspace_id: None, custom_title: None, default_directory_color: None, selected_color: SelectedTabColor::default(), @@ -368,6 +377,7 @@ fn test_config_with_active_tab_being_filtered() { right_panel: None, }, TabSnapshot { + repository_workspace_id: None, custom_title: None, default_directory_color: None, selected_color: SelectedTabColor::default(), diff --git a/app/src/launch_configs/save_modal.rs b/app/src/launch_configs/save_modal.rs index acad7138719..6e424b0735c 100644 --- a/app/src/launch_configs/save_modal.rs +++ b/app/src/launch_configs/save_modal.rs @@ -423,7 +423,9 @@ impl LaunchConfigSaveModal { appearance.theme().active_ui_text_color().into(), Default::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .finish(), ) .with_padding_left(SIDE_PADDING) @@ -528,10 +530,7 @@ impl LaunchConfigSaveModal { .ui_builder() .link( crate::t!("launch-config-link-to-documentation"), - Some( - "" - .to_string(), - ), + Some("".to_string()), None, self.mouse_states.documentation_link_state.clone(), ) diff --git a/app/src/lib.rs b/app/src/lib.rs index 7e97606daaf..5b83de1566d 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -62,7 +62,7 @@ mod prefix; mod preview_config_migration; mod pricing; mod profiling; -mod projects; +mod project_organization; mod prompt; mod quit_warning; #[allow(dead_code)] @@ -73,9 +73,9 @@ mod search_bar; mod server; mod server_time; mod session_management; +mod sftp_manager; mod shell_indicator; mod skill_manager; -mod sftp_manager; mod ssh_manager; mod suggestions; mod system; @@ -213,7 +213,7 @@ use crate::notebooks::manager::NotebookManager; use crate::notebooks::NotebookObject; use crate::palette::PaletteMode; use crate::persistence::PersistenceWriter; -use crate::projects::ProjectManagementModel; +use crate::project_organization::model::ProjectOrganizationModel; use crate::server::experiments::ServerExperiments; use crate::session_management::{RunningSessionSummary, SessionNavigationData}; use crate::settings::manager::SettingsManager; @@ -1122,7 +1122,8 @@ fn initialize_app( experiments, ai_queries, multi_agent_conversations, - persisted_projects, + persisted_repositories, + persisted_repository_workspaces, persisted_project_rules, persisted_ignored_suggestions, persisted_mcp_server_installations, @@ -1141,7 +1142,8 @@ fn initialize_app( sqlite_data.experiments, sqlite_data.ai_queries, sqlite_data.multi_agent_conversations, - sqlite_data.projects, + sqlite_data.repositories, + sqlite_data.repository_workspaces, sqlite_data.project_rules, sqlite_data.ignored_suggestions, sqlite_data.mcp_server_installations, @@ -1166,6 +1168,7 @@ fn initialize_app( Default::default(), Default::default(), Default::default(), + Default::default(), ) }); @@ -1435,8 +1438,16 @@ fn initialize_app( ctx.add_singleton_model(|_| GitStatusUpdateModel::new()); } - ctx.add_singleton_model(|ctx| { - ProjectManagementModel::new(persisted_projects, persistence_writer.sender(), ctx) + let project_organization_persistence = + persistence::RepositoryPersistence::new(persistence_writer.sender()); + ctx.add_singleton_model(move |ctx| { + ProjectOrganizationModel::try_new( + persisted_repositories, + persisted_repository_workspaces, + project_organization_persistence, + ctx, + ) + .unwrap_or_else(|error| panic!("Failed to initialize project organization: {error:#}")) }); ctx.add_singleton_model(move |_| History::new(command_history)); @@ -1811,6 +1822,18 @@ fn app_callbacks(is_integration_test: bool) -> warpui::platform::AppCallbacks { manager.close_notebooks(ctx); }); + // 在终止持久化线程前保存活动 terminal block。否则仍在运行的命令(例如 Claude)尚未 + // 产生完成事件,应用下次启动后就会从历史中消失。 + for window_id in ctx.window_ids().collect_vec() { + if let Some(workspaces) = ctx.views_of_type::(window_id) { + for workspace in workspaces { + workspace.update(ctx, |workspace, ctx| { + workspace.persist_active_terminal_blocks_for_shutdown(ctx); + }); + } + } + } + PersistenceWriter::handle(ctx).update(ctx, |writer, _ctx| { writer.terminate(); }); diff --git a/app/src/menu_test.rs b/app/src/menu_test.rs index b3fea608ee2..86dc91f7451 100644 --- a/app/src/menu_test.rs +++ b/app/src/menu_test.rs @@ -483,8 +483,7 @@ fn test_nested_submenu_leaf_hover_is_handled_at_child_depth() { assert_eq!(menu.selected_index(), Some(0)); assert_eq!( - menu - .menu + menu.menu .selected_submenu() .and_then(|submenu| submenu.selected_index()), Some(0) diff --git a/app/src/notebooks/image/mod.rs b/app/src/notebooks/image/mod.rs index 659bc73deb7..f1b3bcac5b7 100644 --- a/app/src/notebooks/image/mod.rs +++ b/app/src/notebooks/image/mod.rs @@ -18,7 +18,9 @@ use warpui::{ use crate::{ appearance::Appearance, code::buffer_location::RemotePath, - pane_group::{focus_state::PaneFocusHandle, pane::view, BackingView, PaneConfiguration, PaneEvent}, + pane_group::{ + focus_state::PaneFocusHandle, pane::view, BackingView, PaneConfiguration, PaneEvent, + }, terminal::model::session::Session, }; diff --git a/app/src/notebooks/notebook.rs b/app/src/notebooks/notebook.rs index 4c437547d54..fae7b6bdf6e 100644 --- a/app/src/notebooks/notebook.rs +++ b/app/src/notebooks/notebook.rs @@ -52,8 +52,7 @@ use crate::{ cmd_or_ctrl_shift, drive::{ drive_helpers::has_feature_gated_anonymous_user_reached_notebook_limit, - export::ExportManager, items::WarpDriveItemId, ObjectTypeAndId, - ZapDriveObjectSettings, + export::ExportManager, items::WarpDriveItemId, ObjectTypeAndId, ZapDriveObjectSettings, }, editor::{ EditOrigin, EditorView, Event as EditorEvent, InteractionState, @@ -1853,11 +1852,7 @@ impl NotebookView { // Load the server's version of the notebook now that the object store has been updated. // This will also switch back to edit mode if there isn't an active editor. if let Some(notebook) = ObjectStoreModel::as_ref(ctx).get_notebook(&id) { - self.load( - notebook.clone(), - &ZapDriveObjectSettings::default(), - ctx, - ); + self.load(notebook.clone(), &ZapDriveObjectSettings::default(), ctx); } ctx.notify(); } diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 91186caad01..dc2f86b9d3c 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -221,8 +221,7 @@ fn resolve_tab_config_shell(name: &str, ctx: &AppContext) -> Option>(); + for pane_id in pane_ids { + if let Some(terminal_pane) = self.terminal_session_by_id(pane_id) { + terminal_pane.persist_active_block_for_shutdown(ctx); + } + } + } + /// Executes the provided callback for each CodeView contained within /// this pane group. pub fn for_all_code_panes( diff --git a/app/src/pane_group/pane/image_pane.rs b/app/src/pane_group/pane/image_pane.rs index 1bceee364ae..5b64dda0b1c 100644 --- a/app/src/pane_group/pane/image_pane.rs +++ b/app/src/pane_group/pane/image_pane.rs @@ -105,9 +105,7 @@ impl PaneContent for ImagePane { ctx.subscribe_to_view( &self.image_view(ctx), move |pane_group, _, event, ctx| match event { - ImageViewerEvent::Opened => { - ctx.emit(crate::pane_group::Event::AppStateChanged) - } + ImageViewerEvent::Opened => ctx.emit(crate::pane_group::Event::AppStateChanged), ImageViewerEvent::Pane(pane_event) => { pane_group.handle_pane_event(pane_id, pane_event, ctx) } @@ -140,7 +138,8 @@ impl PaneContent for ImagePane { } fn focus(&self, ctx: &mut ViewContext) { - self.image_view(ctx).update(ctx, |view, ctx| view.focus(ctx)); + self.image_view(ctx) + .update(ctx, |view, ctx| view.focus(ctx)); } fn shareable_link( diff --git a/app/src/pane_group/pane/mod.rs b/app/src/pane_group/pane/mod.rs index 1fd7a3e321e..ad0e3365235 100644 --- a/app/src/pane_group/pane/mod.rs +++ b/app/src/pane_group/pane/mod.rs @@ -36,8 +36,8 @@ use std::{any::Any, fmt::Display}; use crate::pane_group::focus_state::PaneFocusHandle; use crate::pane_group::pane::get_started_view::GetStartedView; -use crate::ssh_manager::server_view::SshServerView; use crate::sftp_manager::browser::SftpBrowserView; +use crate::ssh_manager::server_view::SshServerView; use crate::view_components::action_button::ActionButton; use crate::{ ai::execution_profiles::editor::ExecutionProfileEditorView, @@ -382,9 +382,7 @@ impl PaneId { } /// Creates a [`PaneId`] from a [`PaneView`] entity ID. - pub fn from_sftp_pane_view( - sftp_pane_view: &ViewHandle>, - ) -> Self { + pub fn from_sftp_pane_view(sftp_pane_view: &ViewHandle>) -> Self { Self::new(IPaneType::Sftp, sftp_pane_view) } diff --git a/app/src/pane_group/pane/sftp_pane.rs b/app/src/pane_group/pane/sftp_pane.rs index f6362ac4187..c0cb5186baa 100644 --- a/app/src/pane_group/pane/sftp_pane.rs +++ b/app/src/pane_group/pane/sftp_pane.rs @@ -8,10 +8,8 @@ use warpui::{AppContext, ModelHandle, View, ViewContext, ViewHandle}; -use crate::pane_group::{ - BackingView, PaneConfiguration, PaneContent, PaneGroup, PaneView, -}; use crate::app_state::LeafContents; +use crate::pane_group::{BackingView, PaneConfiguration, PaneContent, PaneGroup, PaneView}; use crate::sftp_manager::browser::SftpBrowserView; use super::{DetachType, PaneId, ShareableLink, ShareableLinkError}; diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index c6dd2ea97f2..3b5d5a53db0 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1,7 +1,7 @@ //! Implementation of terminal panes. #[cfg(feature = "local_fs")] use crate::pane_group::CodeSource; -use std::sync::mpsc::SyncSender; +use std::sync::{mpsc::SyncSender, Arc}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use warp_multi_agent_api as multi_agent_api; @@ -154,6 +154,39 @@ impl TerminalPane { } } + /// 在应用退出前将活动命令作为中断 block 保存。 + pub(in crate::pane_group) fn persist_active_block_for_shutdown(&self, ctx: &AppContext) { + if !*GeneralSettings::as_ref(ctx).restore_session + || !AppExecutionMode::as_ref(ctx).can_save_session() + { + return; + } + + let terminal_view = self.terminal_view(ctx); + let view = terminal_view.as_ref(ctx); + let Some(is_local) = view.active_session_is_local(ctx) else { + return; + }; + let Some(block) = view.active_block_snapshot_for_shutdown() else { + return; + }; + let Some(sender) = &self.model_event_sender else { + return; + }; + + let event = ModelEvent::SaveBlock(BlockCompleted { + pane_id: self.uuid.clone(), + block: Arc::new(block), + is_local, + }); + if let Err(err) = sender.send(event) { + log::error!( + "Error sending active block shutdown event for terminal id {:?}: {err:?}", + self.uuid + ); + } + } + pub fn session_navigation_data( &self, pane_group_id: EntityId, diff --git a/app/src/pane_group/pane/welcome_view.rs b/app/src/pane_group/pane/welcome_view.rs index 2c8154fd6ac..0ccd4dad9b0 100644 --- a/app/src/pane_group/pane/welcome_view.rs +++ b/app/src/pane_group/pane/welcome_view.rs @@ -22,7 +22,7 @@ use crate::pane_group::focus_state::PaneFocusHandle; use crate::pane_group::{ pane::view, BackingView, NewTerminalOptions, PaneConfiguration, PaneEvent, PanesLayout, }; -use crate::projects::ProjectManagementModel; +use crate::project_organization::model::ProjectOrganizationModel; use crate::search::binding_source::BindingSource; use crate::search::welcome_palette::{Event as WelcomePaletteEvent, WelcomePalette}; use crate::util::bindings::{keybinding_name_to_display_string, BindingGroup, CustomAction}; @@ -173,7 +173,19 @@ impl WelcomeView { fn open_project_conversation(&mut self, path: &String, ctx: &mut ViewContext) { let path_buf = PathBuf::from(path); - // todo(jparker): What happens if the user deletes a project folder between when this list was generated and now? + if let Err(error) = ProjectOrganizationModel::handle(ctx).update(ctx, |model, ctx| { + model.touch_repository_path(&path_buf, ctx) + }) { + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!("Failed to open repository: {error}")), + window_id, + ctx, + ); + }); + return; + } update_workspace(ctx.window_id(), ctx, |workspace, ctx| { // Create a new terminal tab with the project path as the initial directory workspace.add_tab_with_pane_layout( @@ -206,11 +218,6 @@ impl WelcomeView { }); } }); - - // Update project accesstime - ProjectManagementModel::handle(ctx).update(ctx, |projects, ctx| { - projects.upsert_project(path_buf, ctx); - }); }); } } @@ -318,20 +325,28 @@ impl TypedActionView for WelcomeView { /// WARNING - Don't use. The [`crate::workspace::WorkspaceAction::OpenRepository`] is the /// source-of-truth for this now. fn save_and_open_project(path: String, window_id: WindowId, ctx: &mut AppContext) { - ProjectManagementModel::handle(ctx).update(ctx, |projects, ctx| { - let path_buf = PathBuf::from(&path); - projects.upsert_project(path_buf, ctx); - update_workspace(window_id, ctx, move |workspace, ctx| { - workspace.add_tab_with_pane_layout( - PanesLayout::SingleTerminal(Box::new( - NewTerminalOptions::default() - .with_initial_directory(path) - .with_homepage_hidden(), - )), - Arc::new(HashMap::new()), - None, + if let Err(error) = ProjectOrganizationModel::handle(ctx).update(ctx, |model, ctx| { + model.touch_repository_path(PathBuf::from(&path), ctx) + }) { + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!("Failed to open repository: {error}")), + window_id, ctx, ); }); + return; + } + update_workspace(window_id, ctx, move |workspace, ctx| { + workspace.add_tab_with_pane_layout( + PanesLayout::SingleTerminal(Box::new( + NewTerminalOptions::default() + .with_initial_directory(path) + .with_homepage_hidden(), + )), + Arc::new(HashMap::new()), + None, + ctx, + ); }); } diff --git a/app/src/persistence/mod.rs b/app/src/persistence/mod.rs index 92acd0e7044..cfac03aaa5a 100644 --- a/app/src/persistence/mod.rs +++ b/app/src/persistence/mod.rs @@ -51,7 +51,7 @@ use crate::workflows::WorkflowObject; use crate::workspaces::user_profiles::UserProfileWithUID; use crate::workspaces::workspace::{Workspace as WorkspaceMetadata, WorkspaceUid}; -use self::model::{AgentConversation, AgentConversationData, Project}; +use self::model::{AgentConversation, AgentConversationData, Repository, RepositoryWorkspace}; #[cfg(any(feature = "local_fs", feature = "integration_tests"))] pub use sqlite::database_file_path; @@ -121,6 +121,85 @@ pub struct WriterHandles { pub sender: SyncSender, } +#[derive(Debug)] +pub enum RepositoryPersistenceOperation { + UpsertRepository { + repository: model::Repository, + }, + UpsertRepositoryWithWorkspace { + repository: model::Repository, + workspace: model::RepositoryWorkspace, + }, + DeleteRepository { + repository_id: String, + }, + UpsertRepositoryWorkspace { + workspace: model::RepositoryWorkspace, + }, + DeleteRepositoryWorkspace { + workspace_id: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum RepositoryPersistenceError { + #[error("repository persistence is unavailable")] + Unavailable, + #[error("SQLite writer is paused")] + Paused, + #[error("repository persistence request channel disconnected: {details}")] + RequestDisconnected { details: String }, + #[error("repository persistence response channel disconnected: {details}")] + ResponseDisconnected { details: String }, + #[error("repository persistence database operation failed: {details}")] + Database { details: String }, +} + +#[derive(Debug)] +pub struct RepositoryPersistenceRequest { + pub operation: RepositoryPersistenceOperation, + pub response: SyncSender>, +} + +#[derive(Clone)] +pub struct RepositoryPersistence { + sender: Option>, +} + +impl RepositoryPersistence { + /// 创建 repository persistence acknowledgement client. + pub fn new(sender: Option>) -> Self { + Self { sender } + } + + /// 执行 repository persistence 操作并等待 SQLite writer acknowledgement. + pub fn execute( + &self, + operation: RepositoryPersistenceOperation, + ) -> Result<(), RepositoryPersistenceError> { + let sender = self + .sender + .as_ref() + .ok_or(RepositoryPersistenceError::Unavailable)?; + let (response, receiver) = std::sync::mpsc::sync_channel(1); + sender + .send(ModelEvent::RepositoryPersistence( + RepositoryPersistenceRequest { + operation, + response, + }, + )) + .map_err(|error| RepositoryPersistenceError::RequestDisconnected { + details: error.to_string(), + })?; + receiver + .recv() + .map_err(|error| RepositoryPersistenceError::ResponseDisconnected { + details: error.to_string(), + })? + } +} + /// Model for interacting with the writer thread. pub struct PersistenceWriter { thread_handle: Option>, @@ -197,7 +276,8 @@ pub struct PersistedData { pub experiments: Vec, pub ai_queries: Vec, pub multi_agent_conversations: Vec, - pub projects: Vec, + pub repositories: Vec, + pub repository_workspaces: Vec, pub project_rules: Vec, pub ignored_suggestions: Vec<(String, SuggestionType)>, pub mcp_server_installations: HashMap, @@ -322,12 +402,7 @@ pub enum ModelEvent { UpsertCurrentUserInformation { user_information: PersistedCurrentUserInformation, }, - UpsertProject { - project: Project, - }, - DeleteProject { - path: String, - }, + RepositoryPersistence(RepositoryPersistenceRequest), UpsertMCPServerEnvironmentVariables { mcp_server_uuid: Vec, environment_variables: String, diff --git a/app/src/persistence/sqlite.rs b/app/src/persistence/sqlite.rs index eb2bef3c454..0cc3e006d5f 100644 --- a/app/src/persistence/sqlite.rs +++ b/app/src/persistence/sqlite.rs @@ -41,16 +41,17 @@ use super::block_list::{ }; use super::model::{ self, ActiveMCPServer, CurrentUserInformation, MCPEnvironmentVariables, NewActiveMCPServer, - NewApp, NewCommand, NewFolder, NewNotebook, NewServerExperiment, NewTab, NewTeam, NewWindow, - NewWorkspace, NewWorkspaceTeam, ObjectMetadata, ObjectPermissions, Project, Tab, Window, + NewApp, NewCommand, NewFolder, NewNotebook, NewRepositoryWorkspaceWindowState, + NewServerExperiment, NewTab, NewTeam, NewWindow, NewWorkspace, NewWorkspaceTeam, + ObjectMetadata, ObjectPermissions, Repository, RepositoryWorkspace, Tab, Window, AI_DOCUMENT_PANE_KIND, AI_FACT_PANE_KIND, CODE_PANE_KIND, ENV_VAR_COLLECTION_PANE_KIND, EXECUTION_PROFILE_EDITOR_PANE_KIND, MCP_SERVER_PANE_KIND, NOTEBOOK_PANE_KIND, SETTINGS_PANE_KIND, TERMINAL_PANE_KIND, WELCOME_PANE_KIND, WORKFLOW_PANE_KIND, }; use super::schema; use super::{ - BlockCompleted, FinishedCommandMetadata, ModelEvent, PersistedData, StartedCommandMetadata, - WriterHandles, + BlockCompleted, FinishedCommandMetadata, ModelEvent, PersistedData, RepositoryPersistenceError, + RepositoryPersistenceOperation, StartedCommandMetadata, WriterHandles, }; use crate::ai::agent::conversation::AIConversationId; use crate::ai::ambient_agents::AmbientAgentTaskId; @@ -64,8 +65,8 @@ use crate::ai::mcp::{ }; use crate::app_state::{ AIFactPaneSnapshot, AmbientAgentPaneSnapshot, CodeReviewPaneSnapshot, - EnvVarCollectionPaneSnapshot, LeftPanelSnapshot, RightPanelSnapshot, SettingsPaneSnapshot, - WorkflowPaneSnapshot, + EnvVarCollectionPaneSnapshot, LeftPanelSnapshot, RepositoryWorkspaceWindowStateSnapshot, + RightPanelSnapshot, SettingsPaneSnapshot, WorkflowPaneSnapshot, }; use crate::auth::AuthStateProvider; use crate::auth::PersistedCurrentUserInformation; @@ -88,6 +89,7 @@ use crate::persistence::model::{ NewGenericStringObject, NewObjectStoreRefresh, NewPersistedObjectAction, NewTeamSettings, ProjectRules, UserProfile, CODE_REVIEW_PANE_KIND, GET_STARTED_PANE_KIND, }; +use crate::project_organization::domain::RepositoryWorkspaceId; use crate::server::experiments::ServerExperiment; use crate::server::ids::{ClientId, HashableId, ServerId, SyncId, ToServerId}; use crate::server::telemetry::TelemetryEvent; @@ -683,13 +685,27 @@ fn reconstruct_database(path: &Path) -> Result { setup_database(path) } +#[derive(Clone, Copy)] +enum WriterState { + Running, + Paused, +} + fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result { + start_writer_with_state(conn, database_path, WriterState::Running) +} + +fn start_writer_with_state( + conn: SqliteConnection, + database_path: PathBuf, + initial_state: WriterState, +) -> Result { let (tx, rx) = std::sync::mpsc::sync_channel(CHANNEL_SIZE); let mut current_conn = conn; let handle = thread::Builder::new() .name("SQLite Writer".into()) .spawn(move || { - let mut paused = false; + let mut state = initial_state; loop { let events = match rx.recv() { Ok(event) => { @@ -714,7 +730,7 @@ fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result { current_conn = conn; - paused = false; + state = WriterState::Running; log::info!("SQLite Writer is resumed"); } Err(err) => { @@ -723,7 +739,7 @@ fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result { - paused = true; + state = WriterState::Paused; log::info!("SQLite Writer is paused"); if let Err(err) = std::fs::remove_file(&database_path) { @@ -744,10 +760,29 @@ fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result { + let result = match state { + WriterState::Running => execute_repository_persistence_operation( + request.operation, + &mut current_conn, + &database_path, + report_db_error, + ), + WriterState::Paused => Err(RepositoryPersistenceError::Paused), + }; + if request.response.send(result).is_err() { + log::error!( + "Repository persistence requester disconnected before acknowledgement" + ); + } + } event => { - if paused { - log::info!("Ignoring event as SQLite Writer is on pause"); - continue; + match state { + WriterState::Running => {} + WriterState::Paused => { + log::info!("Ignoring event as SQLite Writer is on pause"); + continue; + } } if let Err(err) = handle_model_event(event, &mut current_conn) { report_db_error("Model", err, &database_path); @@ -764,11 +799,13 @@ fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result anyhow::Result<()> { match event { ModelEvent::PauseAndRemoveDatabase | ModelEvent::ReconstructAndResume + | ModelEvent::RepositoryPersistence(_) | ModelEvent::Terminate => { panic!("Unhandled control-flow event {event:?}"); } @@ -817,12 +854,6 @@ fn handle_model_event(event: ModelEvent, connection: &mut SqliteConnection) -> a ModelEvent::DeleteObjects { ids } => { delete_objects(connection, ids).context("error deleting objects") } - ModelEvent::UpsertProject { project } => { - save_project(connection, project).context("error upserting project") - } - ModelEvent::DeleteProject { path } => { - delete_project(connection, &path).context("error deleting project") - } ModelEvent::UpsertWorkspace { workspace } => { save_workspace(connection, *workspace).context("error upserting workspace") } @@ -945,6 +976,53 @@ fn handle_model_event(event: ModelEvent, connection: &mut SqliteConnection) -> a } } +fn handle_repository_persistence_operation( + operation: RepositoryPersistenceOperation, + connection: &mut SqliteConnection, +) -> anyhow::Result<()> { + match operation { + RepositoryPersistenceOperation::UpsertRepository { repository } => { + save_repository(connection, repository).context("error upserting repository") + } + RepositoryPersistenceOperation::UpsertRepositoryWithWorkspace { + repository, + workspace, + } => connection.immediate_transaction(|connection| { + save_repository(connection, repository).context("error upserting repository")?; + save_repository_workspace(connection, workspace) + .context("error upserting repository workspace")?; + Ok(()) + }), + RepositoryPersistenceOperation::DeleteRepository { repository_id } => { + delete_repository(connection, &repository_id).context("error deleting repository") + } + RepositoryPersistenceOperation::UpsertRepositoryWorkspace { workspace } => { + save_repository_workspace(connection, workspace) + .context("error upserting repository workspace") + } + RepositoryPersistenceOperation::DeleteRepositoryWorkspace { workspace_id } => { + delete_repository_workspace(connection, &workspace_id) + .context("error deleting repository workspace") + } + } +} + +fn execute_repository_persistence_operation( + operation: RepositoryPersistenceOperation, + connection: &mut SqliteConnection, + database_path: &Path, + report_error: impl FnOnce(&str, anyhow::Error, &Path), +) -> Result<(), RepositoryPersistenceError> { + match handle_repository_persistence_operation(operation, connection) { + Ok(()) => Ok(()), + Err(error) => { + let details = format!("{error:#}"); + report_error("Repository persistence", error, database_path); + Err(RepositoryPersistenceError::Database { details }) + } + } +} + /// Report a database error and additional context for debugging. fn report_db_error(err_kind: &str, err: anyhow::Error, database_path: &Path) { // 数据库有时会缺失或不可访问,这里补充权限和存在性诊断。 @@ -1086,6 +1164,9 @@ fn save_app_state(conn: &mut SqliteConnection, app_state: &AppState) -> Result<( .theme_override .as_ref() .and_then(|k| serde_json::to_string(k).ok()), + active_repository_workspace_id: window + .active_repository_workspace_id + .map(|id| id.to_string()), }; diesel::insert_into(schema::windows::dsl::windows) .values(new_window) @@ -1119,6 +1200,9 @@ fn save_app_state(conn: &mut SqliteConnection, app_state: &AppState) -> Result<( SelectedTabColor::Unset => None, _ => serde_yaml::to_string(&tab.selected_color).ok(), }, + repository_workspace_id: tab + .repository_workspace_id + .map(|id| id.to_string()), }) .collect(); @@ -1126,6 +1210,21 @@ fn save_app_state(conn: &mut SqliteConnection, app_state: &AppState) -> Result<( .values(tabs) .execute(conn)?; + let workspace_window_states = window + .repository_workspace_states + .iter() + .map(|state| NewRepositoryWorkspaceWindowState { + window_id, + repository_workspace_id: state.repository_workspace_id.to_string(), + active_tab_index: state.active_tab_index.try_into().unwrap_or(0), + }) + .collect::>(); + if !workspace_window_states.is_empty() { + diesel::insert_into(schema::repository_workspace_window_states::dsl::repository_workspace_window_states) + .values(workspace_window_states) + .execute(conn)?; + } + // Same ID issue as above. let tab_ids: Vec = schema::tabs::dsl::tabs .filter(schema::tabs::columns::window_id.eq(window_id)) @@ -1604,32 +1703,93 @@ fn decode_path(bytes: Vec) -> PathBuf { } } -fn save_project(conn: &mut SqliteConnection, project: Project) -> Result<()> { - use schema::projects::dsl::*; +fn save_repository( + conn: &mut SqliteConnection, + repository: Repository, +) -> Result<(), diesel::result::Error> { + use schema::repositories::dsl::*; - diesel::insert_into(projects) - .values(project.clone()) - .on_conflict(path) + diesel::insert_into(repositories) + .values(repository.clone()) + .on_conflict(id) .do_update() - .set(&project) + .set(&repository) .execute(conn)?; Ok(()) } -fn get_all_projects(conn: &mut SqliteConnection) -> Result, diesel::result::Error> { - use schema::projects::dsl::*; +fn get_all_repositories( + conn: &mut SqliteConnection, +) -> Result, diesel::result::Error> { + use schema::repositories::dsl::*; - Ok(projects - .load_iter::(conn)? - .filter_map(|item| item.ok()) - .collect_vec()) + let mut repository_rows = repositories.load::(conn)?; + for repository in &mut repository_rows { + if repository.display_name != repository.path { + continue; + } + + let normalized_display_name = Path::new(&repository.path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + Error::DeserializationError(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Failed to normalize migrated repository display name: path `{}` has no valid UTF-8 file name", + repository.path + ), + ))) + })? + .to_string(); + repository.display_name = normalized_display_name; + save_repository(conn, repository.clone())?; + } + + Ok(repository_rows) +} + +fn delete_repository(conn: &mut SqliteConnection, repository_id: &str) -> Result<()> { + use schema::repositories::dsl::*; + + diesel::delete(repositories.filter(id.eq(repository_id))).execute(conn)?; + + Ok(()) +} + +fn save_repository_workspace( + conn: &mut SqliteConnection, + repository_workspace: RepositoryWorkspace, +) -> Result<()> { + use schema::repository_workspaces::dsl::*; + + diesel::insert_into(repository_workspaces) + .values(repository_workspace.clone()) + .on_conflict(id) + .do_update() + .set(&repository_workspace) + .execute(conn)?; + + Ok(()) +} + +fn get_all_repository_workspaces( + conn: &mut SqliteConnection, +) -> Result, diesel::result::Error> { + use schema::repository_workspaces::dsl::*; + + repository_workspaces.load::(conn) } -fn delete_project(conn: &mut SqliteConnection, project_path: &str) -> Result<()> { - use schema::projects::dsl::*; +fn delete_repository_workspace( + conn: &mut SqliteConnection, + repository_workspace_id: &str, +) -> Result<()> { + use schema::repository_workspaces::dsl::*; - diesel::delete(projects.filter(path.eq(project_path))).execute(conn)?; + diesel::delete(repository_workspaces.filter(id.eq(repository_workspace_id))).execute(conn)?; Ok(()) } @@ -2704,6 +2864,12 @@ fn read_node(conn: &mut SqliteConnection, node: model::PaneNode) -> Result std::result::Result { + value + .parse::() + .map_err(|error| Error::DeserializationError(Box::new(error))) +} + fn read_sqlite_data( conn: &mut SqliteConnection, current_user_id: Option, @@ -2724,6 +2890,54 @@ fn read_sqlite_data( .load::(conn)? .grouped_by(&db_windows); + let mut workspace_window_states_by_window = HashMap::new(); + for (window_id, workspace_id, workspace_active_tab_index) in + schema::repository_workspace_window_states::dsl::repository_workspace_window_states + .select(( + schema::repository_workspace_window_states::columns::window_id, + schema::repository_workspace_window_states::columns::repository_workspace_id, + schema::repository_workspace_window_states::columns::active_tab_index, + )) + .load::<(i32, String, i32)>(conn)? + { + let repository_workspace_id = parse_repository_workspace_id(&workspace_id)?; + let workspace_tab_index = workspace_active_tab_index + .try_into() + .map_err(|error| Error::DeserializationError(Box::new(error)))?; + workspace_window_states_by_window + .entry(window_id) + .or_insert_with(Vec::new) + .push(RepositoryWorkspaceWindowStateSnapshot { + repository_workspace_id, + active_tab_index: workspace_tab_index, + }); + } + + let tab_workspace_ids = db_tabs + .iter() + .map(|tabs_for_window| { + tabs_for_window + .iter() + .map(|tab| { + tab.repository_workspace_id + .as_deref() + .map(parse_repository_workspace_id) + .transpose() + }) + .collect::, Error>>() + }) + .collect::, Error>>()?; + let active_workspace_ids = db_windows + .iter() + .map(|window| { + window + .active_repository_workspace_id + .as_deref() + .map(parse_repository_workspace_id) + .transpose() + }) + .collect::, Error>>()?; + let db_panels = schema::panels::dsl::panels .load::(conn)? .into_iter() @@ -2735,9 +2949,12 @@ fn read_sqlite_data( .enumerate() .zip(db_tabs) .map(|((idx, window), tabs_for_window)| { + let tab_workspace_ids = &tab_workspace_ids[idx]; + let active_workspace_id = active_workspace_ids[idx]; let saved_tabs: Vec<_> = tabs_for_window .into_iter() - .filter_map(|tab| { + .zip(tab_workspace_ids.iter().copied()) + .filter_map(|(tab, repository_workspace_id)| { let root = read_root_node(conn, tab.id).ok()?; let panel = db_panels.get(&tab.id); @@ -2750,6 +2967,7 @@ fn read_sqlite_data( .and_then(|s| serde_json::from_str::(s).ok()); Some(TabSnapshot { + repository_workspace_id, root, custom_title: tab.custom_title, default_directory_color: None, @@ -2840,6 +3058,10 @@ fn read_sqlite_data( WindowSnapshot { tabs: saved_tabs, active_tab_index: tab_index, + active_repository_workspace_id: active_workspace_id, + repository_workspace_states: workspace_window_states_by_window + .remove(&window.id) + .unwrap_or_default(), quake_mode: window.quake_mode, bounds, universal_search_width: window.universal_search_width, @@ -3253,7 +3475,8 @@ fn read_sqlite_data( let ai_queries = read_ai_queries(conn)?; let multi_agent_conversations = read_agent_conversations(conn)?; - let projects = get_all_projects(conn)?; + let repositories = get_all_repositories(conn)?; + let repository_workspaces = get_all_repository_workspaces(conn)?; let project_rules = get_all_project_rules(conn)?; let ignored_suggestions = get_all_ignored_suggestions(conn)?; let mcp_server_installations = get_all_mcp_server_installations(conn)?; @@ -3271,7 +3494,8 @@ fn read_sqlite_data( experiments: server_experiments, ai_queries, multi_agent_conversations, - projects, + repositories, + repository_workspaces, project_rules, ignored_suggestions, mcp_server_installations, diff --git a/app/src/persistence/sqlite_tests.rs b/app/src/persistence/sqlite_tests.rs index 1c4e597c597..8d870746fc6 100644 --- a/app/src/persistence/sqlite_tests.rs +++ b/app/src/persistence/sqlite_tests.rs @@ -2,6 +2,13 @@ use std::fs; use std::{path::PathBuf, sync::Arc}; +use diesel::connection::SimpleConnection; +use diesel::migration::{MigrationSource, MigrationVersion}; +use diesel::prelude::*; +use diesel::result::{DatabaseErrorKind, Error as DieselError}; +use diesel::sql_types::{BigInt, Nullable, Text, Timestamp}; +use diesel::sqlite::{Sqlite, SqliteConnection}; +use diesel_migrations::MigrationHarness; use warp_core::features::FeatureFlag; use crate::{ @@ -12,7 +19,12 @@ use crate::{ cloud_object::{Owner, StoredObjectPermissions}, code::editor_management::CodeSource, notebooks::{NotebookObject, NotebookObjectModel}, - persistence::{model::ObjectPermissions, BlockCompleted, ModelEvent}, + persistence::{ + model::{ObjectPermissions, Repository, RepositoryWorkspace}, + BlockCompleted, ModelEvent, RepositoryPersistence, RepositoryPersistenceError, + RepositoryPersistenceOperation, RepositoryPersistenceRequest, + }, + project_organization::domain::RepositoryWorkspaceId, server::ids::ClientId, server_time::ServerTimestamp, tab::SelectedTabColor, @@ -21,9 +33,124 @@ use crate::{ }; use super::{ - decode_path, deduplicate_events, encode_path, read_sqlite_data, save_app_state, setup_database, + decode_path, deduplicate_events, delete_repository, delete_repository_workspace, encode_path, + execute_repository_persistence_operation, get_all_repositories, get_all_repository_workspaces, + read_sqlite_data, save_app_state, save_repository, save_repository_workspace, setup_database, + start_writer, start_writer_with_state, WriterState, }; +// Diesel canonicalizes the directory version `2026-07-11-000000` by removing hyphens. +const REPOSITORY_WORKSPACES_MIGRATION_VERSION: &str = "20260711000000"; +const DROP_LEGACY_PROJECTS_MIGRATION_VERSION: &str = "20260711010000"; + +#[derive(QueryableByName)] +struct SqliteTableCount { + #[diesel(sql_type = BigInt)] + count: i64, +} + +#[derive(QueryableByName)] +struct LegacyProjectRow { + #[diesel(sql_type = Text)] + path: String, + #[diesel(sql_type = Timestamp)] + added_ts: chrono::NaiveDateTime, + #[diesel(sql_type = Nullable)] + last_opened_ts: Option, +} + +fn sqlite_table_count(conn: &mut SqliteConnection, table_name: &str) -> i64 { + diesel::sql_query( + "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .bind::(table_name) + .get_result::(conn) + .expect("sqlite table metadata should load") + .count +} + +fn revert_drop_legacy_projects_migration(conn: &mut SqliteConnection) { + let target_version = MigrationVersion::from(DROP_LEGACY_PROJECTS_MIGRATION_VERSION); + let migrations = + >::migrations( + &persistence::MIGRATIONS, + ) + .expect("embedded migrations should load"); + let migration = migrations + .iter() + .find(|migration| migration.name().version() == target_version) + .expect("drop legacy projects migration should be embedded"); + conn.revert_migration(migration.as_ref()) + .expect("drop legacy projects migration should revert"); +} + +fn revert_repository_workspaces_migration_and_later(conn: &mut SqliteConnection) { + let target_version = MigrationVersion::from(REPOSITORY_WORKSPACES_MIGRATION_VERSION); + let applied_versions = conn + .applied_migrations() + .expect("applied migrations should load"); + let migrations = + >::migrations( + &persistence::MIGRATIONS, + ) + .expect("embedded migrations should load"); + let versions_to_revert = applied_versions + .into_iter() + .take_while(|version| version >= &target_version) + .collect::>(); + + assert!( + versions_to_revert + .iter() + .any(|version| version == &target_version), + "repository workspaces migration should be applied" + ); + for version in versions_to_revert { + let migration = migrations + .iter() + .find(|migration| migration.name().version() == version) + .unwrap_or_else(|| panic!("migration {version} should be embedded")); + conn.revert_migration(migration.as_ref()) + .unwrap_or_else(|error| panic!("migration {version} should revert: {error}")); + } +} + +#[test] +fn all_migrations_remove_legacy_projects_table() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + + assert_eq!(sqlite_table_count(&mut conn, "projects"), 0); +} + +#[test] +fn reverting_drop_legacy_projects_backfills_from_repositories() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174099", + "/tmp/zap-project-backfill", + ); + save_repository(&mut conn, repository.clone()).expect("repository should save"); + + revert_drop_legacy_projects_migration(&mut conn); + + let project = + diesel::sql_query("SELECT path, added_ts, last_opened_ts FROM projects WHERE path = ?") + .bind::(&repository.path) + .get_result::(&mut conn) + .expect("legacy project should be backfilled"); + assert_eq!(project.path, repository.path); + assert_eq!(project.added_ts, repository.created_at); + assert_eq!(project.last_opened_ts, Some(repository.last_opened_at)); + + conn.run_pending_migrations(persistence::MIGRATIONS) + .expect("drop legacy projects migration should run again"); + assert_eq!(sqlite_table_count(&mut conn, "projects"), 0); +} + #[test] fn test_deduplicate_snapshots() { let local_notebook = NotebookObject::new_local( @@ -117,6 +244,7 @@ fn test_deduplicate_no_snapshots() { fn test_terminal_window_snapshot(vertical_tabs_panel_open: bool) -> WindowSnapshot { WindowSnapshot { tabs: vec![TabSnapshot { + repository_workspace_id: None, custom_title: None, root: PaneNodeSnapshot::Leaf(LeafSnapshot { is_focused: true, @@ -143,6 +271,8 @@ fn test_terminal_window_snapshot(vertical_tabs_panel_open: bool) -> WindowSnapsh right_panel: None, }], active_tab_index: 0, + active_repository_workspace_id: None, + repository_workspace_states: Vec::new(), bounds: None, fullscreen_state: Default::default(), quake_mode: false, @@ -192,6 +322,942 @@ fn test_sqlite_round_trips_vertical_tabs_panel_open() { ); } +#[test] +fn repository_workspace_state_round_trips() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174100", + "/tmp/repository-workspace-state", + ); + let workspace = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174101", + &repository.id, + "feature/workspace-state", + "/tmp/repository-workspace-state-feature", + ); + let workspace_id = RepositoryWorkspaceId( + workspace + .id + .parse() + .expect("workspace fixture should have a valid UUID"), + ); + save_repository(&mut conn, repository).expect("repository should save"); + save_repository_workspace(&mut conn, workspace).expect("workspace should save"); + + let mut window = test_terminal_window_snapshot(false); + window.active_repository_workspace_id = Some(workspace_id); + window.repository_workspace_states = + vec![crate::app_state::RepositoryWorkspaceWindowStateSnapshot { + repository_workspace_id: workspace_id, + active_tab_index: 0, + }]; + window.tabs[0].repository_workspace_id = Some(workspace_id); + let state = AppState { + windows: vec![window], + active_window_index: Some(0), + block_lists: Default::default(), + running_mcp_servers: Default::default(), + }; + + save_app_state(&mut conn, &state).expect("app state should save"); + let restored = read_sqlite_data(&mut conn, None) + .expect("app state should load") + .app_state; + + assert_eq!(restored.windows, state.windows); + assert_eq!(restored.active_window_index, state.active_window_index); +} + +#[test] +fn repository_rows_round_trip() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let created_at = chrono::DateTime::from_timestamp(1_700_000_000, 0) + .expect("timestamp should be valid") + .naive_utc(); + let last_opened_at = chrono::DateTime::from_timestamp(1_700_000_100, 0) + .expect("timestamp should be valid") + .naive_utc(); + let repository = Repository { + id: "123e4567-e89b-12d3-a456-426614174000".to_string(), + display_name: "zap".to_string(), + path: "/tmp/zap".to_string(), + remote_url: None, + source: "local".to_string(), + created_at, + last_opened_at, + }; + + save_repository(&mut conn, repository.clone()).expect("repository should save"); + + assert_eq!( + get_all_repositories(&mut conn).expect("repositories should load"), + vec![repository] + ); +} + +#[test] +fn legacy_project_migration_normalizes_repository_display_name() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let repository_path = tempdir.path().join("repository"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + revert_repository_workspaces_migration_and_later(&mut conn); + diesel::sql_query("INSERT INTO projects (path, added_ts, last_opened_ts) VALUES (?, ?, NULL)") + .bind::( + repository_path + .to_str() + .expect("repository path should be valid UTF-8"), + ) + .bind::( + chrono::DateTime::from_timestamp(1_700_000_000, 0) + .expect("timestamp should be valid") + .naive_utc(), + ) + .execute(&mut conn) + .expect("legacy project should insert"); + conn.run_pending_migrations(persistence::MIGRATIONS) + .expect("repository workspace migration should run"); + + let persisted_data = read_sqlite_data(&mut conn, None).expect("database should load"); + + assert_eq!(persisted_data.repositories[0].display_name, "repository"); + assert_eq!( + crate::persistence::schema::repositories::table + .select(crate::persistence::schema::repositories::display_name) + .first::(&mut conn) + .expect("repository display name should load"), + "repository" + ); +} + +#[test] +fn malformed_repository_row_returns_error() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + conn.batch_execute( + "INSERT INTO repositories ( + id, display_name, path, remote_url, source, created_at, last_opened_at + ) VALUES ( + '123e4567-e89b-12d3-a456-426614174001', 'malformed', '/tmp/malformed', NULL, + 'local', 'not-a-timestamp', '2026-07-11 01:02:03' + );", + ) + .expect("malformed repository row should insert"); + + assert!(get_all_repositories(&mut conn).is_err()); +} + +#[test] +fn malformed_repository_workspace_row_returns_error() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let now = chrono::Utc::now().naive_utc(); + let repository = Repository { + id: "123e4567-e89b-12d3-a456-426614174002".to_string(), + display_name: "zap".to_string(), + path: "/tmp/zap-malformed-workspace".to_string(), + remote_url: None, + source: "local".to_string(), + created_at: now, + last_opened_at: now, + }; + save_repository(&mut conn, repository).expect("repository should save"); + conn.batch_execute( + "INSERT INTO repository_workspaces ( + id, repository_id, display_name, branch, worktree_path, created_at, last_opened_at + ) VALUES ( + '123e4567-e89b-12d3-a456-426614174003', + '123e4567-e89b-12d3-a456-426614174002', + 'main', 'main', '/tmp/zap-malformed-workspace-main', + 'not-a-timestamp', '2026-07-11 01:02:03' + );", + ) + .expect("malformed repository workspace row should insert"); + + assert!(get_all_repository_workspaces(&mut conn).is_err()); +} + +fn repository_row(id: &str, path: &str) -> Repository { + let now = chrono::DateTime::from_timestamp(1_700_000_000, 0) + .expect("timestamp should be valid") + .naive_utc(); + Repository { + id: id.to_string(), + display_name: "zap".to_string(), + path: path.to_string(), + remote_url: None, + source: "local".to_string(), + created_at: now, + last_opened_at: now, + } +} + +#[test] +fn repository_persistence_acknowledges_committed_upsert() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let conn = setup_database(&database_path).expect("database should initialize"); + let handles = start_writer(conn, database_path.clone()).expect("writer should start"); + let persistence = RepositoryPersistence::new(Some(handles.sender.clone())); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174100", + "/tmp/ack-repository", + ); + + persistence + .execute(RepositoryPersistenceOperation::UpsertRepository { + repository: repository.clone(), + }) + .expect("repository upsert should be acknowledged"); + + let mut read_conn = setup_database(&database_path).expect("read connection should initialize"); + assert_eq!( + get_all_repositories(&mut read_conn).expect("repositories should load"), + vec![repository] + ); + handles + .sender + .send(ModelEvent::Terminate) + .expect("writer should receive termination"); + handles.handle.join().expect("writer should terminate"); +} + +#[test] +fn repository_persistence_returns_database_error() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let conn = setup_database(&database_path).expect("database should initialize"); + let handles = start_writer(conn, database_path.clone()).expect("writer should start"); + let persistence = RepositoryPersistence::new(Some(handles.sender.clone())); + let first = repository_row( + "123e4567-e89b-12d3-a456-426614174101", + "/tmp/ack-duplicate-path", + ); + let second = repository_row( + "123e4567-e89b-12d3-a456-426614174102", + "/tmp/ack-duplicate-path", + ); + persistence + .execute(RepositoryPersistenceOperation::UpsertRepository { repository: first }) + .expect("first repository should persist"); + + let error = persistence + .execute(RepositoryPersistenceOperation::UpsertRepository { repository: second }) + .expect_err("duplicate path should fail"); + + match error { + RepositoryPersistenceError::Database { details } => { + assert!(details.contains("error upserting repository")); + assert!(details.contains("UNIQUE constraint failed: repositories.path")); + } + other => panic!("expected database error, got {other:?}"), + } + handles + .sender + .send(ModelEvent::Terminate) + .expect("writer should receive termination"); + handles.handle.join().expect("writer should terminate"); +} + +#[test] +fn repository_persistence_reports_database_error() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let first = repository_row( + "123e4567-e89b-12d3-a456-426614174111", + "/tmp/ack-reported-error", + ); + let second = repository_row( + "123e4567-e89b-12d3-a456-426614174112", + "/tmp/ack-reported-error", + ); + save_repository(&mut conn, first).expect("first repository should persist"); + let mut reported_error = None; + + let error = execute_repository_persistence_operation( + RepositoryPersistenceOperation::UpsertRepository { repository: second }, + &mut conn, + &database_path, + |error_kind, error, reported_path| { + reported_error = Some(( + error_kind.to_string(), + format!("{error:#}"), + reported_path.to_path_buf(), + )); + }, + ) + .expect_err("duplicate path should fail"); + + let RepositoryPersistenceError::Database { details } = error else { + panic!("expected database error, got {error:?}"); + }; + assert!(details.contains("error upserting repository")); + assert!(details.contains("UNIQUE constraint failed: repositories.path")); + let (error_kind, reported_details, reported_path) = + reported_error.expect("database error should be reported"); + assert_eq!(error_kind, "Repository persistence"); + assert_eq!(reported_details, details); + assert_eq!(reported_path, database_path); +} + +#[test] +fn repository_persistence_fails_while_writer_is_paused() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let conn = setup_database(&database_path).expect("database should initialize"); + let handles = start_writer_with_state(conn, database_path.clone(), WriterState::Paused) + .expect("paused writer should start"); + let persistence = RepositoryPersistence::new(Some(handles.sender.clone())); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174103", + "/tmp/ack-paused-repository", + ); + + assert_eq!( + persistence.execute(RepositoryPersistenceOperation::UpsertRepository { repository }), + Err(RepositoryPersistenceError::Paused) + ); + + let mut read_conn = setup_database(&database_path).expect("read connection should initialize"); + assert!(get_all_repositories(&mut read_conn) + .expect("repositories should load") + .is_empty()); + handles + .sender + .send(ModelEvent::Terminate) + .expect("writer should receive termination"); + handles.handle.join().expect("writer should terminate"); +} + +#[test] +fn repository_persistence_returns_unavailable_without_sender() { + let persistence = RepositoryPersistence::new(None); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174104", + "/tmp/ack-unavailable-repository", + ); + + assert_eq!( + persistence.execute(RepositoryPersistenceOperation::UpsertRepository { repository }), + Err(RepositoryPersistenceError::Unavailable) + ); +} + +#[test] +fn repository_persistence_returns_request_disconnected() { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + drop(receiver); + let persistence = RepositoryPersistence::new(Some(sender)); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174105", + "/tmp/ack-request-disconnected", + ); + + let error = persistence + .execute(RepositoryPersistenceOperation::UpsertRepository { repository }) + .expect_err("disconnected request channel should fail"); + + assert!(matches!( + error, + RepositoryPersistenceError::RequestDisconnected { .. } + )); +} + +#[test] +fn repository_persistence_returns_response_disconnected() { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let receiver_thread = std::thread::spawn(move || { + let event = receiver.recv().expect("request should be received"); + let ModelEvent::RepositoryPersistence(request) = event else { + panic!("expected repository persistence request, got {event:?}"); + }; + drop(request.response); + }); + let persistence = RepositoryPersistence::new(Some(sender)); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174106", + "/tmp/ack-response-disconnected", + ); + + let error = persistence + .execute(RepositoryPersistenceOperation::UpsertRepository { repository }) + .expect_err("disconnected response channel should fail"); + + assert!(matches!( + error, + RepositoryPersistenceError::ResponseDisconnected { .. } + )); + receiver_thread.join().expect("receiver should terminate"); +} + +#[test] +fn repository_persistence_writer_continues_after_response_disconnect() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let conn = setup_database(&database_path).expect("database should initialize"); + let handles = start_writer(conn, database_path.clone()).expect("writer should start"); + let persistence = RepositoryPersistence::new(Some(handles.sender.clone())); + let first = repository_row( + "123e4567-e89b-12d3-a456-426614174107", + "/tmp/ack-dropped-response", + ); + let second = repository_row( + "123e4567-e89b-12d3-a456-426614174108", + "/tmp/ack-after-dropped-response", + ); + let (response, receiver) = std::sync::mpsc::sync_channel(1); + drop(receiver); + handles + .sender + .send(ModelEvent::RepositoryPersistence( + RepositoryPersistenceRequest { + operation: RepositoryPersistenceOperation::UpsertRepository { + repository: first.clone(), + }, + response, + }, + )) + .expect("writer should receive request with disconnected response"); + + persistence + .execute(RepositoryPersistenceOperation::UpsertRepository { + repository: second.clone(), + }) + .expect("writer should continue serving requests"); + + let mut read_conn = setup_database(&database_path).expect("read connection should initialize"); + let mut repositories = get_all_repositories(&mut read_conn).expect("repositories should load"); + repositories.sort_by(|left, right| left.id.cmp(&right.id)); + assert_eq!(repositories, vec![first, second]); + handles + .sender + .send(ModelEvent::Terminate) + .expect("writer should receive termination"); + handles.handle.join().expect("writer should terminate"); +} + +#[test] +fn repository_persistence_operation_matrix_commits_before_acknowledgement() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let conn = setup_database(&database_path).expect("database should initialize"); + let handles = start_writer(conn, database_path.clone()).expect("writer should start"); + let persistence = RepositoryPersistence::new(Some(handles.sender.clone())); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174109", + "/tmp/ack-operation-matrix", + ); + let workspace = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174110", + &repository.id, + "main", + "/tmp/ack-operation-matrix-main", + ); + let mut read_conn = setup_database(&database_path).expect("read connection should initialize"); + + persistence + .execute(RepositoryPersistenceOperation::UpsertRepository { + repository: repository.clone(), + }) + .expect("repository upsert should be acknowledged"); + assert_eq!( + get_all_repositories(&mut read_conn).expect("repositories should load"), + vec![repository.clone()] + ); + + persistence + .execute(RepositoryPersistenceOperation::UpsertRepositoryWorkspace { + workspace: workspace.clone(), + }) + .expect("workspace upsert should be acknowledged"); + assert_eq!( + get_all_repository_workspaces(&mut read_conn).expect("workspaces should load"), + vec![workspace.clone()] + ); + + persistence + .execute(RepositoryPersistenceOperation::DeleteRepositoryWorkspace { + workspace_id: workspace.id, + }) + .expect("workspace delete should be acknowledged"); + assert!(get_all_repository_workspaces(&mut read_conn) + .expect("workspaces should load") + .is_empty()); + + persistence + .execute(RepositoryPersistenceOperation::DeleteRepository { + repository_id: repository.id, + }) + .expect("repository delete should be acknowledged"); + assert!(get_all_repositories(&mut read_conn) + .expect("repositories should load") + .is_empty()); + handles + .sender + .send(ModelEvent::Terminate) + .expect("writer should receive termination"); + handles.handle.join().expect("writer should terminate"); +} + +#[test] +fn repository_and_initial_workspace_are_persisted_as_one_transaction() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let conn = setup_database(&database_path).expect("database should initialize"); + let handles = start_writer(conn, database_path.clone()).expect("writer should start"); + let persistence = RepositoryPersistence::new(Some(handles.sender.clone())); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174113", + "/tmp/atomic-repository", + ); + let workspace = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174114", + &repository.id, + "main", + "/tmp/atomic-repository", + ); + + persistence + .execute( + RepositoryPersistenceOperation::UpsertRepositoryWithWorkspace { + repository: repository.clone(), + workspace: workspace.clone(), + }, + ) + .expect("repository and workspace upsert should be acknowledged"); + + let mut read_conn = setup_database(&database_path).expect("read connection should initialize"); + assert_eq!( + get_all_repositories(&mut read_conn).expect("repositories should load"), + vec![repository] + ); + assert_eq!( + get_all_repository_workspaces(&mut read_conn).expect("workspaces should load"), + vec![workspace] + ); + handles + .sender + .send(ModelEvent::Terminate) + .expect("writer should receive termination"); + handles.handle.join().expect("writer should terminate"); +} + +#[test] +fn failed_initial_workspace_upsert_rolls_back_the_new_repository() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let existing_repository = repository_row( + "123e4567-e89b-12d3-a456-426614174115", + "/tmp/atomic-existing-repository", + ); + let conflicting_workspace = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174116", + &existing_repository.id, + "main", + "/tmp/atomic-conflicting-worktree", + ); + save_repository(&mut conn, existing_repository.clone()).expect("repository should save"); + save_repository_workspace(&mut conn, conflicting_workspace.clone()) + .expect("workspace should save"); + drop(conn); + + let conn = setup_database(&database_path).expect("database should initialize"); + let handles = start_writer(conn, database_path.clone()).expect("writer should start"); + let persistence = RepositoryPersistence::new(Some(handles.sender.clone())); + let new_repository = repository_row( + "123e4567-e89b-12d3-a456-426614174117", + "/tmp/atomic-new-repository", + ); + let new_workspace = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174118", + &new_repository.id, + "main", + "/tmp/atomic-conflicting-worktree", + ); + + assert!(matches!( + persistence.execute( + RepositoryPersistenceOperation::UpsertRepositoryWithWorkspace { + repository: new_repository.clone(), + workspace: new_workspace, + } + ), + Err(RepositoryPersistenceError::Database { .. }) + )); + + let mut read_conn = setup_database(&database_path).expect("read connection should initialize"); + assert_eq!( + get_all_repositories(&mut read_conn).expect("repositories should load"), + vec![existing_repository] + ); + assert_eq!( + get_all_repository_workspaces(&mut read_conn).expect("workspaces should load"), + vec![conflicting_workspace] + ); + handles + .sender + .send(ModelEvent::Terminate) + .expect("writer should receive termination"); + handles.handle.join().expect("writer should terminate"); +} + +fn repository_workspace_row( + id: &str, + repository_id: &str, + branch: &str, + worktree_path: &str, +) -> RepositoryWorkspace { + let now = chrono::DateTime::from_timestamp(1_700_000_000, 0) + .expect("timestamp should be valid") + .naive_utc(); + RepositoryWorkspace { + id: id.to_string(), + repository_id: repository_id.to_string(), + display_name: branch.to_string(), + branch: branch.to_string(), + worktree_path: worktree_path.to_string(), + created_at: now, + last_opened_at: now, + } +} + +fn save_test_window_and_tab(conn: &mut SqliteConnection) -> (i32, i32) { + let app_state = AppState { + windows: vec![test_terminal_window_snapshot(false)], + active_window_index: None, + block_lists: Default::default(), + running_mcp_servers: Default::default(), + }; + save_app_state(conn, &app_state).expect("app state should save"); + + let window_id = crate::persistence::schema::windows::table + .select(crate::persistence::schema::windows::id) + .first(conn) + .expect("window should load"); + let tab_id = crate::persistence::schema::tabs::table + .select(crate::persistence::schema::tabs::id) + .first(conn) + .expect("tab should load"); + (window_id, tab_id) +} + +fn insert_repository_workspace_window_state( + conn: &mut SqliteConnection, + window_id: i32, + repository_workspace_id: &str, +) { + use crate::persistence::schema::repository_workspace_window_states::dsl; + + diesel::insert_into(dsl::repository_workspace_window_states) + .values(( + dsl::window_id.eq(window_id), + dsl::repository_workspace_id.eq(repository_workspace_id), + dsl::active_tab_index.eq(0), + )) + .execute(conn) + .expect("repository workspace window state should insert"); +} + +fn assert_unique_violation(error: &anyhow::Error) { + assert!(matches!( + error.downcast_ref::(), + Some(DieselError::DatabaseError( + DatabaseErrorKind::UniqueViolation, + _ + )) + )); +} + +#[test] +fn repository_workspace_rows_support_crud_and_repository_restrict() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let created_at = chrono::DateTime::from_timestamp(1_700_000_000, 0) + .expect("timestamp should be valid") + .naive_utc(); + let repository = Repository { + id: "123e4567-e89b-12d3-a456-426614174004".to_string(), + display_name: "zap".to_string(), + path: "/tmp/zap-workspace-crud".to_string(), + remote_url: None, + source: "local".to_string(), + created_at, + last_opened_at: created_at, + }; + save_repository(&mut conn, repository.clone()).expect("repository should save"); + let mut workspace = RepositoryWorkspace { + id: "123e4567-e89b-12d3-a456-426614174005".to_string(), + repository_id: repository.id.clone(), + display_name: "main".to_string(), + branch: "main".to_string(), + worktree_path: "/tmp/zap-workspace-crud-main".to_string(), + created_at, + last_opened_at: created_at, + }; + + save_repository_workspace(&mut conn, workspace.clone()).expect("workspace should save"); + assert_eq!( + get_all_repository_workspaces(&mut conn).expect("workspaces should load"), + vec![workspace.clone()] + ); + + workspace.display_name = "Main workspace".to_string(); + workspace.last_opened_at = chrono::DateTime::from_timestamp(1_700_000_100, 0) + .expect("timestamp should be valid") + .naive_utc(); + save_repository_workspace(&mut conn, workspace.clone()).expect("workspace should update"); + assert_eq!( + get_all_repository_workspaces(&mut conn).expect("workspaces should load"), + vec![workspace.clone()] + ); + assert!(delete_repository(&mut conn, &repository.id).is_err()); + + delete_repository_workspace(&mut conn, &workspace.id).expect("workspace should delete"); + assert!(get_all_repository_workspaces(&mut conn) + .expect("workspaces should load") + .is_empty()); + delete_repository(&mut conn, &repository.id).expect("repository should delete"); +} + +#[test] +fn repository_workspace_window_state_cascades_when_window_is_deleted() { + use crate::persistence::schema::repository_workspace_window_states::dsl as window_states; + + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174010", + "/tmp/zap-window-cascade", + ); + let workspace = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174011", + &repository.id, + "main", + "/tmp/zap-window-cascade-main", + ); + save_repository(&mut conn, repository).expect("repository should save"); + save_repository_workspace(&mut conn, workspace.clone()).expect("workspace should save"); + let (window_id, _) = save_test_window_and_tab(&mut conn); + insert_repository_workspace_window_state(&mut conn, window_id, &workspace.id); + + save_app_state( + &mut conn, + &AppState { + windows: vec![], + active_window_index: None, + block_lists: Default::default(), + running_mcp_servers: Default::default(), + }, + ) + .expect("empty app state should save"); + + assert_eq!( + window_states::repository_workspace_window_states + .count() + .get_result::(&mut conn) + .expect("window state count should load"), + 0 + ); +} + +#[test] +fn repository_workspace_window_state_cascades_when_workspace_is_deleted() { + use crate::persistence::schema::repository_workspace_window_states::dsl as window_states; + + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174012", + "/tmp/zap-workspace-cascade", + ); + let workspace = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174013", + &repository.id, + "main", + "/tmp/zap-workspace-cascade-main", + ); + save_repository(&mut conn, repository).expect("repository should save"); + save_repository_workspace(&mut conn, workspace.clone()).expect("workspace should save"); + let (window_id, _) = save_test_window_and_tab(&mut conn); + insert_repository_workspace_window_state(&mut conn, window_id, &workspace.id); + + delete_repository_workspace(&mut conn, &workspace.id).expect("workspace should delete"); + + assert_eq!( + window_states::repository_workspace_window_states + .count() + .get_result::(&mut conn) + .expect("window state count should load"), + 0 + ); +} + +#[test] +fn repository_workspace_delete_nulls_tab_and_window_workspace_ids() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let repository = repository_row("123e4567-e89b-12d3-a456-426614174014", "/tmp/zap-set-null"); + let workspace = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174015", + &repository.id, + "main", + "/tmp/zap-set-null-main", + ); + save_repository(&mut conn, repository).expect("repository should save"); + save_repository_workspace(&mut conn, workspace.clone()).expect("workspace should save"); + let (window_id, tab_id) = save_test_window_and_tab(&mut conn); + diesel::update( + crate::persistence::schema::tabs::table + .filter(crate::persistence::schema::tabs::id.eq(tab_id)), + ) + .set(crate::persistence::schema::tabs::repository_workspace_id.eq(Some(workspace.id.clone()))) + .execute(&mut conn) + .expect("tab workspace should update"); + diesel::update( + crate::persistence::schema::windows::table + .filter(crate::persistence::schema::windows::id.eq(window_id)), + ) + .set( + crate::persistence::schema::windows::active_repository_workspace_id + .eq(Some(workspace.id.clone())), + ) + .execute(&mut conn) + .expect("window workspace should update"); + + delete_repository_workspace(&mut conn, &workspace.id).expect("workspace should delete"); + + assert_eq!( + crate::persistence::schema::tabs::table + .filter(crate::persistence::schema::tabs::id.eq(tab_id)) + .select(crate::persistence::schema::tabs::repository_workspace_id) + .first::>(&mut conn) + .expect("tab workspace should load"), + None + ); + assert_eq!( + crate::persistence::schema::windows::table + .filter(crate::persistence::schema::windows::id.eq(window_id)) + .select(crate::persistence::schema::windows::active_repository_workspace_id) + .first::>(&mut conn) + .expect("window workspace should load"), + None + ); +} + +#[test] +fn repository_source_check_rejects_invalid_source() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let mut repository = repository_row( + "123e4567-e89b-12d3-a456-426614174016", + "/tmp/zap-invalid-source", + ); + repository.source = "remote".to_string(); + + let error = save_repository(&mut conn, repository).expect_err("invalid source should fail"); + + assert!(matches!( + error, + DieselError::DatabaseError(DatabaseErrorKind::CheckViolation, _) + )); +} + +#[test] +fn repository_path_must_be_unique() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let first = repository_row( + "123e4567-e89b-12d3-a456-426614174017", + "/tmp/zap-unique-path", + ); + let second = repository_row( + "123e4567-e89b-12d3-a456-426614174018", + "/tmp/zap-unique-path", + ); + save_repository(&mut conn, first).expect("first repository should save"); + + let error = save_repository(&mut conn, second).expect_err("duplicate path should fail"); + + assert!(matches!( + error, + DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) + )); +} + +#[test] +fn repository_workspace_worktree_path_must_be_unique() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174019", + "/tmp/zap-unique-worktree", + ); + let first = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174020", + &repository.id, + "main", + "/tmp/zap-shared-worktree-path", + ); + let second = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174021", + &repository.id, + "preview", + "/tmp/zap-shared-worktree-path", + ); + save_repository(&mut conn, repository).expect("repository should save"); + save_repository_workspace(&mut conn, first).expect("first workspace should save"); + + let error = + save_repository_workspace(&mut conn, second).expect_err("duplicate path should fail"); + + assert_unique_violation(&error); +} + +#[test] +fn repository_workspace_branch_must_be_unique_within_repository() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174022", + "/tmp/zap-unique-branch", + ); + let first = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174023", + &repository.id, + "main", + "/tmp/zap-unique-branch-main", + ); + let second = repository_workspace_row( + "123e4567-e89b-12d3-a456-426614174024", + &repository.id, + "main", + "/tmp/zap-unique-branch-main-copy", + ); + save_repository(&mut conn, repository).expect("repository should save"); + save_repository_workspace(&mut conn, first).expect("first workspace should save"); + + let error = + save_repository_workspace(&mut conn, second).expect_err("duplicate branch should fail"); + + assert_unique_violation(&error); +} + #[test] fn test_sqlite_round_trips_custom_vertical_tabs_title() { let tempdir = tempfile::tempdir().expect("tempdir should be created"); @@ -201,6 +1267,7 @@ fn test_sqlite_round_trips_custom_vertical_tabs_title() { let app_state = AppState { windows: vec![WindowSnapshot { tabs: vec![TabSnapshot { + repository_workspace_id: None, custom_title: None, root: PaneNodeSnapshot::Leaf(LeafSnapshot { is_focused: true, @@ -227,6 +1294,8 @@ fn test_sqlite_round_trips_custom_vertical_tabs_title() { right_panel: None, }], active_tab_index: 0, + active_repository_workspace_id: None, + repository_workspace_states: Vec::new(), bounds: None, fullscreen_state: Default::default(), quake_mode: false, @@ -274,6 +1343,7 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() { let app_state = AppState { windows: vec![WindowSnapshot { tabs: vec![TabSnapshot { + repository_workspace_id: None, custom_title: None, root: PaneNodeSnapshot::Leaf(LeafSnapshot { is_focused: true, @@ -300,6 +1370,8 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() { right_panel: None, }], active_tab_index: 0, + active_repository_workspace_id: None, + repository_workspace_states: Vec::new(), bounds: None, fullscreen_state: Default::default(), quake_mode: false, @@ -418,9 +1490,7 @@ fn test_migrate_zap_app_group_sqlite_copies_newer_legacy_files() { fs::read_to_string(target_db.with_extension("sqlite-shm")).unwrap(), "legacy-shm" ); - assert!(state_dir - .join(".zap-app-group-sqlite-migrated") - .exists()); + assert!(state_dir.join(".zap-app-group-sqlite-migrated").exists()); } #[cfg(target_os = "macos")] diff --git a/app/src/project_organization/domain.rs b/app/src/project_organization/domain.rs new file mode 100644 index 00000000000..2aca7c65459 --- /dev/null +++ b/app/src/project_organization/domain.rs @@ -0,0 +1,291 @@ +use std::{fmt, path::PathBuf, str::FromStr}; + +use chrono::NaiveDateTime; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct RepositoryId(pub Uuid); + +impl fmt::Display for RepositoryId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl From for RepositoryId { + fn from(value: Uuid) -> Self { + Self(value) + } +} + +impl From for Uuid { + fn from(value: RepositoryId) -> Self { + value.0 + } +} + +impl FromStr for RepositoryId { + type Err = RepositoryIdParseError; + + fn from_str(value: &str) -> Result { + Uuid::parse_str(value) + .map(Self) + .map_err(|source| RepositoryIdParseError { + value: value.to_string(), + source, + }) + } +} + +impl TryFrom for RepositoryId { + type Error = RepositoryIdParseError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl TryFrom<&str> for RepositoryId { + type Error = RepositoryIdParseError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +#[derive(Debug, Error)] +#[error("invalid repository ID `{value}`: {source}")] +pub struct RepositoryIdParseError { + pub value: String, + #[source] + pub source: uuid::Error, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct RepositoryWorkspaceId(pub Uuid); + +impl fmt::Display for RepositoryWorkspaceId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl From for RepositoryWorkspaceId { + fn from(value: Uuid) -> Self { + Self(value) + } +} + +impl From for Uuid { + fn from(value: RepositoryWorkspaceId) -> Self { + value.0 + } +} + +impl FromStr for RepositoryWorkspaceId { + type Err = RepositoryWorkspaceIdParseError; + + fn from_str(value: &str) -> Result { + Uuid::parse_str(value) + .map(Self) + .map_err(|source| RepositoryWorkspaceIdParseError { + value: value.to_string(), + source, + }) + } +} + +impl TryFrom for RepositoryWorkspaceId { + type Error = RepositoryWorkspaceIdParseError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl TryFrom<&str> for RepositoryWorkspaceId { + type Error = RepositoryWorkspaceIdParseError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +#[derive(Debug, Error)] +#[error("invalid repository workspace ID `{value}`: {source}")] +pub struct RepositoryWorkspaceIdParseError { + pub value: String, + #[source] + pub source: uuid::Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RepositorySource { + Local, + Cloned, +} + +impl fmt::Display for RepositorySource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Local => formatter.write_str("local"), + Self::Cloned => formatter.write_str("cloned"), + } + } +} + +impl FromStr for RepositorySource { + type Err = RepositorySourceParseError; + + fn from_str(value: &str) -> Result { + match value { + "local" => Ok(Self::Local), + "cloned" => Ok(Self::Cloned), + value => Err(RepositorySourceParseError { + value: value.to_string(), + }), + } + } +} + +impl TryFrom for RepositorySource { + type Error = RepositorySourceParseError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl TryFrom<&str> for RepositorySource { + type Error = RepositorySourceParseError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +#[derive(Debug, Error)] +#[error("invalid repository source `{value}`; expected `local` or `cloned`")] +pub struct RepositorySourceParseError { + pub value: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Repository { + pub id: RepositoryId, + pub display_name: String, + pub path: PathBuf, + pub remote_url: Option, + pub source: RepositorySource, + pub created_at: NaiveDateTime, + pub last_opened_at: NaiveDateTime, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepositoryWorkspace { + pub id: RepositoryWorkspaceId, + pub repository_id: RepositoryId, + pub display_name: String, + pub branch: String, + pub worktree_path: PathBuf, + pub created_at: NaiveDateTime, + pub last_opened_at: NaiveDateTime, +} + +#[derive(Debug, Error)] +pub enum ProjectOrganizationError { + #[error( + "repository path `{canonical_path}` already belongs to repository {existing_repository_id}" + )] + RepositoryAlreadyExists { + existing_repository_id: RepositoryId, + canonical_path: PathBuf, + }, + #[error( + "canonical repository path `{canonical_path}` matches multiple repositories: {repository_ids:?}" + )] + AmbiguousRepositoryPath { + canonical_path: PathBuf, + repository_ids: Vec, + }, + #[error( + "branch `{branch}` already belongs to workspace {existing_workspace_id} in repository {repository_id}" + )] + WorkspaceBranchAlreadyExists { + repository_id: RepositoryId, + branch: String, + existing_workspace_id: RepositoryWorkspaceId, + }, + #[error( + "worktree path `{canonical_path}` already belongs to workspace {existing_workspace_id}" + )] + WorkspacePathAlreadyExists { + existing_workspace_id: RepositoryWorkspaceId, + canonical_path: PathBuf, + }, + #[error( + "canonical worktree path `{canonical_path}` matches multiple workspaces: {workspace_ids:?}" + )] + AmbiguousWorkspacePath { + canonical_path: PathBuf, + workspace_ids: Vec, + }, + #[error("repository {repository_id} still has workspaces")] + RepositoryHasWorkspaces { repository_id: RepositoryId }, + #[error("repository {repository_id} does not exist")] + RepositoryNotFound { repository_id: RepositoryId }, + #[error("repository workspace {workspace_id} does not exist")] + WorkspaceNotFound { workspace_id: RepositoryWorkspaceId }, + #[error("repository ID {repository_id} already exists")] + RepositoryIdAlreadyExists { repository_id: RepositoryId }, + #[error("repository workspace ID {workspace_id} already exists")] + WorkspaceIdAlreadyExists { workspace_id: RepositoryWorkspaceId }, + #[error("persisted repository has invalid ID `{value}`: {source}")] + InvalidPersistedRepositoryId { + value: String, + #[source] + source: RepositoryIdParseError, + }, + #[error("persisted repository workspace has invalid ID `{value}`: {source}")] + InvalidPersistedWorkspaceId { + value: String, + #[source] + source: RepositoryWorkspaceIdParseError, + }, + #[error("persisted repository workspace has invalid repository ID `{value}`: {source}")] + InvalidPersistedWorkspaceRepositoryId { + value: String, + #[source] + source: RepositoryIdParseError, + }, + #[error("persisted repository has invalid source `{value}`: {source}")] + InvalidPersistedRepositorySource { + value: String, + #[source] + source: RepositorySourceParseError, + }, + #[error("path `{path}` cannot be canonicalized: {source}")] + InvalidPath { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("path `{path}` is not valid UTF-8 and cannot be persisted")] + InvalidPathEncoding { path: PathBuf }, + #[error("failed to persist {operation}: {details}")] + Persistence { + operation: &'static str, + details: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProjectOrganizationEvent { + RepositoryAdded { repository_id: RepositoryId }, + RepositoryUpdated { repository_id: RepositoryId }, + RepositoryRemoved { repository_id: RepositoryId }, + WorkspaceAdded { workspace_id: RepositoryWorkspaceId }, + WorkspaceUpdated { workspace_id: RepositoryWorkspaceId }, + WorkspaceRemoved { workspace_id: RepositoryWorkspaceId }, +} diff --git a/app/src/project_organization/git.rs b/app/src/project_organization/git.rs new file mode 100644 index 00000000000..0e8da5ef1b7 --- /dev/null +++ b/app/src/project_organization/git.rs @@ -0,0 +1,2235 @@ +use std::{ + ffi::{OsStr, OsString}, + io, + path::{Component, Path, PathBuf}, + process::Output, +}; + +use thiserror::Error; + +mod ref_transaction; +pub use ref_transaction::RefTransactionError; +use ref_transaction::{LockedRef, PreparedRefDelete}; + +/// Git 分支引用,保留完整 refname 以避免通过名称前缀猜测类型。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BranchRef { + Local { + name: String, + full_ref: String, + }, + Remote { + remote: String, + name: String, + full_ref: String, + }, +} + +/// 已通过主工作目录、remote 和默认分支校验的 repository 元数据。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedRepository { + pub root: PathBuf, + pub primary_branch: String, + pub remote: String, + pub remote_url: String, + pub default_branch: BranchRef, +} + +/// `git worktree list --porcelain` 返回的 worktree 信息。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorktreeInfo { + pub path: PathBuf, + pub head: Option, + pub branch: Option, + pub is_bare: bool, + pub is_detached: bool, + pub is_locked: bool, + pub locked_reason: Option, + pub is_prunable: bool, + pub prunable_reason: Option, +} + +/// 可作为 repository workspace 接入的已注册 worktree。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExistingWorktreeOption { + pub path: PathBuf, + pub branch_name: String, + pub is_primary: bool, +} + +impl ExistingWorktreeOption { + /// 创建 linked worktree 的接入选项。 + pub fn new(path: PathBuf, branch_name: impl Into) -> Self { + Self { + path, + branch_name: branch_name.into(), + is_primary: false, + } + } + + /// 创建主 worktree 的接入选项。 + pub fn primary(path: PathBuf, branch_name: impl Into) -> Self { + Self { + path, + branch_name: branch_name.into(), + is_primary: true, + } + } +} + +/// 删除预检中的不可变合并目标快照。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MergeTargetSnapshot { + /// 用于合并判断的完整 refname。 + pub full_ref: String, + /// Preflight 时目标 ref 指向的精确 commit OID。 + pub oid: String, + /// Preflight 时分支是否已合入目标 commit。 + pub is_merged: bool, +} + +/// 删除 linked worktree 前提供给 UI 的只读建议快照。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeletionPreflight { + /// `list_worktrees` 中已验证的 canonical registered path。 + pub worktree_path: PathBuf, + /// Worktree 当前检出的本地分支短名称。 + pub branch: String, + /// Worktree 当前检出的本地分支完整 refname。 + pub branch_ref: String, + /// Preflight 时本地分支指向的精确 commit OID。 + pub branch_oid: String, + /// 请求删除分支时捕获的合并目标快照;否则为 `None`。 + pub merge_target: Option, +} + +/// Repository workspace Git 操作的结构化错误。 +#[derive(Debug, Error)] +pub enum GitWorkspaceError { + #[error("failed to prepare branch deletion transaction: {source}")] + RefTransaction { + #[source] + source: RefTransactionError, + }, + #[error("branch deletion candidate for `{branch_ref}` has no merge target")] + MissingMergeTarget { branch_ref: String }, + #[error( + "merge target changed from `{expected}` to `{actual}` while branch deletion was locked" + )] + MergeTargetChanged { expected: String, actual: String }, + #[error( + "ref `{full_ref}` changed while branch deletion was locked: expected {expected_oid}, found {actual_oid}" + )] + RefChanged { + full_ref: String, + expected_oid: String, + actual_oid: String, + }, + #[error( + "{operation_error}; aborting the branch deletion transaction also failed: {abort_error}" + )] + BranchDeleteAbortFailed { + #[source] + operation_error: Box, + abort_error: RefTransactionError, + }, + #[error( + "worktree `{worktree_path}` was removed, but committing deletion of `{branch_ref}` at {branch_oid} failed: {source}; branch inspection error: {inspection_error:?}" + )] + BranchDeleteTransactionFailed { + worktree_path: PathBuf, + worktree_removed: bool, + branch_ref: String, + branch_oid: String, + merge_target_ref: Option, + merge_target_oid: Option, + #[source] + source: RefTransactionError, + inspection_error: Option>, + }, + #[error("failed to canonicalize `{path}`: {source}")] + Canonicalize { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("selected path `{selected}` is not repository root `{root}`")] + NotRepositoryRoot { selected: PathBuf, root: PathBuf }, + #[error( + "linked worktree cannot be registered as a repository: git dir `{git_dir}`, common dir `{common_dir}`" + )] + LinkedWorktree { + git_dir: PathBuf, + common_dir: PathBuf, + }, + #[error( + "repository primary worktree `{path}` is detached or does not check out a local branch" + )] + PrimaryWorktreeDetached { path: PathBuf }, + #[error("prunable worktree `{path}` cannot be registered as a workspace")] + PrunableWorktreeCannotBeWorkspace { path: PathBuf }, + #[error("repository `{repo}` has no configured remote")] + RemoteNotFound { repo: PathBuf }, + #[error("remote `{remote}` in repository `{repo}` has no default branch: {stderr}")] + DefaultBranchNotFound { + repo: PathBuf, + remote: String, + stderr: String, + }, + #[error("failed to execute git for {operation} with arguments {args:?}: {source}")] + CommandIo { + operation: &'static str, + args: Vec, + #[source] + source: io::Error, + }, + #[error("git failed to {operation} with arguments {args:?}: {stderr}")] + CommandFailed { + operation: &'static str, + args: Vec, + stderr: String, + }, + #[error("git returned invalid UTF-8 while attempting to {operation}")] + InvalidUtf8 { operation: &'static str }, + #[error("git returned invalid branch ref `{full_ref}`")] + InvalidBranchRef { full_ref: String }, + #[error("selected remote ref `{full_ref}` is not a direct ref of a configured remote")] + InvalidRemoteRef { full_ref: String }, + #[error("branch ref `{branch_ref}` cannot use itself as merge target `{target_ref}")] + InvalidMergeTarget { + branch_ref: String, + target_ref: String, + }, + #[error("branch ref `{full_ref}` does not exist")] + BranchNotFound { full_ref: String }, + #[error("branch name `{branch}` is invalid")] + InvalidBranchName { branch: String }, + #[error("local branch `{branch}` already exists")] + BranchAlreadyExists { branch: String }, + #[error("local branch `{branch}` is already checked out at `{path}`")] + BranchAlreadyCheckedOut { branch: String, path: PathBuf }, + #[error("worktree `{path}` is not registered in the repository")] + WorktreeNotFound { path: PathBuf }, + #[error("worktree `{path}` appears more than once in the repository")] + AmbiguousWorktree { path: PathBuf }, + #[error("worktree `{path}` does not check out a local branch")] + WorktreeHasNoLocalBranch { path: PathBuf }, + #[error("worktree `{path}` contains uncommitted changes")] + DirtyWorktree { path: PathBuf }, + #[error("worktree branch mismatch: expected `{expected}`, found `{actual}`")] + WorktreeBranchMismatch { expected: String, actual: String }, + #[error("branch `{branch}` is not merged into `{merge_target}`")] + BranchNotMerged { + branch: String, + merge_target: String, + }, + #[error( + "branch `{branch}` changed after preflight: expected {expected_oid}, found {actual_oid:?}" + )] + BranchChanged { + branch: String, + expected_oid: String, + actual_oid: Option, + actual_symbolic_target: Option, + }, + #[error("git returned invalid branch ref record `{record}`")] + InvalidBranchRefRecord { record: String }, + #[error("remote ref `{full_ref}` matches multiple remotes: {remotes:?}")] + AmbiguousRemoteRef { + full_ref: String, + remotes: Vec, + }, + #[error("git returned invalid worktree record: {record}")] + InvalidWorktreeRecord { record: String }, + #[error("target `{path}` already exists")] + TargetExists { path: PathBuf }, + #[error("failed to claim target directory `{path}`: {source}")] + TargetClaimFailed { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to inspect claimed target directory `{path}`: {source}")] + ClaimedTargetInspection { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("claimed target `{path}` is not a directory")] + ClaimedTargetNotDirectory { path: PathBuf }, + #[error("claimed target `{path}` is not empty")] + ClaimedTargetNotEmpty { path: PathBuf }, + #[error("failed to clean up claimed target directory `{path}`: {source}")] + ClaimedTargetCleanupFailed { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error( + "worktree creation failed for `{branch}` at `{worktree_path}`: {create_error}; branch may remain: {branch_may_remain}; registered: {worktree_registered:?}; claimed directory removed: {claimed_directory_removed}; cleanup error: {cleanup_error:?}" + )] + WorktreeCreationFailed { + worktree_path: PathBuf, + branch: String, + branch_may_remain: bool, + worktree_registered: Option, + claimed_directory_removed: bool, + #[source] + create_error: Box, + cleanup_error: Option>, + }, + #[error( + "worktree `{worktree_path}` for branch `{branch}` was created but could not be verified at expected OID {expected_oid}; the worktree and branch may remain: {verification_error}" + )] + WorktreeCreationVerificationFailed { + worktree_path: PathBuf, + branch: String, + expected_oid: String, + #[source] + verification_error: Box, + }, + #[error("newly created branch `{branch}` unexpectedly tracks `{upstream}")] + UnexpectedBranchUpstream { branch: String, upstream: String }, + #[error("git returned invalid direct ref record `{record}`")] + InvalidDirectRefRecord { record: String }, + #[error("failed to create clone target `{path}`: {source}")] + CreateTarget { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error( + "clone failed: {clone_error}; target `{path}` could not be cleaned up: {cleanup_source}" + )] + CleanupFailed { + path: PathBuf, + cleanup_source: io::Error, + #[source] + clone_error: Box, + }, + #[error("Git URL `{url}` does not contain a repository name")] + RepositoryNameMissing { url: String }, + #[error("clone directory name `{name}` must be a single normal path component")] + InvalidCloneDirectoryName { name: String }, + #[error("background Git operation `{operation}` failed: {message}")] + BackgroundTaskFailed { + operation: &'static str, + message: String, + }, +} + +/// 校验路径是 Git 主工作目录,并读取 remote URL 与 remote 默认分支。 +pub fn validate_repository(path: &Path) -> Result { + let selected = canonicalize(path)?; + let root = output_path( + &selected, + "find repository root", + &["rev-parse", "--show-toplevel"], + )?; + let root = canonicalize(&root)?; + if selected != root { + return Err(GitWorkspaceError::NotRepositoryRoot { selected, root }); + } + + let git_dir = output_path( + &root, + "find repository git directory", + &["rev-parse", "--absolute-git-dir"], + )?; + let common_dir = output_path( + &root, + "find repository common directory", + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + )?; + let git_dir = canonicalize(&git_dir)?; + let common_dir = canonicalize(&common_dir)?; + if git_dir != common_dir { + return Err(GitWorkspaceError::LinkedWorktree { + git_dir, + common_dir, + }); + } + + let worktrees = list_worktrees(&root)?; + let primary_branch = resolve_primary_branch(&worktrees, &root)?; + let remote = primary_remote(&root)?; + let remote_url = output_string( + &root, + "read repository remote URL", + &["remote", "get-url", &remote], + )?; + let default_branch = default_branch(&root, &remote)?; + + Ok(ValidatedRepository { + root, + primary_branch, + remote, + remote_url, + default_branch, + }) +} + +/// 在后台线程执行 repository 校验,避免在 UI 调用线程运行 blocking Git。 +pub async fn validate_repository_async( + path: PathBuf, +) -> Result { + spawn_git_task("validate repository", move || validate_repository(&path)).await +} + +/// Clone repository 到明确目标路径;目标为 `None` 时使用 URL 中的 repository 名。 +pub fn clone_repository( + url: &str, + target: Option<&Path>, +) -> Result { + let derived_target; + let target = match target { + Some(target) => target, + None => { + derived_target = PathBuf::from(repository_name_from_url(url)?); + &derived_target + } + }; + clone_to_target(url, target) +} + +/// 在指定父目录 Clone repository,可选择覆盖 URL 推导出的目录名。 +pub fn clone_repository_into( + url: &str, + parent: &Path, + directory_name: Option<&str>, +) -> Result { + let directory_name = match directory_name { + Some(directory_name) => directory_name.to_string(), + None => repository_name_from_url(url)?, + }; + validate_clone_directory_name(&directory_name)?; + clone_to_target(url, &parent.join(directory_name)) +} + +/// 在后台线程 Clone repository,避免长时间 Git 操作阻塞 UI。 +pub async fn clone_repository_async( + url: String, + target: Option, +) -> Result { + spawn_git_task("clone repository", move || { + clone_repository(&url, target.as_deref()) + }) + .await +} + +/// 执行 fetch 后列出本地与远端完整分支引用。 +pub fn fetch_and_list_refs(repo: &Path) -> Result, GitWorkspaceError> { + let remote = primary_remote(repo)?; + git_output_for_operation( + repo, + "fetch repository refs", + &["fetch", "--prune", "--quiet", "--no-tags", &remote], + )?; + list_branch_refs(repo) +} + +/// 在后台线程 fetch 并列出分支引用,避免阻塞 UI。 +pub async fn fetch_and_list_refs_async(repo: PathBuf) -> Result, GitWorkspaceError> { + spawn_git_task("fetch repository refs", move || fetch_and_list_refs(&repo)).await +} + +/// 使用完整 refname 列出本地与远端分支。 +pub fn list_branch_refs(repo: &Path) -> Result, GitWorkspaceError> { + let remotes = list_remotes(repo)?; + let output = git_output_for_operation( + repo, + "list repository refs", + &[ + "for-each-ref", + "--format=%(refname)%09%(symref)", + "refs/heads", + "refs/remotes", + ], + )?; + let stdout = String::from_utf8(output.stdout).map_err(|_| GitWorkspaceError::InvalidUtf8 { + operation: "list repository refs", + })?; + + parse_branch_ref_records(&stdout, &remotes) +} + +pub(crate) fn parse_branch_ref_records( + stdout: &str, + remotes: &[String], +) -> Result, GitWorkspaceError> { + let mut refs = Vec::new(); + for record in stdout.lines() { + let Some((full_ref, symref)) = record.split_once('\t') else { + return Err(GitWorkspaceError::InvalidBranchRefRecord { + record: record.to_string(), + }); + }; + if full_ref.is_empty() || symref.contains('\t') { + return Err(GitWorkspaceError::InvalidBranchRefRecord { + record: record.to_string(), + }); + } + if !symref.is_empty() { + continue; + } + refs.push(parse_branch_ref(full_ref, remotes)?); + } + + Ok(refs) +} + +/// 在后台线程列出分支引用,避免在 UI 调用线程运行 blocking Git。 +pub async fn list_branch_refs_async(repo: PathBuf) -> Result, GitWorkspaceError> { + spawn_git_task("list repository refs", move || list_branch_refs(&repo)).await +} + +/// 解析 remote HEAD,返回带完整 refname 的默认分支。 +pub fn default_branch(repo: &Path, remote: &str) -> Result { + let symbolic_ref = format!("refs/remotes/{remote}/HEAD"); + let output = git_output_for_operation( + repo, + "read remote default branch", + &["symbolic-ref", &symbolic_ref], + ); + let full_ref = match output { + Ok(output) => decode_stdout(output, "read remote default branch")?, + Err(GitWorkspaceError::CommandFailed { stderr, .. }) => { + return Err(GitWorkspaceError::DefaultBranchNotFound { + repo: repo.to_path_buf(), + remote: remote.to_string(), + stderr, + }); + } + Err(error) => return Err(error), + }; + let prefix = format!("refs/remotes/{remote}/"); + let Some(name) = full_ref.strip_prefix(&prefix) else { + return Err(GitWorkspaceError::DefaultBranchNotFound { + repo: repo.to_path_buf(), + remote: remote.to_string(), + stderr: format!("unexpected symbolic ref `{full_ref}`"), + }); + }; + if name.is_empty() || name == "HEAD" { + return Err(GitWorkspaceError::DefaultBranchNotFound { + repo: repo.to_path_buf(), + remote: remote.to_string(), + stderr: format!("unexpected symbolic ref `{full_ref}`"), + }); + } + + Ok(BranchRef::Remote { + remote: remote.to_string(), + name: name.to_string(), + full_ref, + }) +} + +/// 解析 `git worktree list --porcelain`,保留路径和完整 branch ref。 +pub fn list_worktrees(repo: &Path) -> Result, GitWorkspaceError> { + let output = git_output_for_operation( + repo, + "list repository worktrees", + &["worktree", "list", "--porcelain", "-z"], + )?; + parse_worktrees(&output.stdout) +} + +/// 在后台线程列出 worktree,避免在 UI 调用线程运行 blocking Git。 +pub async fn list_worktrees_async(repo: PathBuf) -> Result, GitWorkspaceError> { + spawn_git_task("list repository worktrees", move || list_worktrees(&repo)).await +} + +/// 将已注册 worktree 转换为可接入的 repository workspace 候选项。 +pub fn existing_worktree_options( + repository_root: &Path, + worktrees: impl IntoIterator, +) -> Vec { + let mut options = worktrees + .into_iter() + .filter_map(|worktree| { + let branch_name = worktree.branch.as_deref()?.strip_prefix("refs/heads/")?; + let is_primary = is_primary_worktree_path(repository_root, &worktree.path); + (!worktree.is_bare + && !worktree.is_detached + && !worktree.is_prunable + && !branch_name.is_empty()) + .then(|| { + if is_primary { + ExistingWorktreeOption::primary(worktree.path, branch_name) + } else { + ExistingWorktreeOption::new(worktree.path, branch_name) + } + }) + }) + .collect::>(); + options.sort_by(|left, right| { + right + .is_primary + .cmp(&left.is_primary) + .then_with(|| left.branch_name.cmp(&right.branch_name)) + .then_with(|| left.path.cmp(&right.path)) + }); + options +} + +/// 判断 worktree 路径是否指向 repository 主工作目录。 +pub(crate) fn is_primary_worktree_path(repository_root: &Path, worktree_path: &Path) -> bool { + if repository_root == worktree_path { + return true; + } + + match ( + dunce::canonicalize(repository_root), + dunce::canonicalize(worktree_path), + ) { + (Ok(repository_root), Ok(worktree_path)) => repository_root == worktree_path, + _ => false, + } +} + +fn resolve_primary_branch( + worktrees: &[WorktreeInfo], + repository_root: &Path, +) -> Result { + let Some(primary_worktree) = worktrees + .iter() + .find(|worktree| worktree.path == repository_root) + else { + return Err(GitWorkspaceError::PrimaryWorktreeDetached { + path: repository_root.to_path_buf(), + }); + }; + if primary_worktree.is_bare || primary_worktree.is_detached { + return Err(GitWorkspaceError::PrimaryWorktreeDetached { + path: repository_root.to_path_buf(), + }); + } + let Some(branch_name) = primary_worktree + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("refs/heads/")) + .filter(|branch| !branch.is_empty()) + else { + return Err(GitWorkspaceError::PrimaryWorktreeDetached { + path: repository_root.to_path_buf(), + }); + }; + Ok(branch_name.to_string()) +} + +/// 校验已注册 worktree 在接入 workspace 前仍存在且检出预期本地分支。 +pub fn validate_existing_worktree( + repository: &Path, + worktree_path: &Path, + local_branch: &str, +) -> Result { + let registered_path = canonicalize(worktree_path)?; + let repository_root = canonicalize(repository)?; + let is_primary = registered_path == repository_root; + + let mut matches = list_worktrees(repository)? + .into_iter() + .filter(|worktree| worktree.path == registered_path); + let Some(worktree) = matches.next() else { + return Err(GitWorkspaceError::WorktreeNotFound { + path: registered_path, + }); + }; + if matches.next().is_some() { + return Err(GitWorkspaceError::AmbiguousWorktree { + path: registered_path, + }); + } + + let expected_branch = format!("refs/heads/{local_branch}"); + if worktree.is_prunable { + return Err(GitWorkspaceError::PrunableWorktreeCannotBeWorkspace { + path: registered_path, + }); + } + if is_primary { + resolve_primary_branch(std::slice::from_ref(&worktree), ®istered_path)?; + } + if worktree.is_bare + || worktree.is_detached + || worktree.branch.as_deref() != Some(&expected_branch) + { + return Err(GitWorkspaceError::WorktreeBranchMismatch { + expected: expected_branch, + actual: worktree + .branch + .unwrap_or_else(|| "".to_string()), + }); + } + + validate_ref_exists(repository, &format!("refs/heads/{local_branch}"))?; + Ok(registered_path) +} + +/// 在后台线程校验已注册 linked worktree,避免阻塞 UI 调用线程。 +pub async fn validate_existing_worktree_async( + repository: PathBuf, + worktree_path: PathBuf, + local_branch: String, +) -> Result { + spawn_git_task("validate existing worktree", move || { + validate_existing_worktree(&repository, &worktree_path, &local_branch) + }) + .await +} + +/// 从完整 remote ref 创建不跟踪 upstream 的新本地分支和 linked worktree。 +pub fn create_from_remote( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, +) -> Result<(), GitWorkspaceError> { + create_from_remote_core( + repository, + remote_ref, + new_branch, + worktree_path, + || {}, + create_remote_worktree, + ) +} + +#[cfg(test)] +pub(crate) fn create_from_remote_with_after_target_claim_hook( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, + after: F, +) -> Result<(), GitWorkspaceError> +where + F: FnOnce(), +{ + create_from_remote_core( + repository, + remote_ref, + new_branch, + worktree_path, + after, + create_remote_worktree, + ) +} + +#[cfg(test)] +pub(crate) fn create_from_remote_with_runner( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, + runner: Runner, +) -> Result<(), GitWorkspaceError> +where + Runner: FnOnce(&Path, &str, &str, &Path, &str) -> Result<(), GitWorkspaceError>, +{ + create_from_remote_core( + repository, + remote_ref, + new_branch, + worktree_path, + || {}, + runner, + ) +} + +fn create_from_remote_core( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, + after_target_claim: AfterTargetClaim, + runner: Runner, +) -> Result<(), GitWorkspaceError> +where + AfterTargetClaim: FnOnce(), + Runner: FnOnce(&Path, &str, &str, &Path, &str) -> Result<(), GitWorkspaceError>, +{ + let expected_oid = validate_remote_ref(repository, remote_ref)?; + validate_new_branch(repository, new_branch)?; + let claim = TargetDirectoryClaim::acquire(worktree_path)?; + after_target_claim(); + claim.ensure_directory()?; + + match runner( + repository, + remote_ref, + new_branch, + &claim.canonical_path, + &expected_oid, + ) { + Ok(()) => { + claim.ensure_directory()?; + verify_remote_worktree_creation( + repository, + &claim.canonical_path, + new_branch, + &expected_oid, + ) + .map_err(|verification_error| { + GitWorkspaceError::WorktreeCreationVerificationFailed { + worktree_path: claim.requested_path, + branch: new_branch.to_string(), + expected_oid, + verification_error: Box::new(verification_error), + } + }) + } + Err(create_error) => Err(worktree_creation_failed( + repository, + &claim, + new_branch, + true, + create_error, + )), + } +} + +fn create_remote_worktree( + repository: &Path, + remote_ref: &str, + new_branch: &str, + claimed_path: &Path, + _: &str, +) -> Result<(), GitWorkspaceError> { + let args = [ + OsStr::new("worktree"), + OsStr::new("add"), + OsStr::new("--no-track"), + OsStr::new("-b"), + OsStr::new(new_branch), + claimed_path.as_os_str(), + OsStr::new(remote_ref), + ]; + git_output_with_os_args_for_operation(repository, "create worktree from remote", &args)?; + Ok(()) +} + +fn verify_remote_worktree_creation( + repository: &Path, + worktree_path: &Path, + branch: &str, + expected_oid: &str, +) -> Result<(), GitWorkspaceError> { + let registered_path = canonicalize(worktree_path)?; + let mut matches = list_worktrees(repository)? + .into_iter() + .filter(|worktree| worktree.path == registered_path); + let Some(worktree) = matches.next() else { + return Err(GitWorkspaceError::WorktreeNotFound { + path: registered_path, + }); + }; + if matches.next().is_some() { + return Err(GitWorkspaceError::AmbiguousWorktree { + path: registered_path, + }); + } + + let expected_branch = format!("refs/heads/{branch}"); + if worktree.is_bare + || worktree.is_detached + || worktree.branch.as_deref() != Some(&expected_branch) + { + return Err(GitWorkspaceError::WorktreeBranchMismatch { + expected: expected_branch, + actual: worktree + .branch + .unwrap_or_else(|| "".to_string()), + }); + } + + let actual_snapshot = direct_ref_snapshot(repository, &expected_branch)?; + if actual_snapshot.direct_oid.as_deref() != Some(expected_oid) + || actual_snapshot.symbolic_target.is_some() + { + return Err(GitWorkspaceError::BranchChanged { + branch: branch.to_string(), + expected_oid: expected_oid.to_string(), + actual_oid: actual_snapshot.direct_oid, + actual_symbolic_target: actual_snapshot.symbolic_target, + }); + } + if let Some(upstream) = branch_upstream(repository, &expected_branch)? { + return Err(GitWorkspaceError::UnexpectedBranchUpstream { + branch: branch.to_string(), + upstream, + }); + } + Ok(()) +} + +/// 在后台线程从 remote ref 创建 linked worktree,避免阻塞 UI。 +pub async fn create_from_remote_async( + repository: PathBuf, + remote_ref: String, + new_branch: String, + worktree_path: PathBuf, +) -> Result<(), GitWorkspaceError> { + spawn_git_task("create worktree from remote", move || { + create_from_remote(&repository, &remote_ref, &new_branch, &worktree_path) + }) + .await +} + +/// 从现有本地分支创建 linked worktree。 +pub fn create_from_local( + repository: &Path, + local_branch: &str, + worktree_path: &Path, +) -> Result<(), GitWorkspaceError> { + create_from_local_core( + repository, + local_branch, + worktree_path, + || {}, + create_local_worktree, + ) +} + +#[cfg(test)] +pub(crate) fn create_from_local_with_after_target_claim_hook( + repository: &Path, + branch: &str, + path: &Path, + after: F, +) -> Result<(), GitWorkspaceError> +where + F: FnOnce(), +{ + create_from_local_core(repository, branch, path, after, create_local_worktree) +} + +#[cfg(test)] +pub(crate) fn create_from_local_with_runner( + repository: &Path, + branch: &str, + path: &Path, + runner: Runner, +) -> Result<(), GitWorkspaceError> +where + Runner: FnOnce(&Path, &str, &Path) -> Result<(), GitWorkspaceError>, +{ + create_from_local_core(repository, branch, path, || {}, runner) +} + +fn create_from_local_core( + repository: &Path, + local_branch: &str, + worktree_path: &Path, + after_target_claim: AfterTargetClaim, + runner: Runner, +) -> Result<(), GitWorkspaceError> +where + AfterTargetClaim: FnOnce(), + Runner: FnOnce(&Path, &str, &Path) -> Result<(), GitWorkspaceError>, +{ + let full_ref = format!("refs/heads/{local_branch}"); + validate_ref_exists(repository, &full_ref)?; + if let Some(worktree) = list_worktrees(repository)? + .into_iter() + .find(|worktree| worktree.branch.as_deref() == Some(&full_ref)) + { + return Err(GitWorkspaceError::BranchAlreadyCheckedOut { + branch: local_branch.to_string(), + path: worktree.path, + }); + } + let claim = TargetDirectoryClaim::acquire(worktree_path)?; + after_target_claim(); + claim.ensure_directory()?; + + match runner(repository, local_branch, &claim.canonical_path) { + Ok(()) => claim.ensure_directory(), + Err(create_error) => Err(worktree_creation_failed( + repository, + &claim, + local_branch, + false, + create_error, + )), + } +} + +fn create_local_worktree( + repository: &Path, + local_branch: &str, + claimed_path: &Path, +) -> Result<(), GitWorkspaceError> { + let args = [ + OsStr::new("worktree"), + OsStr::new("add"), + claimed_path.as_os_str(), + OsStr::new(local_branch), + ]; + git_output_with_os_args_for_operation(repository, "create worktree from local branch", &args)?; + Ok(()) +} + +/// 在后台线程从本地分支创建 linked worktree,避免阻塞 UI。 +pub async fn create_from_local_async( + repository: PathBuf, + local_branch: String, + worktree_path: PathBuf, +) -> Result<(), GitWorkspaceError> { + spawn_git_task("create worktree from local branch", move || { + create_from_local(&repository, &local_branch, &worktree_path) + }) + .await +} + +/// 只读校验 linked worktree 是否可删除,并捕获供 UI 使用的建议快照。 +pub fn deletion_preflight( + repository: &Path, + worktree_path: &Path, + delete_branch: bool, +) -> Result { + capture_deletion_candidate(repository, worktree_path, delete_branch) +} + +fn capture_deletion_candidate( + repository: &Path, + worktree_path: &Path, + include_merge_target: bool, +) -> Result { + let worktree_path = canonicalize(worktree_path)?; + let mut matches = list_worktrees(repository)? + .into_iter() + .filter(|worktree| worktree.path == worktree_path); + let Some(worktree) = matches.next() else { + return Err(GitWorkspaceError::WorktreeNotFound { + path: worktree_path, + }); + }; + if matches.next().is_some() { + return Err(GitWorkspaceError::AmbiguousWorktree { + path: worktree_path, + }); + } + if worktree.is_bare || worktree.is_detached { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + } + let Some(full_ref) = worktree.branch else { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + }; + let Some(branch) = full_ref.strip_prefix("refs/heads/") else { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + }; + if branch.is_empty() { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + } + let branch_snapshot = direct_ref_snapshot(repository, &full_ref)?; + if branch_snapshot.symbolic_target.is_some() { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + } + let Some(branch_oid) = branch_snapshot.direct_oid else { + return Err(GitWorkspaceError::BranchNotFound { full_ref }); + }; + + let status = git_output_for_operation( + &worktree_path, + "check worktree status", + &["status", "--porcelain", "--untracked-files=all"], + )?; + if !status.stdout.is_empty() { + return Err(GitWorkspaceError::DirtyWorktree { + path: worktree_path, + }); + } + + if !include_merge_target { + return Ok(DeletionPreflight { + worktree_path, + branch: branch.to_string(), + branch_ref: full_ref, + branch_oid, + merge_target: None, + }); + } + + let merge_target = resolve_merge_target_ref(repository, &full_ref)?; + if merge_target == full_ref { + return Err(GitWorkspaceError::InvalidMergeTarget { + branch_ref: full_ref, + target_ref: merge_target, + }); + } + let target_oid = resolve_commit_oid(repository, &merge_target)?; + let is_merged = is_ancestor(repository, &branch_oid, &target_oid)?; + + Ok(DeletionPreflight { + worktree_path, + branch: branch.to_string(), + branch_ref: full_ref, + branch_oid, + merge_target: Some(MergeTargetSnapshot { + full_ref: merge_target, + oid: target_oid, + is_merged, + }), + }) +} + +fn resolve_merge_target_ref( + repository: &Path, + branch_ref: &str, +) -> Result { + match branch_upstream(repository, branch_ref)? { + Some(upstream) => Ok(upstream), + None => { + let remote = primary_remote(repository)?; + match default_branch(repository, &remote)? { + BranchRef::Remote { full_ref, .. } => Ok(full_ref), + BranchRef::Local { full_ref, .. } => { + Err(GitWorkspaceError::InvalidRemoteRef { full_ref }) + } + } + } + } +} + +fn is_ancestor( + repository: &Path, + branch_oid: &str, + target_oid: &str, +) -> Result { + let args = ["merge-base", "--is-ancestor", branch_oid, target_oid]; + let output = + git_output_allow_failure_for_operation(repository, "check branch merge status", &args)?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + Some(exit_code) => { + debug_assert_ne!(exit_code, 0); + debug_assert_ne!(exit_code, 1); + return Err(command_failed("check branch merge status", &args, &output)); + } + None => { + return Err(command_failed("check branch merge status", &args, &output)); + } + } +} + +/// 在后台线程执行 linked worktree 删除预检,避免阻塞 UI。 +pub async fn deletion_preflight_async( + repository: PathBuf, + worktree_path: PathBuf, + delete_branch: bool, +) -> Result { + spawn_git_task("preflight worktree deletion", move || { + deletion_preflight(&repository, &worktree_path, delete_branch) + }) + .await +} + +/// 删除已通过完整预检的 linked worktree,并按需删除对应本地分支。 +pub fn remove_workspace( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, +) -> Result<(), GitWorkspaceError> { + remove_workspace_inner( + repository, + worktree_path, + branch, + delete_branch, + force_branch, + || {}, + || {}, + remove_registered_worktree, + |_| {}, + ) +} + +#[cfg(test)] +pub(crate) fn remove_workspace_with_transaction_hooks( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + after_candidate: AfterCandidate, + after_prepared: AfterPrepared, +) -> Result<(), GitWorkspaceError> +where + AfterCandidate: FnOnce(), + AfterPrepared: FnOnce(), +{ + remove_workspace_inner( + repository, + worktree_path, + branch, + delete_branch, + force_branch, + after_candidate, + after_prepared, + remove_registered_worktree, + |_| {}, + ) +} + +#[cfg(test)] +pub(crate) fn remove_workspace_with_hook( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + after_candidate: AfterCandidate, +) -> Result<(), GitWorkspaceError> +where + AfterCandidate: FnOnce(), +{ + remove_workspace_inner( + repository, + worktree_path, + branch, + delete_branch, + force_branch, + after_candidate, + || {}, + remove_registered_worktree, + |_| {}, + ) +} + +#[cfg(test)] +pub(crate) fn remove_workspace_with_remove_runner( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + remove_runner: RemoveRunner, +) -> Result<(), GitWorkspaceError> +where + RemoveRunner: FnOnce(&Path, &Path) -> Result<(), GitWorkspaceError>, +{ + remove_workspace_inner( + repository, + worktree_path, + branch, + delete_branch, + force_branch, + || {}, + || {}, + remove_runner, + |_| {}, + ) +} + +#[cfg(test)] +pub(crate) fn remove_workspace_with_after_remove_hook( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + after_remove: AfterRemove, +) -> Result<(), GitWorkspaceError> +where + AfterRemove: FnOnce(&mut PreparedRefDelete), +{ + remove_workspace_inner( + repository, + worktree_path, + branch, + delete_branch, + force_branch, + || {}, + || {}, + remove_registered_worktree, + after_remove, + ) +} + +fn remove_workspace_inner( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + after_candidate: AfterCandidate, + after_prepared: AfterPrepared, + remove_runner: RemoveRunner, + after_remove: AfterRemove, +) -> Result<(), GitWorkspaceError> +where + AfterCandidate: FnOnce(), + AfterPrepared: FnOnce(), + RemoveRunner: FnOnce(&Path, &Path) -> Result<(), GitWorkspaceError>, + AfterRemove: FnOnce(&mut PreparedRefDelete), +{ + let candidate = + capture_deletion_candidate(repository, worktree_path, delete_branch && !force_branch)?; + validate_requested_branch(branch, &candidate)?; + if !delete_branch { + return remove_registered_worktree(repository, &candidate.worktree_path); + } + after_candidate(); + let target = if force_branch { + None + } else { + let target = candidate.merge_target.as_ref().ok_or_else(|| { + GitWorkspaceError::MissingMergeTarget { + branch_ref: candidate.branch_ref.clone(), + } + })?; + Some(LockedRef { + full_ref: &target.full_ref, + oid: &target.oid, + }) + }; + let mut transaction = PreparedRefDelete::prepare( + repository, + &candidate.branch_ref, + &candidate.branch_oid, + target, + ) + .map_err(|source| GitWorkspaceError::RefTransaction { source })?; + after_prepared(); + let registered_path = match validate_locked_deletion( + repository, + worktree_path, + branch, + &candidate.branch_oid, + candidate.merge_target.as_ref(), + force_branch, + ) { + Ok(path) => path, + Err(error) => return abort_after_failed_operation(transaction, error), + }; + if let Err(error) = remove_runner(repository, ®istered_path) { + return abort_after_failed_operation(transaction, error); + } + after_remove(&mut transaction); + if let Err(source) = transaction.commit() { + let branch_ref = candidate.branch_ref.clone(); + let branch_oid = candidate.branch_oid.clone(); + let merge_target_ref = candidate + .merge_target + .as_ref() + .map(|target| target.full_ref.clone()); + let merge_target_oid = candidate + .merge_target + .as_ref() + .map(|target| target.oid.clone()); + let (inspection_error, actual_snapshot) = match direct_ref_snapshot(repository, &branch_ref) + { + Ok(snapshot) => (None, Some(snapshot)), + Err(error) => (Some(Box::new(error)), None), + }; + return Err(GitWorkspaceError::BranchDeleteTransactionFailed { + worktree_path: registered_path, + worktree_removed: true, + branch_ref, + branch_oid: branch_oid.clone(), + merge_target_ref, + merge_target_oid, + source, + inspection_error: inspection_error.or_else(|| { + actual_snapshot.and_then(|snapshot| { + (snapshot.direct_oid.is_some() || snapshot.symbolic_target.is_some()).then( + || { + Box::new(GitWorkspaceError::BranchChanged { + branch: branch.to_string(), + expected_oid: branch_oid, + actual_oid: snapshot.direct_oid, + actual_symbolic_target: snapshot.symbolic_target, + }) + }, + ) + }) + }), + }); + } + Ok(()) +} + +fn validate_requested_branch( + branch: &str, + candidate: &DeletionPreflight, +) -> Result<(), GitWorkspaceError> { + if candidate.branch == branch { + Ok(()) + } else { + Err(GitWorkspaceError::WorktreeBranchMismatch { + expected: branch.to_string(), + actual: candidate.branch.clone(), + }) + } +} + +fn remove_registered_worktree(repository: &Path, path: &Path) -> Result<(), GitWorkspaceError> { + let args = [ + OsStr::new("worktree"), + OsStr::new("remove"), + path.as_os_str(), + ]; + git_output_with_os_args_for_operation(repository, "remove worktree", &args)?; + Ok(()) +} + +fn validate_locked_deletion( + repository: &Path, + path: &Path, + branch: &str, + expected_oid: &str, + expected_target: Option<&MergeTargetSnapshot>, + force: bool, +) -> Result { + let locked = capture_deletion_candidate(repository, path, !force)?; + validate_requested_branch(branch, &locked)?; + if locked.branch_oid != expected_oid { + let snapshot = direct_ref_snapshot(repository, &locked.branch_ref)?; + return Err(GitWorkspaceError::BranchChanged { + branch: locked.branch, + expected_oid: expected_oid.to_string(), + actual_oid: snapshot.direct_oid, + actual_symbolic_target: snapshot.symbolic_target, + }); + } + if !force { + let expected = expected_target.ok_or_else(|| GitWorkspaceError::MissingMergeTarget { + branch_ref: locked.branch_ref.clone(), + })?; + let actual = locked + .merge_target + .ok_or_else(|| GitWorkspaceError::MissingMergeTarget { + branch_ref: locked.branch_ref.clone(), + })?; + if actual.full_ref != expected.full_ref { + return Err(GitWorkspaceError::MergeTargetChanged { + expected: expected.full_ref.clone(), + actual: actual.full_ref, + }); + } + if actual.oid != expected.oid { + return Err(GitWorkspaceError::RefChanged { + full_ref: expected.full_ref.clone(), + expected_oid: expected.oid.clone(), + actual_oid: actual.oid, + }); + } + if !actual.is_merged { + return Err(GitWorkspaceError::BranchNotMerged { + branch: locked.branch, + merge_target: expected.full_ref.clone(), + }); + } + } + Ok(locked.worktree_path) +} + +fn abort_after_failed_operation( + transaction: PreparedRefDelete, + operation_error: GitWorkspaceError, +) -> Result<(), GitWorkspaceError> { + match transaction.abort() { + Ok(()) => Err(operation_error), + Err(abort_error) => Err(GitWorkspaceError::BranchDeleteAbortFailed { + operation_error: Box::new(operation_error), + abort_error, + }), + } +} + +/// 在后台线程删除 linked worktree 和可选本地分支,避免阻塞 UI。 +pub async fn remove_workspace_async( + repository: PathBuf, + worktree_path: PathBuf, + branch: String, + delete_branch: bool, + force_branch: bool, +) -> Result<(), GitWorkspaceError> { + spawn_git_task("remove worktree", move || { + remove_workspace( + &repository, + &worktree_path, + &branch, + delete_branch, + force_branch, + ) + }) + .await +} + +/// 从标准 Git URL、SCP 风格地址或本地路径解析 repository 名。 +pub fn repository_name_from_url(url: &str) -> Result { + let trimmed = url.trim(); + let name = if is_windows_drive_absolute(trimmed) { + repository_name_from_local_path(trimmed) + } else { + match url::Url::parse(trimmed) { + Ok(parsed) => parsed + .path_segments() + .and_then(|segments| segments.filter(|segment| !segment.is_empty()).next_back()) + .map(str::to_string), + Err(_) => repository_name_from_local_path(trimmed), + } + } + .map(|name| name.strip_suffix(".git").unwrap_or(&name).to_string()) + .filter(|name| !name.is_empty()); + + name.ok_or_else(|| GitWorkspaceError::RepositoryNameMissing { + url: url.to_string(), + }) +} + +fn is_windows_drive_absolute(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') +} + +fn repository_name_from_local_path(path: &str) -> Option { + path.trim_end_matches(['/', '\\']) + .rsplit(['/', '\\', ':']) + .find(|segment| !segment.is_empty()) + .map(str::to_string) +} + +fn validate_clone_directory_name(name: &str) -> Result<(), GitWorkspaceError> { + let mut components = Path::new(name).components(); + let valid = matches!( + (components.next(), components.next()), + (Some(Component::Normal(component)), None) if component == OsStr::new(name) + ); + if valid { + Ok(()) + } else { + Err(GitWorkspaceError::InvalidCloneDirectoryName { + name: name.to_string(), + }) + } +} + +/// 将分支名转换为安全目录 slug,并追加 workspace ID 的前 8 位。 +pub fn workspace_dir_name(branch: &str, workspace_id: &str) -> String { + let mut slug = String::new(); + let mut last_was_separator = false; + for character in branch.chars() { + let safe = character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'); + if safe { + slug.push(character); + last_was_separator = false; + } else if !slug.is_empty() && !last_was_separator { + slug.push('-'); + last_was_separator = true; + } + } + let slug = slug.trim_matches('-'); + let slug = if slug.is_empty() { "workspace" } else { slug }; + let short_id: String = workspace_id.chars().take(8).collect(); + if short_id.is_empty() { + slug.to_string() + } else { + format!("{slug}-{short_id}") + } +} + +fn clone_to_target(url: &str, target: &Path) -> Result { + if target.exists() { + return Err(GitWorkspaceError::TargetExists { + path: target.to_path_buf(), + }); + } + std::fs::create_dir(target).map_err(|source| GitWorkspaceError::CreateTarget { + path: target.to_path_buf(), + source, + })?; + + let result = git_output_for_operation(target, "clone repository", &["clone", "--", url, "."]) + .and_then(|_| validate_repository(target)); + match result { + Ok(repository) => Ok(repository), + Err(clone_error) => { + if let Err(cleanup_source) = std::fs::remove_dir_all(target) { + return Err(GitWorkspaceError::CleanupFailed { + path: target.to_path_buf(), + cleanup_source, + clone_error: Box::new(clone_error), + }); + } + Err(clone_error) + } + } +} + +fn primary_remote(repo: &Path) -> Result { + let remotes = list_remotes(repo)?; + remotes + .iter() + .find(|remote| remote.as_str() == "origin") + .or_else(|| remotes.first()) + .cloned() + .ok_or_else(|| GitWorkspaceError::RemoteNotFound { + repo: repo.to_path_buf(), + }) +} + +fn list_remotes(repo: &Path) -> Result, GitWorkspaceError> { + let stdout = output_string(repo, "list repository remotes", &["remote"])?; + Ok(stdout + .lines() + .map(str::trim) + .filter(|remote| !remote.is_empty()) + .map(str::to_string) + .collect()) +} + +fn validate_remote_ref(repository: &Path, remote_ref: &str) -> Result { + let remotes = list_remotes(repository)?; + match parse_branch_ref(remote_ref, &remotes) { + Ok(BranchRef::Remote { name, .. }) if name != "HEAD" => {} + Ok(BranchRef::Local { .. }) + | Ok(BranchRef::Remote { .. }) + | Err(GitWorkspaceError::InvalidBranchRef { .. }) + | Err(GitWorkspaceError::AmbiguousRemoteRef { .. }) => { + return Err(GitWorkspaceError::InvalidRemoteRef { + full_ref: remote_ref.to_string(), + }); + } + Err(error) => return Err(error), + } + validate_ref_exists(repository, remote_ref)?; + + let output = git_output_allow_failure_for_operation( + repository, + "check whether remote ref is symbolic", + &["symbolic-ref", "--quiet", remote_ref], + )?; + match output.status.code() { + Some(0) => Err(GitWorkspaceError::InvalidRemoteRef { + full_ref: remote_ref.to_string(), + }), + Some(1) => resolve_commit_oid(repository, remote_ref), + Some(exit_code) => { + debug_assert_ne!(exit_code, 0); + debug_assert_ne!(exit_code, 1); + Err(command_failed( + "check whether remote ref is symbolic", + &["symbolic-ref", "--quiet", remote_ref], + &output, + )) + } + None => Err(command_failed( + "check whether remote ref is symbolic", + &["symbolic-ref", "--quiet", remote_ref], + &output, + )), + } +} + +fn validate_new_branch(repository: &Path, branch: &str) -> Result<(), GitWorkspaceError> { + let output = git_output_allow_failure_for_operation( + repository, + "validate new branch name", + &["check-ref-format", "--branch", branch], + )?; + if !output.status.success() { + return Err(GitWorkspaceError::InvalidBranchName { + branch: branch.to_string(), + }); + } + + let full_ref = format!("refs/heads/{branch}"); + if ref_exists(repository, &full_ref)? { + return Err(GitWorkspaceError::BranchAlreadyExists { + branch: branch.to_string(), + }); + } + Ok(()) +} + +fn validate_ref_exists(repository: &Path, full_ref: &str) -> Result<(), GitWorkspaceError> { + if ref_exists(repository, full_ref)? { + Ok(()) + } else { + Err(GitWorkspaceError::BranchNotFound { + full_ref: full_ref.to_string(), + }) + } +} + +fn ref_exists(repository: &Path, full_ref: &str) -> Result { + let args = ["show-ref", "--verify", "--quiet", full_ref]; + let output = + git_output_allow_failure_for_operation(repository, "check branch ref existence", &args)?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + Some(exit_code) => { + debug_assert_ne!(exit_code, 0); + debug_assert_ne!(exit_code, 1); + Err(command_failed("check branch ref existence", &args, &output)) + } + None => Err(command_failed("check branch ref existence", &args, &output)), + } +} + +struct TargetDirectoryClaim { + requested_path: PathBuf, + canonical_path: PathBuf, +} + +impl TargetDirectoryClaim { + fn acquire(path: &Path) -> Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent).map_err(|source| { + GitWorkspaceError::TargetClaimFailed { + path: path.to_path_buf(), + source, + } + })?; + } + std::fs::create_dir(path).map_err(|source| match source.kind() { + io::ErrorKind::AlreadyExists => GitWorkspaceError::TargetExists { + path: path.to_path_buf(), + }, + _ => GitWorkspaceError::TargetClaimFailed { + path: path.to_path_buf(), + source, + }, + })?; + let canonical_path = canonicalize(path)?; + Ok(Self { + requested_path: path.to_path_buf(), + canonical_path, + }) + } + + fn ensure_directory(&self) -> Result<(), GitWorkspaceError> { + let metadata = std::fs::symlink_metadata(&self.requested_path).map_err(|source| { + GitWorkspaceError::ClaimedTargetInspection { + path: self.requested_path.clone(), + source, + } + })?; + if metadata.file_type().is_dir() { + Ok(()) + } else { + Err(GitWorkspaceError::ClaimedTargetNotDirectory { + path: self.requested_path.clone(), + }) + } + } +} + +fn worktree_creation_failed( + repository: &Path, + claim: &TargetDirectoryClaim, + branch: &str, + branch_may_remain: bool, + create_error: GitWorkspaceError, +) -> GitWorkspaceError { + let (worktree_registered, mut cleanup_error) = match list_worktrees(repository) { + Ok(worktrees) => ( + Some( + worktrees + .into_iter() + .any(|worktree| worktree.path == claim.canonical_path), + ), + None, + ), + Err(error) => (None, Some(Box::new(error))), + }; + let mut claimed_directory_removed = false; + if worktree_registered == Some(false) { + match remove_claimed_directory_if_empty(claim) { + Ok(()) => claimed_directory_removed = true, + Err(error) => cleanup_error = Some(Box::new(error)), + } + } + + GitWorkspaceError::WorktreeCreationFailed { + worktree_path: claim.requested_path.clone(), + branch: branch.to_string(), + branch_may_remain, + worktree_registered, + claimed_directory_removed, + create_error: Box::new(create_error), + cleanup_error, + } +} + +fn remove_claimed_directory_if_empty( + claim: &TargetDirectoryClaim, +) -> Result<(), GitWorkspaceError> { + let mut entries = std::fs::read_dir(&claim.requested_path).map_err(|source| { + GitWorkspaceError::ClaimedTargetCleanupFailed { + path: claim.requested_path.clone(), + source, + } + })?; + match entries.next() { + None => {} + Some(Ok(_)) => { + return Err(GitWorkspaceError::ClaimedTargetNotEmpty { + path: claim.requested_path.clone(), + }); + } + Some(Err(source)) => { + return Err(GitWorkspaceError::ClaimedTargetCleanupFailed { + path: claim.requested_path.clone(), + source, + }); + } + } + std::fs::remove_dir(&claim.requested_path).map_err(|source| { + GitWorkspaceError::ClaimedTargetCleanupFailed { + path: claim.requested_path.clone(), + source, + } + }) +} + +fn branch_upstream(repository: &Path, full_ref: &str) -> Result, GitWorkspaceError> { + let upstream = output_string( + repository, + "read branch upstream", + &["for-each-ref", "--format=%(upstream)", full_ref], + )?; + if upstream.is_empty() { + Ok(None) + } else { + Ok(Some(upstream)) + } +} + +fn resolve_commit_oid(repository: &Path, full_ref: &str) -> Result { + let commit_ref = format!("{full_ref}^{{commit}}"); + output_string( + repository, + "resolve branch commit OID", + &["rev-parse", "--verify", &commit_ref], + ) +} + +#[derive(Debug, Default)] +struct DirectRefSnapshot { + direct_oid: Option, + symbolic_target: Option, +} + +fn direct_ref_snapshot( + repository: &Path, + full_ref: &str, +) -> Result { + if let Some(symbolic_target) = symbolic_ref_target(repository, full_ref)? { + return Ok(DirectRefSnapshot { + direct_oid: None, + symbolic_target: Some(symbolic_target), + }); + } + let stdout = output_string( + repository, + "read direct branch ref", + &[ + "for-each-ref", + "--format=%(refname)%09%(objectname)", + full_ref, + ], + )?; + let mut direct_oid = None; + for record in stdout.lines() { + let mut fields = record.split('\t'); + let refname = fields.next(); + let objectname = fields.next(); + let trailing = fields.next(); + let (Some(refname), Some(objectname), None) = (refname, objectname, trailing) else { + return Err(GitWorkspaceError::InvalidDirectRefRecord { + record: record.to_string(), + }); + }; + if refname != full_ref { + continue; + } + if direct_oid.is_some() || objectname.is_empty() { + return Err(GitWorkspaceError::InvalidDirectRefRecord { + record: record.to_string(), + }); + } + direct_oid = Some(objectname.to_string()); + } + if let Some(symbolic_target) = symbolic_ref_target(repository, full_ref)? { + return Ok(DirectRefSnapshot { + direct_oid: None, + symbolic_target: Some(symbolic_target), + }); + } + Ok(DirectRefSnapshot { + direct_oid, + symbolic_target: None, + }) +} + +fn symbolic_ref_target( + repository: &Path, + full_ref: &str, +) -> Result, GitWorkspaceError> { + let args = ["symbolic-ref", "--quiet", full_ref]; + let output = + git_output_allow_failure_for_operation(repository, "inspect branch symbolic ref", &args)?; + match output.status.code() { + Some(0) => decode_stdout(output, "inspect branch symbolic ref").map(Some), + Some(1) => Ok(None), + Some(exit_code) => { + debug_assert_ne!(exit_code, 0); + debug_assert_ne!(exit_code, 1); + Err(command_failed( + "inspect branch symbolic ref", + &args, + &output, + )) + } + None => Err(command_failed( + "inspect branch symbolic ref", + &args, + &output, + )), + } +} + +pub(crate) fn parse_branch_ref( + full_ref: &str, + remotes: &[String], +) -> Result { + if let Some(name) = full_ref.strip_prefix("refs/heads/") { + if !name.is_empty() { + return Ok(BranchRef::Local { + name: name.to_string(), + full_ref: full_ref.to_string(), + }); + } + } + + if let Some(remote_ref) = full_ref.strip_prefix("refs/remotes/") { + let mut matches = Vec::new(); + for remote in remotes { + let prefix = format!("{remote}/"); + if let Some(name) = remote_ref.strip_prefix(&prefix) { + if !name.is_empty() { + matches.push((remote.clone(), name.to_string())); + } + } + } + return match matches.as_slice() { + [(remote, name)] => Ok(BranchRef::Remote { + remote: remote.clone(), + name: name.clone(), + full_ref: full_ref.to_string(), + }), + [] => Err(GitWorkspaceError::InvalidBranchRef { + full_ref: full_ref.to_string(), + }), + matches => Err(GitWorkspaceError::AmbiguousRemoteRef { + full_ref: full_ref.to_string(), + remotes: matches.iter().map(|(remote, _)| remote.clone()).collect(), + }), + }; + } + + Err(GitWorkspaceError::InvalidBranchRef { + full_ref: full_ref.to_string(), + }) +} + +#[derive(Default)] +struct WorktreeBuilder { + path: Option, + head: Option, + branch: Option, + is_bare: bool, + is_detached: bool, + is_locked: bool, + locked_reason: Option, + is_prunable: bool, + prunable_reason: Option, +} + +impl WorktreeBuilder { + fn finish(self) -> Result { + let path = self + .path + .ok_or_else(|| GitWorkspaceError::InvalidWorktreeRecord { + record: "missing worktree path".to_string(), + })?; + let path = match path.canonicalize() { + Ok(path) => path, + Err(source) if self.is_prunable && source.kind() == io::ErrorKind::NotFound => { + normalize_missing_path(&path)? + } + Err(source) => { + return Err(GitWorkspaceError::Canonicalize { path, source }); + } + }; + Ok(WorktreeInfo { + path, + head: self.head, + branch: self.branch, + is_bare: self.is_bare, + is_detached: self.is_detached, + is_locked: self.is_locked, + locked_reason: self.locked_reason, + is_prunable: self.is_prunable, + prunable_reason: self.prunable_reason, + }) + } +} + +fn normalize_missing_path(path: &Path) -> Result { + let mut ancestor = path; + let mut missing_components = Vec::new(); + while !ancestor.exists() { + let Some(file_name) = ancestor.file_name() else { + return canonicalize(path); + }; + missing_components.push(file_name.to_os_string()); + let Some(parent) = ancestor.parent() else { + return canonicalize(path); + }; + ancestor = parent; + } + + let mut normalized = canonicalize(ancestor)?; + for component in missing_components.into_iter().rev() { + normalized.push(component); + } + Ok(normalized) +} + +pub(crate) fn parse_worktrees(stdout: &[u8]) -> Result, GitWorkspaceError> { + let mut worktrees = Vec::new(); + let mut current = WorktreeBuilder::default(); + let mut has_record = false; + + for field in stdout.split(|byte| *byte == 0) { + if field.is_empty() { + if has_record { + worktrees.push(current.finish()?); + current = WorktreeBuilder::default(); + has_record = false; + } + continue; + } + has_record = true; + if let Some(path) = field.strip_prefix(b"worktree ") { + current.path = Some(path_from_git_bytes(path)?); + } else if let Some(head) = field.strip_prefix(b"HEAD ") { + current.head = Some(decode_worktree_text(head)?); + } else if let Some(branch) = field.strip_prefix(b"branch ") { + current.branch = Some(decode_worktree_text(branch)?); + } else if field == b"bare" { + current.is_bare = true; + } else if field == b"detached" { + current.is_detached = true; + } else if field == b"locked" { + current.is_locked = true; + } else if let Some(reason) = field.strip_prefix(b"locked ") { + current.is_locked = true; + current.locked_reason = Some(decode_worktree_text(reason)?); + } else if field == b"prunable" { + current.is_prunable = true; + } else if let Some(reason) = field.strip_prefix(b"prunable ") { + current.is_prunable = true; + current.prunable_reason = Some(decode_worktree_text(reason)?); + } else { + return Err(GitWorkspaceError::InvalidWorktreeRecord { + record: format!("{field:?}"), + }); + } + } + if has_record { + worktrees.push(current.finish()?); + } + Ok(worktrees) +} + +fn decode_worktree_text(field: &[u8]) -> Result { + String::from_utf8(field.to_vec()).map_err(|_| GitWorkspaceError::InvalidWorktreeRecord { + record: format!("non-UTF-8 text field {field:?}"), + }) +} + +#[cfg(unix)] +fn path_from_git_bytes(path: &[u8]) -> Result { + use std::os::unix::ffi::OsStringExt; + + Ok(PathBuf::from(OsString::from_vec(path.to_vec()))) +} + +#[cfg(not(unix))] +fn path_from_git_bytes(path: &[u8]) -> Result { + String::from_utf8(path.to_vec()) + .map(PathBuf::from) + .map_err(|_| GitWorkspaceError::InvalidWorktreeRecord { + record: format!("non-UTF-8 worktree path {path:?}"), + }) +} + +fn canonicalize(path: &Path) -> Result { + path.canonicalize() + .map_err(|source| GitWorkspaceError::Canonicalize { + path: path.to_path_buf(), + source, + }) +} + +fn output_path( + repo: &Path, + operation: &'static str, + args: &[&str], +) -> Result { + let mut path_args = vec!["-c", "core.quotePath=false"]; + path_args.extend_from_slice(args); + let output = git_output_for_operation(repo, operation, &path_args)?; + decode_git_path_output(&output.stdout, operation) +} + +pub(crate) fn decode_git_path_output( + stdout: &[u8], + operation: &'static str, +) -> Result { + #[cfg(unix)] + let path = stdout.strip_suffix(b"\n").unwrap_or(stdout); + #[cfg(not(unix))] + let path = stdout + .strip_suffix(b"\r\n") + .or_else(|| stdout.strip_suffix(b"\n")) + .unwrap_or(stdout); + path_from_git_bytes(path).map_err(|error| match error { + GitWorkspaceError::InvalidWorktreeRecord { .. } => { + GitWorkspaceError::InvalidUtf8 { operation } + } + error => error, + }) +} + +fn output_string( + repo: &Path, + operation: &'static str, + args: &[&str], +) -> Result { + let output = git_output_for_operation(repo, operation, args)?; + decode_stdout(output, operation) +} + +fn decode_stdout(output: Output, operation: &'static str) -> Result { + String::from_utf8(output.stdout) + .map(|stdout| stdout.trim().to_string()) + .map_err(|_| GitWorkspaceError::InvalidUtf8 { operation }) +} + +fn git_output(repo: &Path, args: &[&str]) -> Result { + git_output_for_operation(repo, "run git command", args) +} + +fn git_output_for_operation( + repo: &Path, + operation: &'static str, + args: &[&str], +) -> Result { + let display_args = args.iter().map(|arg| (*arg).to_string()).collect(); + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + git_output_with_display_args_for_operation(repo, operation, &os_args, display_args) +} + +pub(crate) fn git_output_with_os_args_for_operation( + repo: &Path, + operation: &'static str, + args: &[&OsStr], +) -> Result { + let display_args = args.iter().map(|arg| format!("{arg:?}")).collect(); + git_output_with_display_args_for_operation(repo, operation, args, display_args) +} + +fn git_output_with_display_args_for_operation( + repo: &Path, + operation: &'static str, + args: &[&OsStr], + display_args: Vec, +) -> Result { + let output = execute_git(repo, operation, args, display_args.clone())?; + if output.status.success() { + Ok(output) + } else { + Err(GitWorkspaceError::CommandFailed { + operation, + args: display_args, + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) + } +} + +fn git_output_allow_failure_for_operation( + repo: &Path, + operation: &'static str, + args: &[&str], +) -> Result { + let display_args = args.iter().map(|arg| (*arg).to_string()).collect(); + let os_args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + execute_git(repo, operation, &os_args, display_args) +} + +fn execute_git( + repo: &Path, + operation: &'static str, + args: &[&OsStr], + display_args: Vec, +) -> Result { + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(repo) + .args(args.iter().copied()) + .output() + .map_err(|source| GitWorkspaceError::CommandIo { + operation, + args: display_args, + source, + })?; + Ok(output) +} + +fn command_failed(operation: &'static str, args: &[&str], output: &Output) -> GitWorkspaceError { + GitWorkspaceError::CommandFailed { + operation, + args: args.iter().map(|arg| (*arg).to_string()).collect(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + } +} + +async fn spawn_git_task(operation: &'static str, task: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(task).await.map_err(|error| { + GitWorkspaceError::BackgroundTaskFailed { + operation, + message: error.to_string(), + } + })? +} diff --git a/app/src/project_organization/git/ref_transaction.rs b/app/src/project_organization/git/ref_transaction.rs new file mode 100644 index 00000000000..76fff917826 --- /dev/null +++ b/app/src/project_organization/git/ref_transaction.rs @@ -0,0 +1,280 @@ +use std::{ + io::{self, BufRead, BufReader, BufWriter, Read, Write}, + path::Path, + process::Stdio, + thread, +}; + +/// Git 引用事务的当前协议阶段。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RefTransactionStage { + Start, + Prepare, + Commit, + Abort, + Wait, + ReadStderr, +} + +/// Git 引用事务执行失败。 +#[derive(Debug, thiserror::Error)] +pub enum RefTransactionError { + #[error("git update-ref transaction cannot verify and delete the same ref `{full_ref}`")] + ConflictingRefUpdate { full_ref: String }, + #[error("failed to start git update-ref transaction: {source}")] + Start { + #[source] + source: io::Error, + }, + #[error("git update-ref transaction is missing its {pipe} pipe")] + MissingPipe { pipe: &'static str }, + #[error("failed to communicate with git update-ref during {stage:?}: {source}")] + Io { + stage: RefTransactionStage, + #[source] + source: io::Error, + }, + #[error("git update-ref returned `{response}` during {stage:?}, expected `{expected}`")] + UnexpectedResponse { + stage: RefTransactionStage, + expected: &'static str, + response: String, + }, + #[error("git update-ref exited during {stage:?}: {stderr}")] + ProcessExited { + stage: RefTransactionStage, + stderr: String, + }, + #[error("failed to join git update-ref stderr reader during {stage:?}")] + StderrReaderPanicked { stage: RefTransactionStage }, +} + +/// 需要在引用事务中验证的引用。 +pub(super) struct LockedRef<'a> { + pub(super) full_ref: &'a str, + pub(super) oid: &'a str, +} + +/// 已完成 prepare、可在外部操作完成后提交或中止的引用删除事务。 +#[derive(Debug)] +pub(crate) struct PreparedRefDelete { + child: std::process::Child, + stdin: Option>, + stdout: BufReader, + stderr_reader: Option>>>, + completed: bool, +} + +impl PreparedRefDelete { + /// 启动并 prepare 一个带可选合并目标验证的分支引用删除事务。 + pub(super) fn prepare( + repository: &Path, + branch_ref: &str, + branch_oid: &str, + merge_target: Option>, + ) -> Result { + let merge_target = merge_target.as_ref(); + if merge_target.is_some_and(|target| target.full_ref == branch_ref) { + return Err(RefTransactionError::ConflictingRefUpdate { + full_ref: branch_ref.to_string(), + }); + } + + let mut child = command::blocking::Command::new("git") + .arg("-C") + .arg(repository) + .args(["update-ref", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|source| RefTransactionError::Start { source })?; + + let stderr = child + .stderr + .take() + .ok_or(RefTransactionError::MissingPipe { pipe: "stderr" })?; + let stderr_reader = thread::spawn(move || { + let mut stderr = stderr; + let mut output = Vec::new(); + stderr.read_to_end(&mut output)?; + Ok(output) + }); + let stdin = child + .stdin + .take() + .ok_or(RefTransactionError::MissingPipe { pipe: "stdin" })?; + let stdout = child + .stdout + .take() + .ok_or(RefTransactionError::MissingPipe { pipe: "stdout" })?; + let mut transaction = Self { + child, + stdin: Some(BufWriter::new(stdin)), + stdout: BufReader::new(stdout), + stderr_reader: Some(stderr_reader), + completed: false, + }; + + transaction.send_line(RefTransactionStage::Start, "start")?; + transaction.expect_response(RefTransactionStage::Start, "start: ok")?; + if let Some(target) = merge_target { + transaction.send_line( + RefTransactionStage::Prepare, + &format!("verify {} {}", target.full_ref, target.oid), + )?; + } + transaction.send_line( + RefTransactionStage::Prepare, + &format!("delete {branch_ref} {branch_oid}"), + )?; + transaction.send_line(RefTransactionStage::Prepare, "prepare")?; + transaction.expect_response(RefTransactionStage::Prepare, "prepare: ok")?; + + Ok(transaction) + } + + /// 提交已 prepare 的引用删除事务。 + pub(super) fn commit(mut self) -> Result<(), RefTransactionError> { + self.send_line(RefTransactionStage::Commit, "commit")?; + self.expect_response(RefTransactionStage::Commit, "commit: ok")?; + self.finish_process(RefTransactionStage::Commit) + } + + /// 中止已 prepare 的引用删除事务。 + pub(super) fn abort(mut self) -> Result<(), RefTransactionError> { + self.send_line(RefTransactionStage::Abort, "abort")?; + self.expect_response(RefTransactionStage::Abort, "abort: ok")?; + self.finish_process(RefTransactionStage::Abort) + } + + #[cfg(test)] + pub(crate) fn terminate_for_test(&mut self) { + let _ = self.child.kill(); + } + + fn send_line( + &mut self, + stage: RefTransactionStage, + line: &str, + ) -> Result<(), RefTransactionError> { + let stdin = self.stdin.as_mut().ok_or_else(|| RefTransactionError::Io { + stage, + source: io::Error::new( + io::ErrorKind::BrokenPipe, + "git update-ref transaction stdin is closed", + ), + })?; + stdin + .write_all(line.as_bytes()) + .and_then(|()| stdin.write_all(b"\n")) + .and_then(|()| stdin.flush()) + .map_err(|source| RefTransactionError::Io { stage, source }) + } + + fn expect_response( + &mut self, + stage: RefTransactionStage, + expected: &'static str, + ) -> Result<(), RefTransactionError> { + let mut response = String::new(); + match self.stdout.read_line(&mut response) { + Ok(0) => match self.finish_process(stage) { + Ok(()) => Err(RefTransactionError::UnexpectedResponse { + stage, + expected, + response, + }), + Err(error) => Err(error), + }, + Ok(_) => { + let response = response.trim_end_matches(['\r', '\n']).to_string(); + if response == expected { + Ok(()) + } else { + Err(RefTransactionError::UnexpectedResponse { + stage, + expected, + response, + }) + } + } + Err(source) => Err(RefTransactionError::Io { stage, source }), + } + } + + fn finish_process(&mut self, stage: RefTransactionStage) -> Result<(), RefTransactionError> { + if self.completed { + return Ok(()); + } + + self.stdin.take(); + let status = self + .child + .wait() + .map_err(|source| RefTransactionError::Io { + stage: RefTransactionStage::Wait, + source, + })?; + let stderr = match self.read_stderr() { + Ok(stderr) => stderr, + Err(error) => { + self.completed = true; + return Err(error); + } + }; + self.completed = true; + + if status.success() { + Ok(()) + } else { + Err(RefTransactionError::ProcessExited { + stage, + stderr: stderr_to_string(stderr), + }) + } + } + + fn read_stderr(&mut self) -> Result, RefTransactionError> { + let Some(stderr_reader) = self.stderr_reader.take() else { + return Ok(Vec::new()); + }; + match stderr_reader.join() { + Ok(Ok(stderr)) => Ok(stderr), + Ok(Err(source)) => Err(RefTransactionError::Io { + stage: RefTransactionStage::ReadStderr, + source, + }), + Err(_) => Err(RefTransactionError::StderrReaderPanicked { + stage: RefTransactionStage::ReadStderr, + }), + } + } +} + +impl Drop for PreparedRefDelete { + fn drop(&mut self) { + if self.completed { + return; + } + + if self.stdin.is_some() { + let _ = self.send_line(RefTransactionStage::Abort, "abort"); + let _ = self.expect_response(RefTransactionStage::Abort, "abort: ok"); + } + let _ = self.finish_process(RefTransactionStage::Abort); + } +} + +fn stderr_to_string(stderr: Vec) -> String { + String::from_utf8(stderr).unwrap_or_else(|error| { + format!( + "git update-ref stderr contained non-UTF-8 bytes: {:?}", + error.into_bytes() + ) + }) +} + +#[cfg(test)] +#[path = "ref_transaction_tests.rs"] +mod tests; diff --git a/app/src/project_organization/git/ref_transaction_tests.rs b/app/src/project_organization/git/ref_transaction_tests.rs new file mode 100644 index 00000000000..e74737cb356 --- /dev/null +++ b/app/src/project_organization/git/ref_transaction_tests.rs @@ -0,0 +1,267 @@ +use std::path::{Path, PathBuf}; + +use super::{LockedRef, PreparedRefDelete, RefTransactionError, RefTransactionStage}; + +struct TransactionFixture { + _tempdir: tempfile::TempDir, + root: PathBuf, +} + +impl TransactionFixture { + fn new() -> Self { + let tempdir = tempfile::tempdir().unwrap(); + let root = tempdir.path().join("repository"); + std::fs::create_dir(&root).unwrap(); + run_git(&root, &["init", "-b", "main"]); + run_git(&root, &["config", "user.name", "Zap Tests"]); + run_git(&root, &["config", "user.email", "zap@example.com"]); + std::fs::write(root.join("README.md"), "fixture\n").unwrap(); + run_git(&root, &["add", "README.md"]); + run_git(&root, &["commit", "-m", "init"]); + + Self { + _tempdir: tempdir, + root, + } + } + + fn add_worktree(&self, branch: &str) -> PathBuf { + let worktree = self + .root + .parent() + .unwrap() + .join(format!("worktree-{}", branch.replace('/', "-"))); + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(&self.root) + .args(["worktree", "add", "-b", branch]) + .arg(&worktree) + .status() + .unwrap(); + assert!(status.success()); + worktree + } + + fn rev_parse(&self, full_ref: &str) -> String { + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(&self.root) + .args(["rev-parse", "--verify", full_ref]) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + fn ref_exists(&self, full_ref: &str) -> bool { + command::blocking::Command::new("git") + .arg("-C") + .arg(&self.root) + .args(["show-ref", "--verify", "--quiet", full_ref]) + .status() + .unwrap() + .success() + } + + fn advance_ref(&self, full_ref: &str) -> String { + let expected_oid = self.rev_parse(full_ref); + let tree_ref = format!("{full_ref}^{{tree}}"); + let tree_oid = self.rev_parse(&tree_ref); + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(&self.root) + .args([ + "commit-tree", + &tree_oid, + "-p", + &expected_oid, + "-m", + "advance", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let changed_oid = String::from_utf8(output.stdout).unwrap().trim().to_string(); + run_git( + &self.root, + &["update-ref", full_ref, &changed_oid, &expected_oid], + ); + changed_oid + } +} + +fn run_git(repository: &Path, args: &[&str]) { + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(repository) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +#[test] +fn prepared_delete_transaction_spans_worktree_remove() { + let fixture = TransactionFixture::new(); + let worktree = fixture.add_worktree("feature/transaction"); + let branch_ref = "refs/heads/feature/transaction"; + let branch_oid = fixture.rev_parse(branch_ref); + let target_ref = "refs/heads/main"; + let target_oid = fixture.rev_parse(target_ref); + let transaction = PreparedRefDelete::prepare( + &fixture.root, + branch_ref, + &branch_oid, + Some(LockedRef { + full_ref: target_ref, + oid: &target_oid, + }), + ) + .unwrap(); + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["worktree", "remove"]) + .arg(&worktree) + .status() + .unwrap(); + assert!(status.success()); + transaction.commit().unwrap(); + assert!(!worktree.exists()); + assert!(!fixture.ref_exists(branch_ref)); +} + +#[test] +fn prepare_rejects_merge_target_equal_to_branch() { + let fixture = TransactionFixture::new(); + let worktree = fixture.add_worktree("feature/self-target"); + let branch_ref = "refs/heads/feature/self-target"; + let branch_oid = fixture.rev_parse(branch_ref); + + let error = PreparedRefDelete::prepare( + &fixture.root, + branch_ref, + &branch_oid, + Some(LockedRef { + full_ref: branch_ref, + oid: &branch_oid, + }), + ) + .unwrap_err(); + + assert!(matches!( + error, + RefTransactionError::ConflictingRefUpdate { full_ref } if full_ref == branch_ref + )); + assert!(worktree.exists()); + assert!(fixture.ref_exists(branch_ref)); +} + +#[test] +fn prepared_transaction_blocks_branch_updates_until_abort() { + let fixture = TransactionFixture::new(); + let branch_ref = "refs/heads/feature/locked"; + let worktree = fixture.add_worktree("feature/locked"); + let branch_oid = fixture.rev_parse(branch_ref); + let changed_oid = fixture.advance_ref("refs/heads/main"); + let transaction = + PreparedRefDelete::prepare(&fixture.root, branch_ref, &branch_oid, None).unwrap(); + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["update-ref", branch_ref, &changed_oid, &branch_oid]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("cannot lock ref")); + transaction.abort().unwrap(); + + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["update-ref", branch_ref, &changed_oid, &branch_oid]) + .status() + .unwrap(); + assert!(status.success()); + + assert!(worktree.exists()); + assert_eq!(fixture.rev_parse(branch_ref), changed_oid); +} + +#[test] +fn dropping_prepared_transaction_releases_branch_lock() { + let fixture = TransactionFixture::new(); + let branch_ref = "refs/heads/feature/drop-lock"; + let worktree = fixture.add_worktree("feature/drop-lock"); + let branch_oid = fixture.rev_parse(branch_ref); + let changed_oid = fixture.advance_ref("refs/heads/main"); + let transaction = + PreparedRefDelete::prepare(&fixture.root, branch_ref, &branch_oid, None).unwrap(); + + drop(transaction); + + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["update-ref", branch_ref, &changed_oid, &branch_oid]) + .status() + .unwrap(); + assert!(status.success()); + assert!(worktree.exists()); + assert_eq!(fixture.rev_parse(branch_ref), changed_oid); +} + +#[test] +fn prepare_rejects_changed_branch_oid() { + let fixture = TransactionFixture::new(); + fixture.add_worktree("feature/branch-drift"); + let branch_ref = "refs/heads/feature/branch-drift"; + let stale_branch_oid = fixture.rev_parse(branch_ref); + let changed_branch_oid = fixture.advance_ref(branch_ref); + + let error = + PreparedRefDelete::prepare(&fixture.root, branch_ref, &stale_branch_oid, None).unwrap_err(); + + assert!(matches!( + error, + RefTransactionError::ProcessExited { + stage: RefTransactionStage::Prepare, + .. + } | RefTransactionError::UnexpectedResponse { + stage: RefTransactionStage::Prepare, + .. + } + )); + assert_eq!(fixture.rev_parse(branch_ref), changed_branch_oid); +} + +#[test] +fn prepare_rejects_changed_merge_target() { + let fixture = TransactionFixture::new(); + fixture.add_worktree("feature/target-drift"); + let branch_ref = "refs/heads/feature/target-drift"; + let branch_oid = fixture.rev_parse(branch_ref); + let target_ref = "refs/heads/main"; + let stale_target_oid = fixture.rev_parse(target_ref); + fixture.advance_ref(target_ref); + let error = PreparedRefDelete::prepare( + &fixture.root, + branch_ref, + &branch_oid, + Some(LockedRef { + full_ref: target_ref, + oid: &stale_target_oid, + }), + ) + .unwrap_err(); + assert!(matches!( + error, + RefTransactionError::ProcessExited { + stage: RefTransactionStage::Prepare, + .. + } | RefTransactionError::UnexpectedResponse { + stage: RefTransactionStage::Prepare, + .. + } + )); +} diff --git a/app/src/project_organization/git_tests.rs b/app/src/project_organization/git_tests.rs new file mode 100644 index 00000000000..2894325222d --- /dev/null +++ b/app/src/project_organization/git_tests.rs @@ -0,0 +1,2392 @@ +use std::path::{Path, PathBuf}; + +use super::git::*; + +struct GitFixture { + tempdir: tempfile::TempDir, + root: PathBuf, + remote: PathBuf, +} + +impl GitFixture { + fn new() -> Self { + let tempdir = tempfile::tempdir().unwrap(); + let root = tempdir.path().join("repo with 'quote"); + std::fs::create_dir(&root).unwrap(); + run_git(&root, &["init", "-b", "main"]); + std::fs::write(root.join("README.md"), "fixture").unwrap(); + run_git(&root, &["add", "README.md"]); + run_git( + &root, + &[ + "-c", + "user.name=Zap Tests", + "-c", + "user.email=zap@example.com", + "commit", + "-m", + "init", + ], + ); + + let remote = tempdir.path().join("remote repository.git"); + let remote_str = remote.to_str().unwrap(); + run_git( + tempdir.path(), + &["init", "--bare", "-b", "main", remote_str], + ); + run_git(&root, &["remote", "add", "origin", remote_str]); + run_git(&root, &["push", "-u", "origin", "main"]); + run_git(&root, &["remote", "set-head", "origin", "-a"]); + + Self { + tempdir, + root, + remote, + } + } + + fn add_linked_worktree(&self, branch: &str) -> PathBuf { + let path = self + .tempdir + .path() + .join(format!("worktree {} 'quoted'", branch.replace('/', "-"))); + self.add_linked_worktree_at(branch, &path); + path + } + + fn add_linked_worktree_at(&self, branch: &str, path: &Path) { + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(&self.root) + .args(["worktree", "add", "-b", branch]) + .arg(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "failed to add worktree for {branch}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +struct RelativeWorktreeCleanup { + repository: PathBuf, + worktree_path: PathBuf, +} + +impl Drop for RelativeWorktreeCleanup { + fn drop(&mut self) { + if !self.worktree_path.exists() { + return; + } + match command::blocking::Command::new("git") + .arg("-C") + .arg(&self.repository) + .args(["worktree", "remove"]) + .arg(&self.worktree_path) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => eprintln!( + "failed to clean relative test worktree `{}`: {status}", + self.worktree_path.display() + ), + Err(error) => eprintln!( + "failed to run cleanup for relative test worktree `{}`: {error}", + self.worktree_path.display() + ), + } + } +} + +fn run_git(cwd: &Path, args: &[&str]) { + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +fn git_output(cwd: &Path, args: &[&str]) -> String { + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +fn current_branch(worktree: &Path) -> String { + git_output(worktree, &["branch", "--show-current"]) +} + +fn branch_upstream(repository: &Path, branch: &str) -> Option { + let full_ref = format!("refs/heads/{branch}"); + let output = git_output( + repository, + &["for-each-ref", "--format=%(upstream)", &full_ref], + ); + (!output.is_empty()).then_some(output) +} + +fn ref_exists(repository: &Path, full_ref: &str) -> bool { + command::blocking::Command::new("git") + .arg("-C") + .arg(repository) + .args(["show-ref", "--verify", "--quiet", full_ref]) + .status() + .unwrap() + .success() +} + +fn ref_oid(repository: &Path, full_ref: &str) -> String { + git_output(repository, &["rev-parse", "--verify", full_ref]) +} + +fn fixture_commit_oid(repository: &Path, message: &str) -> String { + git_output( + repository, + &[ + "-c", + "user.name=Zap Tests", + "-c", + "user.email=zap@example.com", + "commit-tree", + "HEAD^{tree}", + "-p", + "HEAD", + "-m", + message, + ], + ) +} + +fn advance_remote_main(fixture: &GitFixture) { + let full_ref = "refs/remotes/origin/main"; + let old_oid = ref_oid(&fixture.root, full_ref); + let new_oid = fixture_commit_oid(&fixture.root, "advanced remote main"); + run_git(&fixture.root, &["update-ref", full_ref, &new_oid, &old_oid]); +} + +fn injected_command_error(operation: &'static str) -> GitWorkspaceError { + GitWorkspaceError::CommandFailed { + operation, + args: vec!["worktree".to_string(), "add".to_string()], + stderr: "injected creation failure".to_string(), + } +} + +#[test] +fn rejects_linked_worktree_as_repository() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/a"); + + let error = validate_repository(&worktree_path).unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::LinkedWorktree { .. })); +} + +#[test] +fn rejects_directory_below_repository_root() { + let fixture = GitFixture::new(); + let nested = fixture.root.join("nested"); + std::fs::create_dir(&nested).unwrap(); + + let error = validate_repository(&nested).unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::NotRepositoryRoot { .. })); +} + +#[test] +fn validates_repository_and_reads_remote_metadata() { + let fixture = GitFixture::new(); + + let repository = validate_repository(&fixture.root).unwrap(); + + assert_eq!(repository.root, fixture.root.canonicalize().unwrap()); + assert_eq!(repository.primary_branch, "main"); + assert_eq!(repository.remote, "origin"); + assert_eq!(repository.remote_url, fixture.remote.to_str().unwrap()); + assert!(matches!( + repository.default_branch, + BranchRef::Remote { + remote, + name, + full_ref + } if remote == "origin" && name == "main" && full_ref == "refs/remotes/origin/main" + )); +} + +#[cfg(unix)] +#[test] +fn decodes_non_utf8_git_path_output_without_loss() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let expected = PathBuf::from(std::ffi::OsString::from_vec( + b"/tmp/repository-\xff ".to_vec(), + )); + let mut output = expected.as_os_str().as_bytes().to_vec(); + output.push(b'\n'); + + let decoded = decode_git_path_output(&output, "decode test path").unwrap(); + + assert_eq!( + decoded.as_os_str().as_bytes(), + expected.as_os_str().as_bytes() + ); +} + +#[cfg(unix)] +#[test] +fn preserves_trailing_carriage_return_in_git_path_output() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let expected = PathBuf::from(std::ffi::OsString::from_vec( + b"/tmp/repository-with-trailing-cr\r".to_vec(), + )); + let mut output = expected.as_os_str().as_bytes().to_vec(); + output.push(b'\n'); + + let decoded = decode_git_path_output(&output, "decode test path").unwrap(); + + assert_eq!( + decoded.as_os_str().as_bytes(), + expected.as_os_str().as_bytes() + ); +} + +#[test] +fn rejects_repository_without_remote() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["remote", "remove", "origin"]); + + let error = validate_repository(&fixture.root).unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::RemoteNotFound { .. })); +} + +#[test] +fn rejects_repository_without_remote_default_branch() { + let fixture = GitFixture::new(); + run_git( + &fixture.root, + &["symbolic-ref", "--delete", "refs/remotes/origin/HEAD"], + ); + + let error = validate_repository(&fixture.root).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::DefaultBranchNotFound { remote, .. } if remote == "origin" + )); +} + +#[test] +fn selects_first_remote_when_origin_is_absent() { + let fixture = GitFixture::new(); + let remote_url = fixture.remote.to_str().unwrap(); + run_git(&fixture.root, &["remote", "remove", "origin"]); + run_git(&fixture.root, &["remote", "add", "a", remote_url]); + run_git( + &fixture.root, + &["remote", "add", "zzzz-longer-remote", remote_url], + ); + run_git(&fixture.root, &["fetch", "a"]); + run_git(&fixture.root, &["remote", "set-head", "a", "-a"]); + + let repository = validate_repository(&fixture.root).unwrap(); + + assert_eq!(repository.remote, "a"); + assert!(matches!( + repository.default_branch, + BranchRef::Remote { remote, name, .. } if remote == "a" && name == "main" + )); +} + +#[test] +fn classifies_local_and_remote_refs_without_prefix_guessing() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "origin/foo"]); + run_git( + &fixture.root, + &["push", "origin", "main:refs/heads/team/remote-branch"], + ); + run_git(&fixture.root, &["fetch", "origin"]); + + let refs = list_branch_refs(&fixture.root).unwrap(); + + assert!(refs.iter().any(|branch_ref| matches!( + branch_ref, + BranchRef::Local { name, full_ref } + if name == "origin/foo" && full_ref == "refs/heads/origin/foo" + ))); + assert!(refs.iter().any(|branch_ref| matches!( + branch_ref, + BranchRef::Remote { + remote, + name, + full_ref + } if remote == "origin" + && name == "team/remote-branch" + && full_ref == "refs/remotes/origin/team/remote-branch" + ))); +} + +#[test] +fn rejects_ambiguous_overlapping_remote_ref() { + let remotes = vec!["foo".to_string(), "foo/bar".to_string()]; + + let error = parse_branch_ref("refs/remotes/foo/bar/main", &remotes).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::AmbiguousRemoteRef { + full_ref, + remotes: candidates, + } if full_ref == "refs/remotes/foo/bar/main" && candidates == remotes + )); +} + +#[test] +fn rejects_direct_head_ref_ambiguous_between_overlapping_remotes() { + let fixture = GitFixture::new(); + let remote_url = fixture.remote.to_str().unwrap(); + run_git(&fixture.root, &["remote", "add", "foo", remote_url]); + run_git(&fixture.root, &["remote", "add", "foo/bar", remote_url]); + run_git( + &fixture.root, + &["update-ref", "refs/remotes/foo/bar/HEAD", "HEAD"], + ); + + let error = list_branch_refs(&fixture.root).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::AmbiguousRemoteRef { + full_ref, + remotes, + } if full_ref == "refs/remotes/foo/bar/HEAD" + && remotes == ["foo".to_string(), "foo/bar".to_string()] + )); +} + +#[test] +fn rejects_malformed_branch_ref_record() { + let error = parse_branch_ref_records("refs/heads/main\n", &[]).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::InvalidBranchRefRecord { record } + if record == "refs/heads/main" + )); +} + +#[test] +fn fetches_remote_refs_before_listing_them() { + let fixture = GitFixture::new(); + run_git( + &fixture.root, + &["push", "origin", "main:refs/heads/created-after-clone"], + ); + run_git( + &fixture.root, + &[ + "update-ref", + "-d", + "refs/remotes/origin/created-after-clone", + ], + ); + + let refs = fetch_and_list_refs(&fixture.root).unwrap(); + + assert!(refs.iter().any(|branch_ref| matches!( + branch_ref, + BranchRef::Remote { remote, name, .. } + if remote == "origin" && name == "created-after-clone" + ))); +} + +#[test] +fn fetches_primary_remote_when_branch_has_no_upstream() { + let fixture = GitFixture::new(); + run_git( + &fixture.root, + &["push", "origin", "main:refs/heads/primary-only"], + ); + + let secondary = fixture.tempdir.path().join("secondary.git"); + run_git( + fixture.tempdir.path(), + &["init", "--bare", "-b", "main", secondary.to_str().unwrap()], + ); + run_git( + &fixture.root, + &["remote", "add", "zz-secondary", secondary.to_str().unwrap()], + ); + run_git(&fixture.root, &["push", "zz-secondary", "main"]); + run_git(&fixture.root, &["remote", "remove", "origin"]); + run_git( + &fixture.root, + &[ + "remote", + "add", + "a-primary", + fixture.remote.to_str().unwrap(), + ], + ); + run_git( + &fixture.root, + &["config", "branch.main.remote", "zz-secondary"], + ); + let _ = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["config", "--unset-all", "branch.main.merge"]) + .status() + .unwrap(); + + let refs = fetch_and_list_refs(&fixture.root).unwrap(); + + assert!(refs.iter().any(|branch_ref| matches!( + branch_ref, + BranchRef::Remote { remote, name, .. } + if remote == "a-primary" && name == "primary-only" + ))); +} + +#[test] +fn omits_symbolic_remote_head_from_ref_lists() { + let fixture = GitFixture::new(); + + let listed_refs = list_branch_refs(&fixture.root).unwrap(); + let fetched_refs = fetch_and_list_refs(&fixture.root).unwrap(); + + for refs in [listed_refs, fetched_refs] { + assert!(!refs.iter().any(|branch_ref| matches!( + branch_ref, + BranchRef::Remote { name, full_ref, .. } + if name == "HEAD" || full_ref == "refs/remotes/origin/HEAD" + ))); + } +} + +#[test] +fn parses_worktree_paths_and_full_branch_refs() { + let fixture = GitFixture::new(); + let linked_path = fixture.add_linked_worktree("feature/worktree"); + + let worktrees = list_worktrees(&fixture.root).unwrap(); + + assert!(worktrees.iter().any(|worktree| { + worktree.path == fixture.root.canonicalize().unwrap() + && worktree.branch.as_deref() == Some("refs/heads/main") + })); + assert!(worktrees.iter().any(|worktree| { + worktree.path == linked_path.canonicalize().unwrap() + && worktree.branch.as_deref() == Some("refs/heads/feature/worktree") + })); +} + +#[test] +fn existing_worktree_options_include_primary_before_linked_worktrees() { + let repository_root = PathBuf::from("/tmp/repository"); + let options = existing_worktree_options( + &repository_root, + [ + WorktreeInfo { + path: repository_root.clone(), + head: Some("a".to_string()), + branch: Some("refs/heads/main".to_string()), + is_bare: false, + is_detached: false, + is_locked: false, + locked_reason: None, + is_prunable: false, + prunable_reason: None, + }, + WorktreeInfo { + path: PathBuf::from("/tmp/repository-feature"), + head: Some("b".to_string()), + branch: Some("refs/heads/feature/existing".to_string()), + is_bare: false, + is_detached: false, + is_locked: false, + locked_reason: None, + is_prunable: false, + prunable_reason: None, + }, + WorktreeInfo { + path: PathBuf::from("/tmp/repository-detached"), + head: Some("c".to_string()), + branch: None, + is_bare: false, + is_detached: true, + is_locked: false, + locked_reason: None, + is_prunable: false, + prunable_reason: None, + }, + WorktreeInfo { + path: PathBuf::from("/tmp/repository-prunable"), + head: Some("d".to_string()), + branch: Some("refs/heads/feature/prunable".to_string()), + is_bare: false, + is_detached: false, + is_locked: false, + locked_reason: None, + is_prunable: true, + prunable_reason: Some("missing".to_string()), + }, + ], + ); + + assert_eq!( + options, + vec![ + ExistingWorktreeOption::primary(repository_root.clone(), "main"), + ExistingWorktreeOption::new( + PathBuf::from("/tmp/repository-feature"), + "feature/existing", + ), + ], + ); +} + +#[test] +fn existing_worktree_options_recognize_primary_path_aliases() { + let fixture = GitFixture::new(); + let alias_parent = fixture.tempdir.path().join("repository-alias"); + std::fs::create_dir(&alias_parent).unwrap(); + let repository_root = alias_parent + .join("..") + .join(fixture.root.file_name().unwrap()); + let worktrees = list_worktrees(&fixture.root).unwrap(); + + let options = existing_worktree_options(&repository_root, worktrees); + + assert_eq!(options.first().map(|option| option.is_primary), Some(true)); + assert_eq!( + options.first().map(|option| option.branch_name.as_str()), + Some("main") + ); +} + +#[test] +fn validates_registered_existing_worktree_without_rejecting_dirty_contents() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/adopt"); + std::fs::write(worktree_path.join("untracked.txt"), "dirty").unwrap(); + + assert_eq!( + validate_existing_worktree(&fixture.root, &worktree_path, "feature/adopt").unwrap(), + worktree_path.canonicalize().unwrap(), + ); +} + +#[test] +fn validates_repository_primary_worktree_for_existing_workspace_adoption() { + let fixture = GitFixture::new(); + + assert_eq!( + validate_existing_worktree(&fixture.root, &fixture.root, "main").unwrap(), + fixture.root.canonicalize().unwrap(), + ); +} + +#[test] +fn rejects_detached_primary_worktree_during_repository_validation() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["checkout", "--detach", "HEAD"]); + + let error = validate_repository(&fixture.root).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::PrimaryWorktreeDetached { path } + if path == fixture.root.canonicalize().unwrap() + )); +} + +#[test] +fn rejects_detached_primary_worktree_during_workspace_adoption() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["checkout", "--detach", "HEAD"]); + + let error = validate_existing_worktree(&fixture.root, &fixture.root, "main").unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::PrimaryWorktreeDetached { path } + if path == fixture.root.canonicalize().unwrap() + )); +} + +#[test] +fn rejects_prunable_existing_worktree_during_workspace_adoption() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/prunable-adoption"); + std::fs::remove_file(worktree_path.join(".git")).unwrap(); + + let error = + validate_existing_worktree(&fixture.root, &worktree_path, "feature/prunable-adoption") + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::PrunableWorktreeCannotBeWorkspace { path } + if path == worktree_path.canonicalize().unwrap() + )); +} + +#[test] +fn preserves_prunable_worktree_when_its_path_no_longer_exists() { + let fixture = GitFixture::new(); + let linked_path = fixture.add_linked_worktree("feature/prunable"); + std::fs::remove_dir_all(&linked_path).unwrap(); + + let worktrees = list_worktrees(&fixture.root).unwrap(); + let expected_path = linked_path + .parent() + .unwrap() + .canonicalize() + .unwrap() + .join(linked_path.file_name().unwrap()); + + assert!(worktrees.iter().any(|worktree| { + worktree.path == expected_path + && worktree.branch.as_deref() == Some("refs/heads/feature/prunable") + && worktree.is_prunable + })); +} + +#[test] +fn preserves_newline_worktree_path() { + let fixture = GitFixture::new(); + let linked_path = fixture.tempdir.path().join("worktree\nnewline"); + fixture.add_linked_worktree_at("feature/newline", &linked_path); + + let worktrees = list_worktrees(&fixture.root).unwrap(); + + assert!(worktrees + .iter() + .any(|worktree| worktree.path == linked_path.canonicalize().unwrap())); +} + +#[cfg(unix)] +#[test] +fn preserves_non_utf8_worktree_path() { + use std::os::unix::ffi::OsStringExt; + + let tempdir = tempfile::tempdir().unwrap(); + let linked_path = tempdir + .path() + .join(std::ffi::OsString::from_vec(b"worktree-\xff".to_vec())); + let mut output = b"worktree ".to_vec(); + output.extend(linked_path.as_os_str().as_encoded_bytes()); + output.extend_from_slice( + b"\0HEAD 0123456789abcdef\0branch refs/heads/feature/non-utf8\0prunable missing\0\0", + ); + + let worktrees = parse_worktrees(&output).unwrap(); + let expected_path = tempdir + .path() + .canonicalize() + .unwrap() + .join(std::ffi::OsString::from_vec(b"worktree-\xff".to_vec())); + + assert_eq!(worktrees[0].path, expected_path); +} + +#[test] +fn clones_repository_into_path_with_spaces_and_quotes() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("clone path with 'quote"); + + let repository = clone_repository(fixture.remote.to_str().unwrap(), Some(&target)).unwrap(); + + assert_eq!(repository.root, target.canonicalize().unwrap()); + assert_eq!(repository.remote_url, fixture.remote.to_str().unwrap()); + assert_eq!( + std::fs::read_to_string(target.join("README.md")).unwrap(), + "fixture" + ); +} + +#[test] +fn clone_uses_repository_name_when_target_is_not_provided() { + let fixture = GitFixture::new(); + let parent = fixture.tempdir.path().join("clone parent"); + std::fs::create_dir(&parent).unwrap(); + + let repository = + clone_repository_into(fixture.remote.to_str().unwrap(), &parent, None).unwrap(); + + assert_eq!( + repository.root, + parent.join("remote repository").canonicalize().unwrap() + ); +} + +#[test] +fn clone_into_rejects_invalid_directory_names_without_escaping_parent() { + let fixture = GitFixture::new(); + let parent = fixture.tempdir.path().join("clone parent"); + std::fs::create_dir(&parent).unwrap(); + let escaped = fixture.tempdir.path().join("escaped"); + let absolute = fixture.tempdir.path().join("absolute-target"); + let invalid_names = [ + "../escaped".to_string(), + absolute.to_string_lossy().into_owned(), + "nested/name".to_string(), + ".".to_string(), + "".to_string(), + ]; + + for directory_name in invalid_names { + let error = clone_repository_into( + fixture.remote.to_str().unwrap(), + &parent, + Some(&directory_name), + ) + .unwrap_err(); + assert!(matches!( + error, + GitWorkspaceError::InvalidCloneDirectoryName { name } + if name == directory_name + )); + } + + assert!(!escaped.exists()); + assert!(!absolute.exists()); + assert!(!parent.join("nested").exists()); +} + +#[test] +fn clone_into_accepts_single_normal_directory_name() { + let fixture = GitFixture::new(); + let parent = fixture.tempdir.path().join("custom clone parent"); + std::fs::create_dir(&parent).unwrap(); + + let repository = clone_repository_into( + fixture.remote.to_str().unwrap(), + &parent, + Some("custom clone"), + ) + .unwrap(); + + assert_eq!( + repository.root, + parent.join("custom clone").canonicalize().unwrap() + ); +} + +#[test] +fn clone_failure_removes_target_created_by_the_operation() { + let tempdir = tempfile::tempdir().unwrap(); + let missing_source = tempdir.path().join("missing repository.git"); + let target = tempdir.path().join("new target"); + + let error = clone_repository(missing_source.to_str().unwrap(), Some(&target)).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::CommandFailed { + operation, + ref args, + ref stderr, + } if operation == "clone repository" + && args.first().is_some_and(|arg| arg == "clone") + && !stderr.is_empty() + )); + assert!(!target.exists()); +} + +#[test] +fn clone_never_deletes_preexisting_target() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("existing target"); + std::fs::create_dir(&target).unwrap(); + let sentinel = target.join("keep.txt"); + std::fs::write(&sentinel, "keep").unwrap(); + + let error = clone_repository(fixture.remote.to_str().unwrap(), Some(&target)).unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::TargetExists { .. })); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "keep"); +} + +#[test] +fn cleanup_failure_displays_clone_and_cleanup_errors() { + let error = GitWorkspaceError::CleanupFailed { + path: PathBuf::from("clone-target"), + cleanup_source: std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "cleanup permission denied", + ), + clone_error: Box::new(GitWorkspaceError::CommandFailed { + operation: "clone repository", + args: vec!["clone".to_string()], + stderr: "fatal: source repository missing".to_string(), + }), + }; + + let message = error.to_string(); + + assert!(message.contains("fatal: source repository missing")); + assert!(message.contains("cleanup permission denied")); +} + +#[test] +fn cleanup_failure_exposes_clone_error_as_primary_source() { + use std::error::Error; + + let error = GitWorkspaceError::CleanupFailed { + path: PathBuf::from("clone-target"), + cleanup_source: std::io::Error::other("cleanup denied"), + clone_error: Box::new(injected_command_error("clone repository")), + }; + + assert!( + matches!(error.source(), Some(source) if source.to_string().contains("clone repository")) + ); +} + +#[test] +fn parses_repository_names_from_supported_git_urls() { + assert_eq!( + repository_name_from_url("https://github.com/acme/widgets.git").unwrap(), + "widgets" + ); + assert_eq!( + repository_name_from_url("git@github.com:acme/widgets.git").unwrap(), + "widgets" + ); + assert_eq!( + repository_name_from_url("ssh://git@example.com/acme/widgets/").unwrap(), + "widgets" + ); + assert_eq!( + repository_name_from_url("/tmp/repositories/local-widgets.git").unwrap(), + "local-widgets" + ); +} + +#[test] +fn rejects_git_url_without_repository_name() { + let error = repository_name_from_url("ssh://git@example.com/").unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::RepositoryNameMissing { .. } + )); +} + +#[test] +fn parses_repository_name_from_windows_drive_path() { + assert_eq!( + repository_name_from_url(r"C:\repositories\windows-widgets.git").unwrap(), + "windows-widgets" + ); +} + +#[test] +fn creates_filesystem_safe_workspace_directory_names() { + assert_eq!( + workspace_dir_name("feature/a b", "12345678"), + "feature-a-b-12345678" + ); + assert_eq!( + workspace_dir_name(" feature\\a::b///c ", "abcdef12-extra"), + "feature-a-b-c-abcdef12" + ); + assert_eq!( + workspace_dir_name("///::: ", "fedcba98"), + "workspace-fedcba98" + ); +} + +#[test] +fn creates_new_branch_from_remote_ref_without_tracking() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("remote worktree"); + + create_from_remote( + &fixture.root, + "refs/remotes/origin/main", + "feature/remote", + &path, + ) + .unwrap(); + + assert_eq!(current_branch(&path), "feature/remote"); + assert_eq!(branch_upstream(&fixture.root, "feature/remote"), None); +} + +#[test] +fn creates_remote_worktree_for_nested_claimed_target() { + let fixture = GitFixture::new(); + let worktree_path = fixture + .tempdir + .path() + .join("missing-parent") + .join("remote worktree"); + + create_from_remote( + &fixture.root, + "refs/remotes/origin/main", + "feature/nested-remote", + &worktree_path, + ) + .unwrap(); + + assert_eq!(current_branch(&worktree_path), "feature/nested-remote"); + assert!(worktree_path.is_dir()); +} + +#[test] +fn successful_remote_creation_rejects_new_upstream() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("unexpected upstream"); + + let error = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/unexpected-upstream", + &path, + |repository, remote_ref, new_branch, claimed_path, _| { + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(repository) + .args(["worktree", "add", "--no-track", "-b", new_branch]) + .arg(claimed_path) + .arg(remote_ref) + .status() + .unwrap(); + assert!(status.success()); + run_git( + repository, + &[ + "branch", + "--set-upstream-to=origin/main", + "feature/unexpected-upstream", + ], + ); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationVerificationFailed { + verification_error, + .. + } if matches!(verification_error.as_ref(), GitWorkspaceError::UnexpectedBranchUpstream { + branch, + upstream, + } if branch == "feature/unexpected-upstream" && upstream == "refs/remotes/origin/main") + )); +} + +#[test] +fn creates_remote_worktree_for_relative_claimed_target() { + let fixture = GitFixture::new(); + assert_ne!(std::env::current_dir().unwrap(), fixture.root); + let mut relative_target = std::ffi::OsString::from(".task2-relative-remote-"); + relative_target.push(fixture.tempdir.path().file_name().unwrap()); + let relative_target = PathBuf::from(relative_target); + let worktree_path = std::env::current_dir().unwrap().join(&relative_target); + let _cleanup = RelativeWorktreeCleanup { + repository: fixture.root.clone(), + worktree_path, + }; + + create_from_remote( + &fixture.root, + "refs/remotes/origin/main", + "feature/relative-remote", + &relative_target, + ) + .unwrap(); + + let canonical_target = relative_target.canonicalize().unwrap(); + assert_eq!(current_branch(&canonical_target), "feature/relative-remote"); + assert!(!fixture.root.join(&relative_target).exists()); + + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["worktree", "remove"]) + .arg(&canonical_target) + .status() + .unwrap(); + assert!(status.success()); +} + +#[test] +fn creates_local_worktree_for_relative_claimed_target() { + let fixture = GitFixture::new(); + assert_ne!(std::env::current_dir().unwrap(), fixture.root); + run_git(&fixture.root, &["branch", "feature/relative-local"]); + let mut relative_target = std::ffi::OsString::from(".task2-relative-local-"); + relative_target.push(fixture.tempdir.path().file_name().unwrap()); + let relative_target = PathBuf::from(relative_target); + let worktree_path = std::env::current_dir().unwrap().join(&relative_target); + let _cleanup = RelativeWorktreeCleanup { + repository: fixture.root.clone(), + worktree_path, + }; + + create_from_local(&fixture.root, "feature/relative-local", &relative_target).unwrap(); + + let canonical_target = relative_target.canonicalize().unwrap(); + assert_eq!(current_branch(&canonical_target), "feature/relative-local"); + assert!(!fixture.root.join(&relative_target).exists()); + + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["worktree", "remove"]) + .arg(&canonical_target) + .status() + .unwrap(); + assert!(status.success()); +} + +#[test] +fn creates_local_worktree_for_nested_claimed_target() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "feature/nested-local"]); + let worktree_path = fixture + .tempdir + .path() + .join("missing-parent") + .join("local worktree"); + + create_from_local(&fixture.root, "feature/nested-local", &worktree_path).unwrap(); + + assert_eq!(current_branch(&worktree_path), "feature/nested-local"); + assert!(worktree_path.is_dir()); +} + +#[test] +fn successful_remote_creation_rejects_branch_change_before_verification() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("changed after success"); + let expected_oid = ref_oid(&fixture.root, "refs/remotes/origin/main"); + let changed_oid = git_output( + &fixture.root, + &[ + "-c", + "user.name=Zap Tests", + "-c", + "user.email=zap@example.com", + "commit-tree", + "HEAD^{tree}", + "-p", + "HEAD", + "-m", + "post-success branch change", + ], + ); + + let error = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/post-success-change", + &path, + |repository, remote_ref, new_branch, claimed_path, runner_expected_oid| { + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(repository) + .args(["worktree", "add", "--no-track", "-b", new_branch]) + .arg(claimed_path) + .arg(remote_ref) + .status() + .unwrap(); + assert!(status.success()); + run_git( + &fixture.root, + &[ + "update-ref", + "refs/heads/feature/post-success-change", + &changed_oid, + runner_expected_oid, + ], + ); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationVerificationFailed { + worktree_path, + branch, + expected_oid: expected, + verification_error, + } if worktree_path == path + && branch == "feature/post-success-change" + && expected == expected_oid + && matches!(verification_error.as_ref(), GitWorkspaceError::BranchChanged { + branch, + expected_oid: nested_expected, + actual_oid: Some(actual), + actual_symbolic_target: None, + } if branch == "feature/post-success-change" + && *nested_expected == expected_oid + && *actual == changed_oid) + )); + assert_eq!( + ref_oid(&fixture.root, "refs/heads/feature/post-success-change"), + changed_oid + ); + assert!(path.exists()); +} + +#[test] +fn rejects_invalid_or_symbolic_remote_refs_before_creation() { + let fixture = GitFixture::new(); + + for remote_ref in ["refs/heads/main", "refs/remotes/origin/HEAD"] { + let branch = format!("feature/{}", remote_ref.replace('/', "-")); + let path = fixture.tempdir.path().join(&branch); + let error = create_from_remote(&fixture.root, remote_ref, &branch, &path).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::InvalidRemoteRef { full_ref } if full_ref == remote_ref + )); + assert!(!ref_exists(&fixture.root, &format!("refs/heads/{branch}"))); + assert!(!path.exists()); + } +} + +#[test] +fn rejects_missing_remote_ref_before_creation() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("missing remote"); + + let error = create_from_remote( + &fixture.root, + "refs/remotes/origin/missing", + "feature/missing-remote", + &path, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchNotFound { full_ref } + if full_ref == "refs/remotes/origin/missing" + )); + assert!(!ref_exists( + &fixture.root, + "refs/heads/feature/missing-remote" + )); + assert!(!path.exists()); +} + +#[test] +fn rejects_invalid_new_branch_name_before_creation() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("invalid branch"); + + let error = create_from_remote( + &fixture.root, + "refs/remotes/origin/main", + "invalid branch", + &path, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::InvalidBranchName { branch } if branch == "invalid branch" + )); + assert!(!path.exists()); +} + +#[test] +fn rejects_existing_new_branch_before_creation() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "feature/existing"]); + let path = fixture.tempdir.path().join("existing branch"); + + let error = create_from_remote( + &fixture.root, + "refs/remotes/origin/main", + "feature/existing", + &path, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchAlreadyExists { branch } if branch == "feature/existing" + )); + assert!(!path.exists()); +} + +#[test] +fn rejects_existing_target_before_remote_creation() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("existing target"); + std::fs::create_dir(&path).unwrap(); + + let error = create_from_remote( + &fixture.root, + "refs/remotes/origin/main", + "feature/target-exists", + &path, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::TargetExists { path: error_path } if error_path == path + )); + assert!(!ref_exists( + &fixture.root, + "refs/heads/feature/target-exists" + )); +} + +#[test] +fn remote_creation_claims_target_before_git_command() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("remote claimed target"); + + create_from_remote_with_after_target_claim_hook( + &fixture.root, + "refs/remotes/origin/main", + "feature/remote-target-claim", + &path, + || { + let error = std::fs::create_dir(&path).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + }, + ) + .unwrap(); + + assert_eq!(current_branch(&path), "feature/remote-target-claim"); +} + +#[test] +fn local_creation_claims_target_before_git_command() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "feature/local-target-claim"]); + let path = fixture.tempdir.path().join("local claimed target"); + + create_from_local_with_after_target_claim_hook( + &fixture.root, + "feature/local-target-claim", + &path, + || { + let error = std::fs::create_dir(&path).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + }, + ) + .unwrap(); + + assert_eq!(current_branch(&path), "feature/local-target-claim"); +} + +#[test] +fn creation_rejects_claim_replaced_by_file_before_git_command() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("replaced target"); + + let error = create_from_remote_with_after_target_claim_hook( + &fixture.root, + "refs/remotes/origin/main", + "feature/replaced-target", + &path, + || { + std::fs::remove_dir(&path).unwrap(); + std::fs::write(&path, "replacement").unwrap(); + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::ClaimedTargetNotDirectory { path: error_path } if error_path == path + )); + assert!(!ref_exists( + &fixture.root, + "refs/heads/feature/replaced-target" + )); + assert_eq!(std::fs::read_to_string(path).unwrap(), "replacement"); +} + +#[cfg(unix)] +#[test] +fn rejects_dangling_symlink_target_without_creating_branch() { + use std::os::unix::fs::symlink; + + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("dangling target"); + symlink(fixture.tempdir.path().join("missing target"), &path).unwrap(); + + let error = create_from_remote( + &fixture.root, + "refs/remotes/origin/main", + "feature/dangling-target", + &path, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::TargetExists { path: error_path } if error_path == path + )); + assert!(!ref_exists( + &fixture.root, + "refs/heads/feature/dangling-target" + )); + assert!(std::fs::symlink_metadata(path) + .unwrap() + .file_type() + .is_symlink()); +} + +#[test] +fn remote_runner_failure_preserves_branch_and_removes_empty_claimed_target() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("remote residual target"); + + let error = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/residual", + &path, + |repository, _, new_branch, _, expected_oid| { + run_git( + repository, + &["update-ref", "refs/heads/feature/residual", expected_oid], + ); + assert_eq!(new_branch, "feature/residual"); + Err(injected_command_error( + "inject remote residual creation failure", + )) + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationFailed { + worktree_path, + branch, + branch_may_remain: true, + worktree_registered: Some(false), + claimed_directory_removed: true, + create_error, + cleanup_error: None, + } if worktree_path == path + && branch == "feature/residual" + && matches!(create_error.as_ref(), GitWorkspaceError::CommandFailed { + operation: "inject remote residual creation failure", + .. + }) + )); + assert!(ref_exists(&fixture.root, "refs/heads/feature/residual")); + assert!(!path.exists()); +} + +#[test] +fn remote_runner_failure_keeps_nonempty_claimed_target() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("remote nonempty target"); + let sentinel = path.join("keep.txt"); + + let error = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/nonempty-residual", + &path, + |_, _, _, claimed_path, _| { + std::fs::write(claimed_path.join("keep.txt"), "keep").unwrap(); + Err(injected_command_error( + "inject remote nonempty creation failure", + )) + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationFailed { + worktree_path, + branch, + branch_may_remain: true, + worktree_registered: Some(false), + claimed_directory_removed: false, + create_error, + cleanup_error: Some(cleanup_error), + } if worktree_path == path + && branch == "feature/nonempty-residual" + && matches!(create_error.as_ref(), GitWorkspaceError::CommandFailed { + operation: "inject remote nonempty creation failure", + .. + }) + && matches!(cleanup_error.as_ref(), GitWorkspaceError::ClaimedTargetNotEmpty { + path: cleanup_path, + } if cleanup_path == &path) + )); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "keep"); +} + +#[test] +fn remote_runner_failure_preserves_registered_worktree() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("registered residual target"); + + let error = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/registered-residual", + &path, + |repository, remote_ref, new_branch, claimed_path, _| { + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(repository) + .args(["worktree", "add", "--no-track", "-b", new_branch]) + .arg(claimed_path) + .arg(remote_ref) + .status() + .unwrap(); + assert!(status.success()); + Err(injected_command_error( + "inject registered remote creation failure", + )) + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationFailed { + worktree_path, + branch, + branch_may_remain: true, + worktree_registered: Some(true), + claimed_directory_removed: false, + create_error, + cleanup_error: None, + } if worktree_path == path + && branch == "feature/registered-residual" + && matches!(create_error.as_ref(), GitWorkspaceError::CommandFailed { + operation: "inject registered remote creation failure", + .. + }) + )); + assert!(path.exists()); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/registered-residual" + )); +} + +#[test] +fn local_runner_failure_removes_empty_claimed_target_and_preserves_branch() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("local residual target"); + run_git(&fixture.root, &["branch", "feature/local-residual"]); + + let error = + create_from_local_with_runner(&fixture.root, "feature/local-residual", &path, |_, _, _| { + Err(injected_command_error("inject local creation failure")) + }) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationFailed { + worktree_path, + branch, + branch_may_remain: false, + worktree_registered: Some(false), + claimed_directory_removed: true, + create_error, + cleanup_error: None, + } if worktree_path == path + && branch == "feature/local-residual" + && matches!(create_error.as_ref(), GitWorkspaceError::CommandFailed { + operation: "inject local creation failure", + .. + }) + )); + assert!(!path.exists()); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/local-residual" + )); +} + +#[test] +fn rejects_missing_local_branch_before_creation() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("missing local"); + + let error = create_from_local(&fixture.root, "feature/missing", &path).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchNotFound { full_ref } + if full_ref == "refs/heads/feature/missing" + )); + assert!(!path.exists()); +} + +#[test] +fn reports_main_repository_path_when_local_branch_is_checked_out() { + let fixture = GitFixture::new(); + let path = fixture.tempdir.path().join("second main"); + + let error = create_from_local(&fixture.root, "main", &path).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchAlreadyCheckedOut { branch, path } + if branch == "main" && path == fixture.root.canonicalize().unwrap() + )); + assert!(!path.exists()); +} + +#[test] +fn reports_the_path_that_already_checks_out_a_local_branch() { + let fixture = GitFixture::new(); + let occupied = fixture.add_linked_worktree("feature/occupied"); + let path = fixture.tempdir.path().join("second occupied"); + + let error = create_from_local(&fixture.root, "feature/occupied", &path).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchAlreadyCheckedOut { branch, path } + if branch == "feature/occupied" && path == occupied.canonicalize().unwrap() + )); + assert!(!path.exists()); +} + +#[test] +fn creates_local_worktree_at_path_with_spaces_and_quotes() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "feature/local"]); + let path = fixture.tempdir.path().join("local worktree with 'quote"); + + create_from_local(&fixture.root, "feature/local", &path).unwrap(); + + assert_eq!(current_branch(&path), "feature/local"); +} + +#[cfg(unix)] +#[test] +fn os_arg_command_helper_preserves_non_utf8_bytes() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let fixture = GitFixture::new(); + let raw_arg = std::ffi::OsString::from_vec(b"raw-path-\xff".to_vec()); + let args = [ + std::ffi::OsStr::new("rev-parse"), + std::ffi::OsStr::new("--git-path"), + raw_arg.as_os_str(), + ]; + + let output = + git_output_with_os_args_for_operation(&fixture.root, "echo raw path", &args).unwrap(); + + assert!(output.stdout.ends_with(b"raw-path-\xff\n")); + assert_eq!(raw_arg.as_os_str().as_bytes(), b"raw-path-\xff"); +} + +#[test] +fn dirty_worktree_blocks_deletion_before_mutation() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/dirty"); + + let untracked = worktree_path.join("untracked.txt"); + std::fs::write(&untracked, "untracked").unwrap(); + assert!(matches!( + deletion_preflight(&fixture.root, &worktree_path, true), + Err(GitWorkspaceError::DirtyWorktree { path }) if path == worktree_path.canonicalize().unwrap() + )); + assert_eq!(std::fs::read_to_string(&untracked).unwrap(), "untracked"); + std::fs::remove_file(&untracked).unwrap(); + + let tracked = worktree_path.join("README.md"); + std::fs::write(&tracked, "unstaged").unwrap(); + assert!(matches!( + deletion_preflight(&fixture.root, &worktree_path, true), + Err(GitWorkspaceError::DirtyWorktree { .. }) + )); + run_git(&worktree_path, &["restore", "README.md"]); + + std::fs::write(&tracked, "staged").unwrap(); + run_git(&worktree_path, &["add", "README.md"]); + assert!(matches!( + deletion_preflight(&fixture.root, &worktree_path, true), + Err(GitWorkspaceError::DirtyWorktree { .. }) + )); + assert!(worktree_path.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/dirty")); +} + +#[test] +fn worktree_branch_mismatch_blocks_removal_before_mutation() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/actual"); + + let error = remove_workspace( + &fixture.root, + &worktree_path, + "feature/expected", + true, + false, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeBranchMismatch { expected, actual } + if expected == "feature/expected" && actual == "feature/actual" + )); + assert!(worktree_path.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/actual")); +} + +#[test] +fn missing_branch_blocks_deletion_before_mutation() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/missing-ref"); + run_git( + &fixture.root, + &["update-ref", "-d", "refs/heads/feature/missing-ref"], + ); + + let error = deletion_preflight(&fixture.root, &worktree_path, true).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchNotFound { full_ref } + if full_ref == "refs/heads/feature/missing-ref" + )); + assert!(worktree_path.exists()); +} + +#[test] +fn detached_worktree_is_rejected_before_mutation() { + let fixture = GitFixture::new(); + let worktree_path = fixture.tempdir.path().join("detached worktree"); + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["worktree", "add", "--detach"]) + .arg(&worktree_path) + .arg("HEAD") + .output() + .unwrap(); + assert!(output.status.success()); + + let error = deletion_preflight(&fixture.root, &worktree_path, false).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeHasNoLocalBranch { path } + if path == worktree_path.canonicalize().unwrap() + )); + assert!(worktree_path.exists()); +} + +#[test] +fn deletion_preflight_captures_merge_target_oid() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/preflight-target"); + + let preflight = deletion_preflight(&fixture.root, &worktree_path, true).unwrap(); + let merge_target = preflight.merge_target.as_ref().unwrap(); + + assert_eq!(preflight.branch_ref, "refs/heads/feature/preflight-target"); + assert_eq!(merge_target.full_ref, "refs/remotes/origin/main"); + assert_eq!( + merge_target.oid, + ref_oid(&fixture.root, "refs/remotes/origin/main") + ); + assert!(merge_target.is_merged); +} + +#[test] +fn deletion_preflight_rejects_self_merge_target() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/self-target"); + run_git( + &fixture.root, + &["config", "branch.feature/self-target.remote", "."], + ); + run_git( + &fixture.root, + &[ + "config", + "branch.feature/self-target.merge", + "refs/heads/feature/self-target", + ], + ); + + let error = deletion_preflight(&fixture.root, &worktree_path, true).unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::InvalidMergeTarget { + branch_ref, + target_ref, + } if branch_ref == "refs/heads/feature/self-target" + && target_ref == "refs/heads/feature/self-target" + )); +} + +#[test] +fn unmerged_branch_without_force_blocks_removal_before_mutation() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/unmerged"); + std::fs::write(worktree_path.join("feature.txt"), "feature").unwrap(); + run_git(&worktree_path, &["add", "feature.txt"]); + run_git( + &worktree_path, + &[ + "-c", + "user.name=Zap Tests", + "-c", + "user.email=zap@example.com", + "commit", + "-m", + "feature commit", + ], + ); + + let preflight = deletion_preflight(&fixture.root, &worktree_path, true).unwrap(); + let merge_target = preflight.merge_target.as_ref().unwrap(); + assert!(!merge_target.is_merged); + assert_eq!(merge_target.full_ref, "refs/remotes/origin/main"); + + let error = remove_workspace( + &fixture.root, + &worktree_path, + "feature/unmerged", + true, + false, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchNotMerged { branch, merge_target } + if branch == "feature/unmerged" && merge_target == "refs/remotes/origin/main" + )); + assert!(worktree_path.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/unmerged")); +} + +#[test] +fn merged_branch_is_safely_removed() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/merged"); + run_git( + &fixture.root, + &["push", "origin", "main:refs/heads/integration"], + ); + run_git(&fixture.root, &["fetch", "origin"]); + run_git( + &fixture.root, + &[ + "branch", + "--set-upstream-to=origin/integration", + "feature/merged", + ], + ); + + let preflight = deletion_preflight(&fixture.root, &worktree_path, true).unwrap(); + assert_eq!(preflight.branch, "feature/merged"); + let merge_target = preflight.merge_target.as_ref().unwrap(); + assert!(merge_target.is_merged); + assert_eq!(merge_target.full_ref, "refs/remotes/origin/integration"); + + remove_workspace(&fixture.root, &worktree_path, "feature/merged", true, false).unwrap(); + + assert!(!worktree_path.exists()); + assert!(!ref_exists(&fixture.root, "refs/heads/feature/merged")); +} + +#[test] +fn default_merge_target_deletion_does_not_depend_on_main_worktree_head() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/default-target"); + std::fs::write(worktree_path.join("merged.txt"), "merged").unwrap(); + run_git(&worktree_path, &["add", "merged.txt"]); + run_git( + &worktree_path, + &[ + "-c", + "user.name=Zap Tests", + "-c", + "user.email=zap@example.com", + "commit", + "-m", + "merged feature", + ], + ); + run_git( + &fixture.root, + &[ + "-c", + "user.name=Zap Tests", + "-c", + "user.email=zap@example.com", + "merge", + "--no-ff", + "feature/default-target", + "-m", + "merge feature", + ], + ); + run_git(&fixture.root, &["push", "origin", "main"]); + run_git(&fixture.root, &["branch", "other", "HEAD~1"]); + run_git(&fixture.root, &["switch", "other"]); + + let preflight = deletion_preflight(&fixture.root, &worktree_path, true).unwrap(); + let merge_target = preflight.merge_target.as_ref().unwrap(); + assert!(merge_target.is_merged); + assert_eq!(merge_target.full_ref, "refs/remotes/origin/main"); + assert_eq!( + preflight.worktree_path, + worktree_path.canonicalize().unwrap() + ); + assert_eq!( + preflight.branch_oid, + ref_oid(&fixture.root, "refs/heads/feature/default-target") + ); + + remove_workspace( + &fixture.root, + &worktree_path, + "feature/default-target", + true, + false, + ) + .unwrap(); + + assert!(!worktree_path.exists()); + assert!(!ref_exists( + &fixture.root, + "refs/heads/feature/default-target" + )); +} + +#[test] +fn branch_oid_change_after_candidate_capture_blocks_worktree_mutation() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/changed"); + let expected_oid = ref_oid(&fixture.root, "refs/heads/feature/changed"); + let changed_oid = git_output( + &fixture.root, + &[ + "-c", + "user.name=Zap Tests", + "-c", + "user.email=zap@example.com", + "commit-tree", + "HEAD^{tree}", + "-p", + "HEAD", + "-m", + "changed ref", + ], + ); + + let error = remove_workspace_with_hook( + &fixture.root, + &worktree_path, + "feature/changed", + true, + false, + || { + run_git( + &fixture.root, + &[ + "update-ref", + "refs/heads/feature/changed", + &changed_oid, + &expected_oid, + ], + ); + }, + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::RefTransaction { .. })); + assert!(worktree_path.exists()); + assert_eq!( + ref_oid(&fixture.root, "refs/heads/feature/changed"), + changed_oid + ); +} + +#[test] +fn symbolic_branch_change_after_candidate_capture_blocks_worktree_mutation() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/symbolic-change"); + run_git(&fixture.root, &["branch", "feature/symbolic-target"]); + let expected_oid = ref_oid(&fixture.root, "refs/heads/feature/symbolic-change"); + + let error = remove_workspace_with_hook( + &fixture.root, + &worktree_path, + "feature/symbolic-change", + true, + false, + || { + run_git( + &fixture.root, + &[ + "update-ref", + "--no-deref", + "-d", + "refs/heads/feature/symbolic-change", + &expected_oid, + ], + ); + run_git( + &fixture.root, + &[ + "symbolic-ref", + "refs/heads/feature/symbolic-change", + "refs/heads/feature/symbolic-target", + ], + ); + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeBranchMismatch { ref actual, .. } + if actual == "feature/symbolic-target" + )); + assert!(worktree_path.exists()); + assert_eq!( + git_output( + &fixture.root, + &["symbolic-ref", "refs/heads/feature/symbolic-change"] + ), + "refs/heads/feature/symbolic-target" + ); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/symbolic-target" + )); +} + +#[cfg(unix)] +#[test] +fn removal_rejects_alias_retargeted_after_candidate_capture() { + use std::os::unix::fs::symlink; + + let fixture = GitFixture::new(); + let original = fixture.add_linked_worktree("feature/alias-original"); + let other = fixture.add_linked_worktree("feature/alias-other"); + let alias = fixture.tempdir.path().join("worktree alias"); + symlink(&original, &alias).unwrap(); + + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &alias, + "feature/alias-original", + true, + false, + || { + std::fs::remove_file(&alias).unwrap(); + symlink(&other, &alias).unwrap(); + }, + || {}, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeBranchMismatch { .. } + )); + assert!(original.exists()); + assert!(other.exists()); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/alias-original" + )); + assert!(ref_exists(&fixture.root, "refs/heads/feature/alias-other")); +} + +#[test] +fn confirmed_force_removes_unmerged_branch() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/force"); + std::fs::write(worktree_path.join("force.txt"), "force").unwrap(); + run_git(&worktree_path, &["add", "force.txt"]); + run_git( + &worktree_path, + &[ + "-c", + "user.name=Zap Tests", + "-c", + "user.email=zap@example.com", + "commit", + "-m", + "force commit", + ], + ); + + remove_workspace(&fixture.root, &worktree_path, "feature/force", true, true).unwrap(); + + assert!(!worktree_path.exists()); + assert!(!ref_exists(&fixture.root, "refs/heads/feature/force")); +} + +#[test] +fn removes_worktree_but_keeps_branch_without_remote_metadata() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/keep"); + run_git(&fixture.root, &["remote", "remove", "origin"]); + + let preflight = deletion_preflight(&fixture.root, &worktree_path, false).unwrap(); + assert_eq!(preflight.branch, "feature/keep"); + assert!(preflight.merge_target.is_none()); + + remove_workspace(&fixture.root, &worktree_path, "feature/keep", false, false).unwrap(); + + assert!(!worktree_path.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/keep")); +} + +#[test] +fn locked_deletion_accepts_same_oid_recreation_before_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/same-oid-current"); + let branch_ref = "refs/heads/feature/same-oid-current"; + let oid = ref_oid(&fixture.root, branch_ref); + + remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/same-oid-current", + true, + false, + || { + run_git(&fixture.root, &["update-ref", "-d", branch_ref, &oid]); + run_git(&fixture.root, &["update-ref", branch_ref, &oid]); + }, + || {}, + ) + .unwrap(); + + assert!(!worktree.exists()); + assert!(!ref_exists(&fixture.root, branch_ref)); +} + +#[test] +fn locked_deletion_rejects_different_oid_before_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/different-oid"); + let branch_ref = "refs/heads/feature/different-oid"; + let old_oid = ref_oid(&fixture.root, branch_ref); + let changed_oid = fixture_commit_oid(&fixture.root, "changed branch"); + + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/different-oid", + true, + false, + || { + run_git( + &fixture.root, + &["update-ref", branch_ref, &changed_oid, &old_oid], + ) + }, + || {}, + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::RefTransaction { .. })); + assert!(worktree.exists()); + assert_eq!(ref_oid(&fixture.root, branch_ref), changed_oid); +} + +#[test] +fn locked_deletion_rejects_target_drift_before_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/target-drift"); + + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/target-drift", + true, + false, + || advance_remote_main(&fixture), + || {}, + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::RefTransaction { .. })); + assert!(worktree.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/target-drift")); +} + +#[test] +fn force_deletion_does_not_require_remote_metadata() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["remote", "remove", "origin"]); + let worktree = fixture.add_linked_worktree("feature/force-without-remote"); + + remove_workspace( + &fixture.root, + &worktree, + "feature/force-without-remote", + true, + true, + ) + .unwrap(); + + assert!(!worktree.exists()); + assert!(!ref_exists( + &fixture.root, + "refs/heads/feature/force-without-remote" + )); +} + +#[test] +fn locked_deletion_aborts_when_worktree_becomes_dirty_after_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/dirty-after-lock"); + + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/dirty-after-lock", + true, + false, + || {}, + || std::fs::write(worktree.join("keep.txt"), "keep").unwrap(), + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::DirtyWorktree { .. })); + assert!(worktree.exists()); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/dirty-after-lock" + )); +} + +#[test] +fn locked_deletion_aborts_when_target_selection_changes_after_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/target-selection"); + run_git(&fixture.root, &["push", "origin", "main:refs/heads/other"]); + run_git(&fixture.root, &["fetch", "origin"]); + + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/target-selection", + true, + false, + || {}, + || { + run_git( + &fixture.root, + &[ + "branch", + "--set-upstream-to=origin/other", + "feature/target-selection", + ], + ); + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::MergeTargetChanged { .. } + )); + assert!(worktree.exists()); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/target-selection" + )); +} + +#[test] +fn worktree_remove_failure_aborts_and_preserves_branch() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/remove-failure"); + + let error = remove_workspace_with_remove_runner( + &fixture.root, + &worktree, + "feature/remove-failure", + true, + false, + |_repository, _path| Err(injected_command_error("remove worktree")), + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::CommandFailed { .. })); + assert!(worktree.exists()); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/remove-failure" + )); +} + +#[test] +fn commit_failure_reports_removed_worktree_partial_state() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/commit-failure"); + + let error = remove_workspace_with_after_remove_hook( + &fixture.root, + &worktree, + "feature/commit-failure", + true, + false, + |transaction| transaction.terminate_for_test(), + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchDeleteTransactionFailed { + worktree_removed: true, + .. + } + )); + assert!(!worktree.exists()); +} + +#[test] +fn wrapper_errors_expose_primary_sources() { + use std::error::Error; + + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("source chain target"); + let creation = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/source-chain", + &target, + |_repository, _remote_ref, _branch, _target, _expected_oid| { + Err(injected_command_error("create worktree")) + }, + ) + .unwrap_err(); + + assert!( + matches!(creation.source(), Some(source) if source.to_string().contains("create worktree")) + ); +} + +#[tokio::test] +async fn async_worktree_wrappers_return_creation_preflight_and_removal_results() { + let fixture = GitFixture::new(); + let worktree_path = fixture.tempdir.path().join("async worktree"); + + create_from_remote_async( + fixture.root.clone(), + "refs/remotes/origin/main".to_string(), + "feature/async".to_string(), + worktree_path.clone(), + ) + .await + .unwrap(); + let preflight = deletion_preflight_async(fixture.root.clone(), worktree_path.clone(), true) + .await + .unwrap(); + assert_eq!(preflight.branch, "feature/async"); + assert!(preflight.merge_target.as_ref().unwrap().is_merged); + + remove_workspace_async( + fixture.root.clone(), + worktree_path.clone(), + "feature/async".to_string(), + true, + false, + ) + .await + .unwrap(); + + assert!(!worktree_path.exists()); + assert!(!ref_exists(&fixture.root, "refs/heads/feature/async")); +} + +#[tokio::test] +async fn create_from_local_async_returns_created_worktree() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "feature/async-local"]); + let worktree_path = fixture.tempdir.path().join("async local worktree"); + + create_from_local_async( + fixture.root.clone(), + "feature/async-local".to_string(), + worktree_path.clone(), + ) + .await + .unwrap(); + + assert_eq!(current_branch(&worktree_path), "feature/async-local"); + remove_workspace_async( + fixture.root.clone(), + worktree_path.clone(), + "feature/async-local".to_string(), + true, + false, + ) + .await + .unwrap(); + assert!(!worktree_path.exists()); +} + +#[tokio::test] +async fn async_wrappers_run_git_operations_off_the_calling_task() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("async clone"); + + let validated = validate_repository_async(fixture.root.clone()) + .await + .unwrap(); + let refs = fetch_and_list_refs_async(fixture.root.clone()) + .await + .unwrap(); + let worktrees = list_worktrees_async(fixture.root.clone()).await.unwrap(); + let cloned = clone_repository_async( + fixture.remote.to_str().unwrap().to_string(), + Some(target.clone()), + ) + .await + .unwrap(); + + assert_eq!(validated.root, fixture.root.canonicalize().unwrap()); + assert!(!refs.is_empty()); + assert_eq!(worktrees.len(), 1); + assert_eq!(cloned.root, target.canonicalize().unwrap()); +} diff --git a/app/src/project_organization/migration.rs b/app/src/project_organization/migration.rs new file mode 100644 index 00000000000..081b3182695 --- /dev/null +++ b/app/src/project_organization/migration.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; + +/// 已确认属于 linked worktree 的终端工作目录标识。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorktreeIdentity { + pub repository_path: PathBuf, + pub worktree_path: PathBuf, + pub branch: String, +} + +/// 归类页签中的终端工作目录。 +/// +/// 只有全部可识别目录指向同一个 linked worktree 时才返回归属, 避免把混合页签 +/// 错误迁移到某个 workspace。 +pub fn classify_tab_worktree( + identities: impl IntoIterator>, +) -> Option { + let mut identities = identities.into_iter().flatten(); + let first = identities.next()?; + identities + .all(|identity| identity == first) + .then_some(first) +} + +/// repository 或 workspace 在 Zap 外部变更后的可见健康状态。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WorkspaceHealth { + Ready, + RepositoryMissing, + WorktreeMissing, + BranchMissing, + WorktreeBranchMismatch { actual: String }, +} diff --git a/app/src/project_organization/migration_tests.rs b/app/src/project_organization/migration_tests.rs new file mode 100644 index 00000000000..66433792527 --- /dev/null +++ b/app/src/project_organization/migration_tests.rs @@ -0,0 +1,50 @@ +use std::path::PathBuf; + +use super::migration::{classify_tab_worktree, WorkspaceHealth, WorktreeIdentity}; + +#[test] +fn linked_worktree_tab_with_one_identity_is_classified() { + let identity = WorktreeIdentity { + repository_path: PathBuf::from("/repository"), + worktree_path: PathBuf::from("/repository-worktrees/feature-a"), + branch: "feature/a".to_string(), + }; + + assert_eq!( + classify_tab_worktree([Some(identity.clone()), Some(identity.clone())]), + Some(identity) + ); +} + +#[test] +fn mixed_worktree_tab_remains_unclassified() { + let first = WorktreeIdentity { + repository_path: PathBuf::from("/repository"), + worktree_path: PathBuf::from("/worktree-a"), + branch: "feature/a".to_string(), + }; + let second = WorktreeIdentity { + repository_path: PathBuf::from("/repository"), + worktree_path: PathBuf::from("/worktree-b"), + branch: "feature/b".to_string(), + }; + + assert_eq!(classify_tab_worktree([Some(first), Some(second)]), None); +} + +#[test] +fn tab_without_recognized_worktree_remains_unclassified() { + assert_eq!(classify_tab_worktree([None, None]), None); +} + +#[test] +fn health_states_preserve_branch_mismatch_details() { + assert_eq!( + WorkspaceHealth::WorktreeBranchMismatch { + actual: "feature/actual".to_string(), + }, + WorkspaceHealth::WorktreeBranchMismatch { + actual: "feature/actual".to_string(), + } + ); +} diff --git a/app/src/project_organization/mod.rs b/app/src/project_organization/mod.rs new file mode 100644 index 00000000000..430637d4184 --- /dev/null +++ b/app/src/project_organization/mod.rs @@ -0,0 +1,13 @@ +pub mod domain; +pub mod git; +pub mod migration; +pub mod model; +pub mod view; + +#[cfg(test)] +#[path = "git_tests.rs"] +mod git_tests; + +#[cfg(test)] +#[path = "migration_tests.rs"] +mod migration_tests; diff --git a/app/src/project_organization/model.rs b/app/src/project_organization/model.rs new file mode 100644 index 00000000000..b1d76779637 --- /dev/null +++ b/app/src/project_organization/model.rs @@ -0,0 +1,816 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use chrono::Utc; +use uuid::Uuid; +use warpui::{Entity, ModelContext, SingletonEntity}; + +use crate::persistence::{ + model::{Repository as PersistedRepository, RepositoryWorkspace as PersistedWorkspace}, + RepositoryPersistence, RepositoryPersistenceOperation, +}; + +use super::domain::{ + ProjectOrganizationError, ProjectOrganizationEvent, Repository, RepositoryId, RepositorySource, + RepositoryWorkspace, RepositoryWorkspaceId, +}; + +pub struct ProjectOrganizationModel { + repositories: HashMap, + repository_ids_by_path: HashMap, + workspaces: HashMap, + workspace_ids_by_repository_branch: HashMap<(RepositoryId, String), RepositoryWorkspaceId>, + workspace_ids_by_path: HashMap, + persistence: RepositoryPersistence, +} + +enum CanonicalPathMatch { + None, + Unique(Id), + Ambiguous(Vec), +} + +impl Entity for ProjectOrganizationModel { + type Event = ProjectOrganizationEvent; +} + +impl SingletonEntity for ProjectOrganizationModel {} + +impl ProjectOrganizationModel { + pub fn try_new( + persisted_repositories: Vec, + persisted_workspaces: Vec, + persistence: RepositoryPersistence, + _ctx: &mut ModelContext, + ) -> Result { + let mut model = Self { + repositories: HashMap::new(), + repository_ids_by_path: HashMap::new(), + workspaces: HashMap::new(), + workspace_ids_by_repository_branch: HashMap::new(), + workspace_ids_by_path: HashMap::new(), + persistence, + }; + + for repository in persisted_repositories { + let repository = Self::repository_from_persisted(repository)?; + model.insert_repository_checked(repository)?; + } + for workspace in persisted_workspaces { + let workspace = Self::workspace_from_persisted(workspace)?; + model.insert_workspace_checked(workspace)?; + } + + Ok(model) + } + + pub fn add_local_repository( + &mut self, + path: impl AsRef, + ctx: &mut ModelContext, + ) -> Result { + self.add_local_repository_with_optional_remote(path, None, ctx) + } + + pub fn add_local_repository_with_remote( + &mut self, + path: impl AsRef, + remote_url: String, + ctx: &mut ModelContext, + ) -> Result { + self.add_local_repository_with_optional_remote(path, Some(remote_url), ctx) + } + + /// 添加本地 repository,并原子创建其主 worktree 对应的 local workspace。 + pub fn add_local_repository_with_initial_workspace( + &mut self, + path: impl AsRef, + remote_url: Option, + primary_branch: impl Into, + ctx: &mut ModelContext, + ) -> Result<(RepositoryId, RepositoryWorkspaceId), ProjectOrganizationError> { + let canonical_path = Self::canonicalize(path.as_ref())?; + let now = Utc::now().naive_utc(); + let repository = Repository { + id: RepositoryId::from(Uuid::new_v4()), + display_name: canonical_path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| ProjectOrganizationError::InvalidPathEncoding { + path: canonical_path.clone(), + })? + .to_string(), + path: canonical_path.clone(), + remote_url, + source: RepositorySource::Local, + created_at: now, + last_opened_at: now, + }; + self.validate_new_repository(&repository)?; + + let workspace = RepositoryWorkspace { + id: RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id: repository.id, + display_name: "local".to_string(), + branch: primary_branch.into(), + worktree_path: canonical_path, + created_at: repository.created_at, + last_opened_at: repository.last_opened_at, + }; + if self.workspaces.contains_key(&workspace.id) { + return Err(ProjectOrganizationError::WorkspaceIdAlreadyExists { + workspace_id: workspace.id, + }); + } + if let Some(existing_workspace_id) = self + .workspace_ids_by_repository_branch + .get(&(workspace.repository_id, workspace.branch.clone())) + { + return Err(ProjectOrganizationError::WorkspaceBranchAlreadyExists { + repository_id: workspace.repository_id, + branch: workspace.branch.clone(), + existing_workspace_id: *existing_workspace_id, + }); + } + if let Some(existing_workspace_id) = + self.workspace_ids_by_path.get(&workspace.worktree_path) + { + return Err(ProjectOrganizationError::WorkspacePathAlreadyExists { + existing_workspace_id: *existing_workspace_id, + canonical_path: workspace.worktree_path.clone(), + }); + } + + self.persist( + RepositoryPersistenceOperation::UpsertRepositoryWithWorkspace { + repository: Self::persisted_repository(&repository)?, + workspace: Self::persisted_workspace(&workspace)?, + }, + "repository addition with initial workspace", + )?; + let repository_id = repository.id; + let workspace_id = workspace.id; + self.commit_repository(repository); + self.commit_workspace(workspace); + ctx.emit(ProjectOrganizationEvent::RepositoryAdded { repository_id }); + ctx.emit(ProjectOrganizationEvent::WorkspaceAdded { workspace_id }); + Ok((repository_id, workspace_id)) + } + + fn add_local_repository_with_optional_remote( + &mut self, + path: impl AsRef, + remote_url: Option, + ctx: &mut ModelContext, + ) -> Result { + let canonical_path = Self::canonicalize(path.as_ref())?; + match self.repository_match_for_canonical_path(&canonical_path, None) { + CanonicalPathMatch::None => {} + CanonicalPathMatch::Unique(existing_repository_id) => { + return Err(ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path, + }); + } + CanonicalPathMatch::Ambiguous(repository_ids) => { + return Err(ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path, + repository_ids, + }); + } + } + let display_name = canonical_path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| ProjectOrganizationError::InvalidPathEncoding { + path: canonical_path.clone(), + })? + .to_string(); + let now = Utc::now().naive_utc(); + let repository = Repository { + id: RepositoryId::from(Uuid::new_v4()), + display_name, + path: canonical_path, + remote_url, + source: RepositorySource::Local, + created_at: now, + last_opened_at: now, + }; + let repository_id = repository.id; + + self.persist_repository(&repository, "repository addition")?; + self.commit_repository(repository); + ctx.emit(ProjectOrganizationEvent::RepositoryAdded { repository_id }); + Ok(repository_id) + } + + pub fn touch_repository_path( + &mut self, + path: impl AsRef, + ctx: &mut ModelContext, + ) -> Result { + let canonical_path = Self::canonicalize(path.as_ref())?; + let repository_id = match self.repository_match_for_canonical_path(&canonical_path, None) { + CanonicalPathMatch::None => return self.add_local_repository(canonical_path, ctx), + CanonicalPathMatch::Unique(repository_id) => repository_id, + CanonicalPathMatch::Ambiguous(repository_ids) => { + return Err(ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path, + repository_ids, + }); + } + }; + let mut repository = self + .repositories + .get(&repository_id) + .expect("repository path index must reference an existing repository") + .clone(); + let previous_path = repository.path.clone(); + repository.path = canonical_path; + repository.last_opened_at = Utc::now().naive_utc(); + + self.persist_repository(&repository, "repository access timestamp update")?; + self.repository_ids_by_path.remove(&previous_path); + self.repository_ids_by_path + .insert(repository.path.clone(), repository_id); + self.repositories.insert(repository_id, repository); + ctx.emit(ProjectOrganizationEvent::RepositoryUpdated { repository_id }); + Ok(repository_id) + } + + pub fn insert_repository( + &mut self, + mut repository: Repository, + ctx: &mut ModelContext, + ) -> Result<(), ProjectOrganizationError> { + repository.path = Self::canonicalize(&repository.path)?; + if self.repositories.contains_key(&repository.id) { + return Err(ProjectOrganizationError::RepositoryIdAlreadyExists { + repository_id: repository.id, + }); + } + match self.repository_match_for_canonical_path(&repository.path, None) { + CanonicalPathMatch::None => {} + CanonicalPathMatch::Unique(existing_repository_id) => { + return Err(ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path: repository.path, + }); + } + CanonicalPathMatch::Ambiguous(repository_ids) => { + return Err(ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path: repository.path, + repository_ids, + }); + } + } + self.persist_repository(&repository, "repository insertion")?; + let repository_id = repository.id; + self.commit_repository(repository); + ctx.emit(ProjectOrganizationEvent::RepositoryAdded { repository_id }); + Ok(()) + } + + pub fn update_repository( + &mut self, + mut repository: Repository, + ctx: &mut ModelContext, + ) -> Result<(), ProjectOrganizationError> { + repository.path = Self::canonicalize(&repository.path)?; + let previous = self.repositories.get(&repository.id).cloned().ok_or( + ProjectOrganizationError::RepositoryNotFound { + repository_id: repository.id, + }, + )?; + match self.repository_match_for_canonical_path(&repository.path, Some(repository.id)) { + CanonicalPathMatch::None => {} + CanonicalPathMatch::Unique(existing_repository_id) => { + return Err(ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path: repository.path, + }); + } + CanonicalPathMatch::Ambiguous(repository_ids) => { + return Err(ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path: repository.path, + repository_ids, + }); + } + } + let previous_path = previous.path; + let repository_id = repository.id; + + self.persist_repository(&repository, "repository update")?; + self.repository_ids_by_path.remove(&previous_path); + self.repository_ids_by_path + .insert(repository.path.clone(), repository_id); + self.repositories.insert(repository_id, repository); + ctx.emit(ProjectOrganizationEvent::RepositoryUpdated { repository_id }); + Ok(()) + } + + pub fn rename_repository( + &mut self, + repository_id: RepositoryId, + display_name: String, + ctx: &mut ModelContext, + ) -> Result<(), ProjectOrganizationError> { + let mut repository = self + .repositories + .get(&repository_id) + .cloned() + .ok_or(ProjectOrganizationError::RepositoryNotFound { repository_id })?; + repository.display_name = display_name; + self.update_repository(repository, ctx) + } + + pub fn remove_repository( + &mut self, + repository_id: RepositoryId, + ctx: &mut ModelContext, + ) -> Result { + if self + .workspace_ids_by_repository_branch + .keys() + .any(|(workspace_repository_id, _)| *workspace_repository_id == repository_id) + { + return Err(ProjectOrganizationError::RepositoryHasWorkspaces { repository_id }); + } + let repository = self + .repositories + .get(&repository_id) + .cloned() + .ok_or(ProjectOrganizationError::RepositoryNotFound { repository_id })?; + + self.persist( + RepositoryPersistenceOperation::DeleteRepository { + repository_id: repository_id.to_string(), + }, + "repository removal", + )?; + self.repositories.remove(&repository_id); + self.repository_ids_by_path.remove(&repository.path); + ctx.emit(ProjectOrganizationEvent::RepositoryRemoved { repository_id }); + Ok(repository) + } + + pub fn insert_workspace( + &mut self, + mut workspace: RepositoryWorkspace, + ctx: &mut ModelContext, + ) -> Result<(), ProjectOrganizationError> { + workspace.worktree_path = Self::canonicalize(&workspace.worktree_path)?; + if self.workspaces.contains_key(&workspace.id) { + return Err(ProjectOrganizationError::WorkspaceIdAlreadyExists { + workspace_id: workspace.id, + }); + } + if !self.repositories.contains_key(&workspace.repository_id) { + return Err(ProjectOrganizationError::RepositoryNotFound { + repository_id: workspace.repository_id, + }); + } + let branch_key = (workspace.repository_id, workspace.branch.clone()); + if let Some(existing_workspace_id) = + self.workspace_ids_by_repository_branch.get(&branch_key) + { + return Err(ProjectOrganizationError::WorkspaceBranchAlreadyExists { + repository_id: workspace.repository_id, + branch: workspace.branch, + existing_workspace_id: *existing_workspace_id, + }); + } + match self.workspace_match_for_canonical_path(&workspace.worktree_path, None) { + CanonicalPathMatch::None => {} + CanonicalPathMatch::Unique(existing_workspace_id) => { + return Err(ProjectOrganizationError::WorkspacePathAlreadyExists { + existing_workspace_id, + canonical_path: workspace.worktree_path, + }); + } + CanonicalPathMatch::Ambiguous(workspace_ids) => { + return Err(ProjectOrganizationError::AmbiguousWorkspacePath { + canonical_path: workspace.worktree_path, + workspace_ids, + }); + } + } + self.persist_workspace(&workspace, "repository workspace insertion")?; + let workspace_id = workspace.id; + self.commit_workspace(workspace); + ctx.emit(ProjectOrganizationEvent::WorkspaceAdded { workspace_id }); + Ok(()) + } + + pub fn update_workspace( + &mut self, + mut workspace: RepositoryWorkspace, + ctx: &mut ModelContext, + ) -> Result<(), ProjectOrganizationError> { + workspace.worktree_path = Self::canonicalize(&workspace.worktree_path)?; + let previous = self.workspaces.get(&workspace.id).cloned().ok_or( + ProjectOrganizationError::WorkspaceNotFound { + workspace_id: workspace.id, + }, + )?; + if !self.repositories.contains_key(&workspace.repository_id) { + return Err(ProjectOrganizationError::RepositoryNotFound { + repository_id: workspace.repository_id, + }); + } + let branch_key = (workspace.repository_id, workspace.branch.clone()); + if let Some(existing_workspace_id) = + self.workspace_ids_by_repository_branch.get(&branch_key) + { + if *existing_workspace_id != workspace.id { + return Err(ProjectOrganizationError::WorkspaceBranchAlreadyExists { + repository_id: workspace.repository_id, + branch: workspace.branch, + existing_workspace_id: *existing_workspace_id, + }); + } + } + match self.workspace_match_for_canonical_path(&workspace.worktree_path, Some(workspace.id)) + { + CanonicalPathMatch::None => {} + CanonicalPathMatch::Unique(existing_workspace_id) => { + return Err(ProjectOrganizationError::WorkspacePathAlreadyExists { + existing_workspace_id, + canonical_path: workspace.worktree_path, + }); + } + CanonicalPathMatch::Ambiguous(workspace_ids) => { + return Err(ProjectOrganizationError::AmbiguousWorkspacePath { + canonical_path: workspace.worktree_path, + workspace_ids, + }); + } + } + let workspace_id = workspace.id; + + self.persist_workspace(&workspace, "repository workspace update")?; + self.workspace_ids_by_repository_branch + .remove(&(previous.repository_id, previous.branch)); + self.workspace_ids_by_path.remove(&previous.worktree_path); + self.workspace_ids_by_repository_branch + .insert(branch_key, workspace_id); + self.workspace_ids_by_path + .insert(workspace.worktree_path.clone(), workspace_id); + self.workspaces.insert(workspace_id, workspace); + ctx.emit(ProjectOrganizationEvent::WorkspaceUpdated { workspace_id }); + Ok(()) + } + + pub fn rename_workspace( + &mut self, + workspace_id: RepositoryWorkspaceId, + display_name: String, + ctx: &mut ModelContext, + ) -> Result<(), ProjectOrganizationError> { + let mut workspace = self + .workspaces + .get(&workspace_id) + .cloned() + .ok_or(ProjectOrganizationError::WorkspaceNotFound { workspace_id })?; + workspace.display_name = display_name; + self.update_workspace(workspace, ctx) + } + + pub fn remove_workspace( + &mut self, + workspace_id: RepositoryWorkspaceId, + ctx: &mut ModelContext, + ) -> Result { + let workspace = self + .workspaces + .get(&workspace_id) + .cloned() + .ok_or(ProjectOrganizationError::WorkspaceNotFound { workspace_id })?; + self.persist( + RepositoryPersistenceOperation::DeleteRepositoryWorkspace { + workspace_id: workspace_id.to_string(), + }, + "repository workspace removal", + )?; + self.workspaces.remove(&workspace_id); + self.workspace_ids_by_repository_branch + .remove(&(workspace.repository_id, workspace.branch.clone())); + self.workspace_ids_by_path.remove(&workspace.worktree_path); + ctx.emit(ProjectOrganizationEvent::WorkspaceRemoved { workspace_id }); + Ok(workspace) + } + + pub fn repository(&self, repository_id: RepositoryId) -> Option<&Repository> { + self.repositories.get(&repository_id) + } + + pub fn workspace(&self, workspace_id: RepositoryWorkspaceId) -> Option<&RepositoryWorkspace> { + self.workspaces.get(&workspace_id) + } + + pub fn repositories(&self) -> impl Iterator { + self.repositories.values() + } + + pub fn workspaces(&self) -> impl Iterator { + self.workspaces.values() + } + + pub fn workspaces_for_repository( + &self, + repository_id: RepositoryId, + ) -> impl Iterator { + self.workspaces + .values() + .filter(move |workspace| workspace.repository_id == repository_id) + } + + fn canonicalize(path: &Path) -> Result { + dunce::canonicalize(path).map_err(|source| ProjectOrganizationError::InvalidPath { + path: path.to_path_buf(), + source, + }) + } + + fn normalize_persisted_path(path: String) -> PathBuf { + let path = PathBuf::from(path); + dunce::canonicalize(&path).unwrap_or(path) + } + + fn repository_match_for_canonical_path( + &self, + canonical_path: &Path, + excluded_id: Option, + ) -> CanonicalPathMatch { + let mut matches = self + .repositories + .iter() + .filter_map(|(repository_id, repository)| { + if Some(*repository_id) == excluded_id { + return None; + } + let candidate = dunce::canonicalize(&repository.path).ok()?; + (candidate == canonical_path).then_some(*repository_id) + }) + .collect::>(); + matches.sort_by_key(|id| id.0); + match matches.as_slice() { + [] => CanonicalPathMatch::None, + [repository_id] => CanonicalPathMatch::Unique(*repository_id), + _ => CanonicalPathMatch::Ambiguous(matches), + } + } + + fn workspace_match_for_canonical_path( + &self, + canonical_path: &Path, + excluded_id: Option, + ) -> CanonicalPathMatch { + let mut matches = self + .workspaces + .iter() + .filter_map(|(workspace_id, workspace)| { + if Some(*workspace_id) == excluded_id { + return None; + } + let candidate = dunce::canonicalize(&workspace.worktree_path).ok()?; + (candidate == canonical_path).then_some(*workspace_id) + }) + .collect::>(); + matches.sort_by_key(|id| id.0); + match matches.as_slice() { + [] => CanonicalPathMatch::None, + [workspace_id] => CanonicalPathMatch::Unique(*workspace_id), + _ => CanonicalPathMatch::Ambiguous(matches), + } + } + + fn validate_new_repository( + &self, + repository: &Repository, + ) -> Result<(), ProjectOrganizationError> { + if self.repositories.contains_key(&repository.id) { + return Err(ProjectOrganizationError::RepositoryIdAlreadyExists { + repository_id: repository.id, + }); + } + if let Some(existing_repository_id) = self.repository_ids_by_path.get(&repository.path) { + return Err(ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id: *existing_repository_id, + canonical_path: repository.path.clone(), + }); + } + Ok(()) + } + + fn validate_new_workspace( + &self, + workspace: &RepositoryWorkspace, + ) -> Result<(), ProjectOrganizationError> { + if self.workspaces.contains_key(&workspace.id) { + return Err(ProjectOrganizationError::WorkspaceIdAlreadyExists { + workspace_id: workspace.id, + }); + } + if !self.repositories.contains_key(&workspace.repository_id) { + return Err(ProjectOrganizationError::RepositoryNotFound { + repository_id: workspace.repository_id, + }); + } + let branch_key = (workspace.repository_id, workspace.branch.clone()); + if let Some(existing_workspace_id) = + self.workspace_ids_by_repository_branch.get(&branch_key) + { + return Err(ProjectOrganizationError::WorkspaceBranchAlreadyExists { + repository_id: workspace.repository_id, + branch: workspace.branch.clone(), + existing_workspace_id: *existing_workspace_id, + }); + } + if let Some(existing_workspace_id) = + self.workspace_ids_by_path.get(&workspace.worktree_path) + { + return Err(ProjectOrganizationError::WorkspacePathAlreadyExists { + existing_workspace_id: *existing_workspace_id, + canonical_path: workspace.worktree_path.clone(), + }); + } + Ok(()) + } + + fn insert_repository_checked( + &mut self, + repository: Repository, + ) -> Result<(), ProjectOrganizationError> { + self.validate_new_repository(&repository)?; + self.commit_repository(repository); + Ok(()) + } + + fn commit_repository(&mut self, repository: Repository) { + debug_assert!(!self.repositories.contains_key(&repository.id)); + self.repository_ids_by_path + .insert(repository.path.clone(), repository.id); + self.repositories.insert(repository.id, repository); + } + + fn insert_workspace_checked( + &mut self, + workspace: RepositoryWorkspace, + ) -> Result<(), ProjectOrganizationError> { + self.validate_new_workspace(&workspace)?; + self.commit_workspace(workspace); + Ok(()) + } + + fn commit_workspace(&mut self, workspace: RepositoryWorkspace) { + debug_assert!(!self.workspaces.contains_key(&workspace.id)); + self.workspace_ids_by_repository_branch.insert( + (workspace.repository_id, workspace.branch.clone()), + workspace.id, + ); + self.workspace_ids_by_path + .insert(workspace.worktree_path.clone(), workspace.id); + self.workspaces.insert(workspace.id, workspace); + } + + fn repository_from_persisted( + repository: PersistedRepository, + ) -> Result { + let id = RepositoryId::try_from(repository.id.clone()).map_err(|source| { + ProjectOrganizationError::InvalidPersistedRepositoryId { + value: repository.id.clone(), + source, + } + })?; + let source = RepositorySource::try_from(repository.source.clone()).map_err(|source| { + ProjectOrganizationError::InvalidPersistedRepositorySource { + value: repository.source.clone(), + source, + } + })?; + let path = Self::normalize_persisted_path(repository.path); + Ok(Repository { + id, + display_name: repository.display_name, + path, + remote_url: repository.remote_url, + source, + created_at: repository.created_at, + last_opened_at: repository.last_opened_at, + }) + } + + fn workspace_from_persisted( + workspace: PersistedWorkspace, + ) -> Result { + let id = RepositoryWorkspaceId::try_from(workspace.id.clone()).map_err(|source| { + ProjectOrganizationError::InvalidPersistedWorkspaceId { + value: workspace.id.clone(), + source, + } + })?; + let repository_id = + RepositoryId::try_from(workspace.repository_id.clone()).map_err(|source| { + ProjectOrganizationError::InvalidPersistedWorkspaceRepositoryId { + value: workspace.repository_id.clone(), + source, + } + })?; + let worktree_path = Self::normalize_persisted_path(workspace.worktree_path); + Ok(RepositoryWorkspace { + id, + repository_id, + display_name: workspace.display_name, + branch: workspace.branch, + worktree_path, + created_at: workspace.created_at, + last_opened_at: workspace.last_opened_at, + }) + } + + fn persisted_repository( + repository: &Repository, + ) -> Result { + let path = repository.path.to_str().ok_or_else(|| { + ProjectOrganizationError::InvalidPathEncoding { + path: repository.path.clone(), + } + })?; + Ok(PersistedRepository { + id: repository.id.to_string(), + display_name: repository.display_name.clone(), + path: path.to_string(), + remote_url: repository.remote_url.clone(), + source: repository.source.to_string(), + created_at: repository.created_at, + last_opened_at: repository.last_opened_at, + }) + } + + fn persisted_workspace( + workspace: &RepositoryWorkspace, + ) -> Result { + let worktree_path = workspace.worktree_path.to_str().ok_or_else(|| { + ProjectOrganizationError::InvalidPathEncoding { + path: workspace.worktree_path.clone(), + } + })?; + Ok(PersistedWorkspace { + id: workspace.id.to_string(), + repository_id: workspace.repository_id.to_string(), + display_name: workspace.display_name.clone(), + branch: workspace.branch.clone(), + worktree_path: worktree_path.to_string(), + created_at: workspace.created_at, + last_opened_at: workspace.last_opened_at, + }) + } + + fn persist_repository( + &self, + repository: &Repository, + operation: &'static str, + ) -> Result<(), ProjectOrganizationError> { + self.persist( + RepositoryPersistenceOperation::UpsertRepository { + repository: Self::persisted_repository(repository)?, + }, + operation, + ) + } + + fn persist_workspace( + &self, + workspace: &RepositoryWorkspace, + operation: &'static str, + ) -> Result<(), ProjectOrganizationError> { + self.persist( + RepositoryPersistenceOperation::UpsertRepositoryWorkspace { + workspace: Self::persisted_workspace(workspace)?, + }, + operation, + ) + } + + fn persist( + &self, + operation: RepositoryPersistenceOperation, + operation_name: &'static str, + ) -> Result<(), ProjectOrganizationError> { + self.persistence + .execute(operation) + .map_err(|error| ProjectOrganizationError::Persistence { + operation: operation_name, + details: error.to_string(), + }) + } +} + +#[cfg(test)] +#[path = "model_tests.rs"] +mod model_tests; diff --git a/app/src/project_organization/model_tests.rs b/app/src/project_organization/model_tests.rs new file mode 100644 index 00000000000..b2e72efb30c --- /dev/null +++ b/app/src/project_organization/model_tests.rs @@ -0,0 +1,2069 @@ +use std::{ + path::Path, + sync::{ + mpsc::{self, Receiver}, + Arc, Mutex, + }, +}; + +use chrono::{Duration, Utc}; +use tempfile::TempDir; +use uuid::Uuid; +use warpui::{App, Entity, ModelHandle}; + +use crate::{ + persistence::{ + model::{Repository as PersistedRepository, RepositoryWorkspace as PersistedWorkspace}, + ModelEvent, RepositoryPersistence, RepositoryPersistenceError, + RepositoryPersistenceOperation, + }, + project_organization::{ + domain::{ + ProjectOrganizationError, ProjectOrganizationEvent, Repository, RepositoryId, + RepositorySource, RepositoryWorkspace, RepositoryWorkspaceId, + }, + model::ProjectOrganizationModel, + }, +}; + +struct PersistenceHarness { + operations: Receiver, +} + +fn acknowledged_persistence( + result: Result<(), RepositoryPersistenceError>, +) -> (RepositoryPersistence, PersistenceHarness) { + let (event_sender, event_receiver) = mpsc::sync_channel(20); + let (operation_sender, operation_receiver) = mpsc::sync_channel(20); + std::thread::spawn(move || { + while let Ok(ModelEvent::RepositoryPersistence(request)) = event_receiver.recv() { + if operation_sender.send(request.operation).is_err() { + return; + } + if request.response.send(result.clone()).is_err() { + return; + } + } + }); + ( + RepositoryPersistence::new(Some(event_sender)), + PersistenceHarness { + operations: operation_receiver, + }, + ) +} + +fn create_model( + app: &mut App, + repositories: Vec, + workspaces: Vec, + persistence: RepositoryPersistence, +) -> ModelHandle { + app.add_model(|ctx| { + ProjectOrganizationModel::try_new(repositories, workspaces, persistence, ctx) + .expect("project organization model should initialize") + }) +} + +fn create_acknowledged_model( + app: &mut App, + repositories: Vec, + workspaces: Vec, +) -> (ModelHandle, PersistenceHarness) { + let (persistence, harness) = acknowledged_persistence(Ok(())); + ( + create_model(app, repositories, workspaces, persistence), + harness, + ) +} + +struct ProjectOrganizationEventProbe; + +impl Entity for ProjectOrganizationEventProbe { + type Event = (); +} + +fn capture_project_organization_events( + app: &mut App, + model: &ModelHandle, +) -> ( + Arc>>, + ModelHandle, +) { + let emitted_events = Arc::new(Mutex::new(Vec::new())); + let captured_events = emitted_events.clone(); + let subscribed_model = model.clone(); + let probe = app.add_model(move |ctx| { + ctx.subscribe_to_model(&subscribed_model, move |_, event, _| { + captured_events.lock().unwrap().push(event.clone()); + }); + ProjectOrganizationEventProbe + }); + (emitted_events, probe) +} + +fn persisted_repository(id: RepositoryId, path: &Path) -> PersistedRepository { + let created_at = Utc::now().naive_utc() - Duration::hours(1); + PersistedRepository { + id: id.to_string(), + display_name: "repository".to_string(), + path: path + .to_str() + .expect("temporary repository path should be valid UTF-8") + .to_string(), + remote_url: None, + source: "local".to_string(), + created_at, + last_opened_at: created_at, + } +} + +fn persisted_workspace( + id: RepositoryWorkspaceId, + repository_id: RepositoryId, + branch: &str, + worktree_path: &Path, +) -> PersistedWorkspace { + let created_at = Utc::now().naive_utc() - Duration::minutes(30); + PersistedWorkspace { + id: id.to_string(), + repository_id: repository_id.to_string(), + display_name: branch.to_string(), + branch: branch.to_string(), + worktree_path: worktree_path + .to_str() + .expect("temporary worktree path should be valid UTF-8") + .to_string(), + created_at, + last_opened_at: created_at, + } +} + +fn initialization_error( + app: &mut App, + repositories: Vec, + workspaces: Vec, +) -> ProjectOrganizationError { + let (sender, receiver) = mpsc::sync_channel(1); + app.add_model(move |ctx| { + match ProjectOrganizationModel::try_new( + repositories, + workspaces, + RepositoryPersistence::new(None), + ctx, + ) { + Ok(_) => panic!("project organization initialization should fail"), + Err(error) => { + sender + .send(error) + .expect("initialization error should be captured"); + ProjectOrganizationModel::try_new( + vec![], + vec![], + RepositoryPersistence::new(None), + ctx, + ) + .expect("empty project organization model should initialize") + } + } + }); + receiver + .recv() + .expect("project organization initialization should return an error") +} + +fn repository_workspace( + id: RepositoryWorkspaceId, + repository_id: RepositoryId, + branch: &str, + worktree_path: &Path, +) -> RepositoryWorkspace { + let created_at = Utc::now().naive_utc() - Duration::minutes(30); + RepositoryWorkspace { + id, + repository_id, + display_name: branch.to_string(), + branch: branch.to_string(), + worktree_path: worktree_path.to_path_buf(), + created_at, + last_opened_at: created_at, + } +} + +fn repository(id: RepositoryId, path: &Path) -> Repository { + let created_at = Utc::now().naive_utc() - Duration::hours(1); + Repository { + id, + display_name: "repository".to_string(), + path: path.to_path_buf(), + remote_url: None, + source: RepositorySource::Local, + created_at, + last_opened_at: created_at, + } +} + +fn assert_persistence_failure(error: ProjectOrganizationError, operation: &'static str) { + match error { + ProjectOrganizationError::Persistence { + operation: actual_operation, + details, + } => { + assert_eq!(actual_operation, operation); + assert!(details.contains("injected failure")); + } + error => panic!("expected persistence error, got {error:?}"), + } +} + +#[test] +fn add_local_repository_rejects_duplicate_canonical_path() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let alias_path = repository_path.join("..").join("repository"); + let canonical_path = + dunce::canonicalize(&repository_path).expect("repository path should canonicalize"); + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("first repository should be added"); + let error = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&alias_path, ctx) + }) + .expect_err("canonical duplicate should be rejected"); + + assert!(matches!( + error, + ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path: existing_path, + } if existing_repository_id == repository_id && existing_path == canonical_path + )); + }); +} + +#[test] +fn add_local_repository_with_remote_persists_remote_url() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let (model, _operations) = create_acknowledged_model(&mut app, vec![], vec![]); + + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository_with_remote( + &repository_path, + "https://example.com/zap.git".to_string(), + ctx, + ) + }) + .unwrap(); + + assert_eq!( + model.read(&app, |model, _| { + model + .repository(repository_id) + .and_then(|repository| repository.remote_url.as_deref()) + .map(str::to_string) + }), + Some("https://example.com/zap.git".to_string()) + ); + }); +} + +#[test] +fn add_repository_rejects_ambiguous_recovered_aliases() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let target = tempdir.path().join("repository"); + let first_alias = tempdir.path().join("first").join("..").join("repository"); + let second_alias = tempdir.path().join("second").join("..").join("repository"); + let first_id = RepositoryId::from(Uuid::from_u128(1)); + let second_id = RepositoryId::from(Uuid::from_u128(2)); + let (model, _operations) = create_acknowledged_model( + &mut app, + vec![ + persisted_repository(first_id, &first_alias), + persisted_repository(second_id, &second_alias), + ], + vec![], + ); + std::fs::create_dir(tempdir.path().join("first")).unwrap(); + std::fs::create_dir(tempdir.path().join("second")).unwrap(); + std::fs::create_dir(&target).unwrap(); + + let error = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&target, ctx) + }) + .unwrap_err(); + + assert!(matches!( + error, + ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path, + repository_ids, + } if canonical_path == dunce::canonicalize(&target).unwrap() + && repository_ids == vec![first_id, second_id] + )); + }); +} + +#[test] +fn insert_repository_rejects_ambiguous_recovered_aliases() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let target = tempdir.path().join("repository"); + let first_alias = tempdir.path().join("first").join("..").join("repository"); + let second_alias = tempdir.path().join("second").join("..").join("repository"); + let first_id = RepositoryId::from(Uuid::from_u128(1)); + let second_id = RepositoryId::from(Uuid::from_u128(2)); + let (model, _operations) = create_acknowledged_model( + &mut app, + vec![ + persisted_repository(first_id, &first_alias), + persisted_repository(second_id, &second_alias), + ], + vec![], + ); + std::fs::create_dir(tempdir.path().join("first")).unwrap(); + std::fs::create_dir(tempdir.path().join("second")).unwrap(); + std::fs::create_dir(&target).unwrap(); + + let error = model + .update(&mut app, |model, ctx| { + model.insert_repository( + repository(RepositoryId::from(Uuid::from_u128(3)), &target), + ctx, + ) + }) + .unwrap_err(); + + assert!(matches!( + error, + ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path, + repository_ids, + } if canonical_path == dunce::canonicalize(&target).unwrap() + && repository_ids == vec![first_id, second_id] + )); + }); +} + +#[test] +fn update_repository_rejects_ambiguous_recovered_aliases_excluding_itself() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let target = tempdir.path().join("repository"); + let first_alias = tempdir.path().join("first").join("..").join("repository"); + let second_alias = tempdir.path().join("second").join("..").join("repository"); + let updated_alias = tempdir.path().join("updated").join("..").join("repository"); + let first_id = RepositoryId::from(Uuid::from_u128(1)); + let second_id = RepositoryId::from(Uuid::from_u128(2)); + let updated_id = RepositoryId::from(Uuid::from_u128(3)); + let (model, _operations) = create_acknowledged_model( + &mut app, + vec![ + persisted_repository(first_id, &first_alias), + persisted_repository(second_id, &second_alias), + persisted_repository(updated_id, &updated_alias), + ], + vec![], + ); + std::fs::create_dir(tempdir.path().join("first")).unwrap(); + std::fs::create_dir(tempdir.path().join("second")).unwrap(); + std::fs::create_dir(tempdir.path().join("updated")).unwrap(); + std::fs::create_dir(&target).unwrap(); + let mut updated = model.read(&app, |model, _| { + model + .repository(updated_id) + .expect("updated repository should exist") + .clone() + }); + updated.path = target.clone(); + + let error = model + .update(&mut app, |model, ctx| model.update_repository(updated, ctx)) + .unwrap_err(); + + assert!(matches!( + error, + ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path, + repository_ids, + } if canonical_path == dunce::canonicalize(&target).unwrap() + && repository_ids == vec![first_id, second_id] + )); + }); +} + +#[test] +fn add_local_repository_commits_memory_after_persistence_succeeds() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let canonical_path = dunce::canonicalize(&repository_path).unwrap(); + let phantom_id = RepositoryId::from(Uuid::from_u128(1)); + let (model, operations) = create_acknowledged_model(&mut app, vec![], vec![]); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + model.update(&mut app, |model, _| { + model + .repository_ids_by_path + .insert(canonical_path.clone(), phantom_id); + }); + + let result = model.update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }); + let operations = operations.operations.try_iter().collect::>(); + + assert_eq!(operations.len(), 1); + let repository_id = result.expect("memory commit should be infallible after persistence"); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepository { repository } + if repository.id == repository_id.to_string() + )); + assert!(model.read(&app, |model, _| model.repository(repository_id).is_some())); + assert_eq!( + *emitted_events.lock().unwrap(), + vec![ProjectOrganizationEvent::RepositoryAdded { repository_id }] + ); + }); +} + +#[test] +fn adding_repository_with_initial_workspace_commits_both_rows_after_persistence() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let canonical_path = dunce::canonicalize(&repository_path).unwrap(); + let (model, operations) = create_acknowledged_model(&mut app, vec![], vec![]); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + + let (repository_id, workspace_id) = model + .update(&mut app, |model, ctx| { + model.add_local_repository_with_initial_workspace( + &repository_path, + None, + "main", + ctx, + ) + }) + .expect("repository and local workspace should be added"); + let operations = operations.operations.try_iter().collect::>(); + + assert_eq!(operations.len(), 1); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepositoryWithWorkspace { + repository, + workspace, + } if repository.id == repository_id.to_string() + && workspace.id == workspace_id.to_string() + && workspace.display_name == "local" + && workspace.branch == "main" + && workspace.worktree_path == canonical_path.to_string_lossy() + )); + model.read(&app, |model, _| { + let repository = model + .repository(repository_id) + .expect("repository should exist"); + assert_eq!(repository.path, canonical_path); + let workspace = model + .workspace(workspace_id) + .expect("workspace should exist"); + assert_eq!(workspace.display_name, "local"); + assert_eq!(workspace.branch, "main"); + assert_eq!(workspace.worktree_path, canonical_path); + }); + assert_eq!( + *emitted_events.lock().unwrap(), + vec![ + ProjectOrganizationEvent::RepositoryAdded { repository_id }, + ProjectOrganizationEvent::WorkspaceAdded { workspace_id }, + ] + ); + }); +} + +#[test] +fn repository_with_initial_workspace_does_not_change_memory_when_persistence_fails() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let canonical_path = dunce::canonicalize(&repository_path).unwrap(); + let (persistence, harness) = + acknowledged_persistence(Err(RepositoryPersistenceError::Database { + details: "injected failure".to_string(), + })); + let model = create_model(&mut app, vec![], vec![], persistence); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + + let error = model + .update(&mut app, |model, ctx| { + model.add_local_repository_with_initial_workspace( + &repository_path, + None, + "main", + ctx, + ) + }) + .expect_err("persistence failure should be returned"); + + assert_persistence_failure(error, "repository addition with initial workspace"); + model.read(&app, |model, _| { + assert_eq!(model.repositories().count(), 0); + assert_eq!(model.workspaces().count(), 0); + assert!(!model.repository_ids_by_path.contains_key(&canonical_path)); + }); + assert!(emitted_events.lock().unwrap().is_empty()); + let operations = harness.operations.try_iter().collect::>(); + assert_eq!(operations.len(), 1); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepositoryWithWorkspace { + repository, + workspace, + } if repository.path == canonical_path.to_string_lossy() + && workspace.worktree_path == canonical_path.to_string_lossy() + )); + }); +} + +#[test] +fn insert_repository_commits_memory_after_persistence_succeeds() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let canonical_path = dunce::canonicalize(&repository_path).unwrap(); + let repository_id = RepositoryId::from(Uuid::from_u128(1)); + let phantom_id = RepositoryId::from(Uuid::from_u128(2)); + let (model, operations) = create_acknowledged_model(&mut app, vec![], vec![]); + model.update(&mut app, |model, _| { + model + .repository_ids_by_path + .insert(canonical_path, phantom_id); + }); + + let result = model.update(&mut app, |model, ctx| { + model.insert_repository(repository(repository_id, &repository_path), ctx) + }); + let operations = operations.operations.try_iter().collect::>(); + + assert_eq!(operations.len(), 1); + result.expect("memory commit should be infallible after persistence"); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepository { repository } + if repository.id == repository_id.to_string() + )); + assert!(model.read(&app, |model, _| model.repository(repository_id).is_some())); + }); +} + +#[test] +fn repository_add_does_not_change_memory_when_persistence_fails() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let canonical_path = dunce::canonicalize(&repository_path).unwrap(); + let (persistence, harness) = + acknowledged_persistence(Err(RepositoryPersistenceError::Database { + details: "injected failure".to_string(), + })); + let model = create_model(&mut app, vec![], vec![], persistence); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + + let error = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .unwrap_err(); + + assert_persistence_failure(error, "repository addition"); + model.read(&app, |model, _| { + assert_eq!(model.repositories().count(), 0); + assert!(!model.repository_ids_by_path.contains_key(&canonical_path)); + }); + assert!(emitted_events.lock().unwrap().is_empty()); + let operations = harness.operations.try_iter().collect::>(); + assert_eq!(operations.len(), 1); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepository { repository } + if repository.path == canonical_path.to_string_lossy() + )); + }); +} + +#[test] +fn repository_update_does_not_change_indexes_when_persistence_fails() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let old_path = tempdir.path().join("old-repository"); + let new_path = tempdir.path().join("new-repository"); + std::fs::create_dir(&old_path).unwrap(); + std::fs::create_dir(&new_path).unwrap(); + let canonical_old_path = dunce::canonicalize(&old_path).unwrap(); + let canonical_new_path = dunce::canonicalize(&new_path).unwrap(); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let persisted_repository = persisted_repository(repository_id, &old_path); + let (persistence, harness) = + acknowledged_persistence(Err(RepositoryPersistenceError::Database { + details: "injected failure".to_string(), + })); + let model = create_model(&mut app, vec![persisted_repository], vec![], persistence); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + let mut updated_repository = model.read(&app, |model, _| { + model.repository(repository_id).unwrap().clone() + }); + updated_repository.display_name = "updated".to_string(); + updated_repository.path = new_path; + + let error = model + .update(&mut app, |model, ctx| { + model.update_repository(updated_repository, ctx) + }) + .unwrap_err(); + + assert_persistence_failure(error, "repository update"); + model.read(&app, |model, _| { + let repository = model.repository(repository_id).unwrap(); + assert_eq!(repository.display_name, "repository"); + assert_eq!(repository.path, canonical_old_path); + assert_eq!( + model.repository_ids_by_path.get(&canonical_old_path), + Some(&repository_id) + ); + assert!(!model + .repository_ids_by_path + .contains_key(&canonical_new_path)); + }); + assert!(emitted_events.lock().unwrap().is_empty()); + let operations = harness.operations.try_iter().collect::>(); + assert_eq!(operations.len(), 1); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepository { repository } + if repository.path == canonical_new_path.to_string_lossy() + )); + }); +} + +#[test] +fn repository_delete_does_not_change_indexes_when_persistence_fails() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let canonical_path = dunce::canonicalize(&repository_path).unwrap(); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let (persistence, harness) = + acknowledged_persistence(Err(RepositoryPersistenceError::Database { + details: "injected failure".to_string(), + })); + let model = create_model( + &mut app, + vec![persisted_repository(repository_id, &repository_path)], + vec![], + persistence, + ); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + + let error = model + .update(&mut app, |model, ctx| { + model.remove_repository(repository_id, ctx) + }) + .unwrap_err(); + + assert_persistence_failure(error, "repository removal"); + model.read(&app, |model, _| { + assert!(model.repository(repository_id).is_some()); + assert_eq!( + model.repository_ids_by_path.get(&canonical_path), + Some(&repository_id) + ); + }); + assert!(emitted_events.lock().unwrap().is_empty()); + let operations = harness.operations.try_iter().collect::>(); + assert_eq!(operations.len(), 1); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::DeleteRepository { repository_id: id } + if id == &repository_id.to_string() + )); + }); +} + +#[test] +fn workspace_insert_does_not_change_memory_when_persistence_fails() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + let worktree_path = tempdir.path().join("worktree"); + std::fs::create_dir(&repository_path).unwrap(); + std::fs::create_dir(&worktree_path).unwrap(); + let canonical_worktree_path = dunce::canonicalize(&worktree_path).unwrap(); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + let (persistence, harness) = + acknowledged_persistence(Err(RepositoryPersistenceError::Database { + details: "injected failure".to_string(), + })); + let model = create_model( + &mut app, + vec![persisted_repository(repository_id, &repository_path)], + vec![], + persistence, + ); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + + let error = model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + workspace_id, + repository_id, + "feature/test", + &worktree_path, + ), + ctx, + ) + }) + .unwrap_err(); + + assert_persistence_failure(error, "repository workspace insertion"); + model.read(&app, |model, _| { + assert!(model.workspace(workspace_id).is_none()); + assert!(!model + .workspace_ids_by_repository_branch + .contains_key(&(repository_id, "feature/test".to_string()))); + assert!(!model + .workspace_ids_by_path + .contains_key(&canonical_worktree_path)); + }); + assert!(emitted_events.lock().unwrap().is_empty()); + let operations = harness.operations.try_iter().collect::>(); + assert_eq!(operations.len(), 1); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepositoryWorkspace { workspace } + if workspace.id == workspace_id.to_string() + )); + }); +} + +#[test] +fn workspace_update_does_not_change_indexes_when_persistence_fails() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + let old_path = tempdir.path().join("old-worktree"); + let new_path = tempdir.path().join("new-worktree"); + for path in [&repository_path, &old_path, &new_path] { + std::fs::create_dir(path).unwrap(); + } + let canonical_old_path = dunce::canonicalize(&old_path).unwrap(); + let canonical_new_path = dunce::canonicalize(&new_path).unwrap(); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + let (persistence, harness) = + acknowledged_persistence(Err(RepositoryPersistenceError::Database { + details: "injected failure".to_string(), + })); + let model = create_model( + &mut app, + vec![persisted_repository(repository_id, &repository_path)], + vec![persisted_workspace( + workspace_id, + repository_id, + "feature/old", + &old_path, + )], + persistence, + ); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + let mut updated_workspace = model.read(&app, |model, _| { + model.workspace(workspace_id).unwrap().clone() + }); + updated_workspace.display_name = "updated".to_string(); + updated_workspace.branch = "feature/new".to_string(); + updated_workspace.worktree_path = new_path; + + let error = model + .update(&mut app, |model, ctx| { + model.update_workspace(updated_workspace, ctx) + }) + .unwrap_err(); + + assert_persistence_failure(error, "repository workspace update"); + model.read(&app, |model, _| { + let workspace = model.workspace(workspace_id).unwrap(); + assert_eq!(workspace.display_name, "feature/old"); + assert_eq!(workspace.branch, "feature/old"); + assert_eq!(workspace.worktree_path, canonical_old_path); + assert_eq!( + model + .workspace_ids_by_repository_branch + .get(&(repository_id, "feature/old".to_string())), + Some(&workspace_id) + ); + assert!(!model + .workspace_ids_by_repository_branch + .contains_key(&(repository_id, "feature/new".to_string()))); + assert_eq!( + model.workspace_ids_by_path.get(&canonical_old_path), + Some(&workspace_id) + ); + assert!(!model + .workspace_ids_by_path + .contains_key(&canonical_new_path)); + }); + assert!(emitted_events.lock().unwrap().is_empty()); + let operations = harness.operations.try_iter().collect::>(); + assert_eq!(operations.len(), 1); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepositoryWorkspace { workspace } + if workspace.branch == "feature/new" + && workspace.worktree_path == canonical_new_path.to_string_lossy() + )); + }); +} + +#[test] +fn workspace_delete_does_not_change_indexes_when_persistence_fails() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + let worktree_path = tempdir.path().join("worktree"); + std::fs::create_dir(&repository_path).unwrap(); + std::fs::create_dir(&worktree_path).unwrap(); + let canonical_worktree_path = dunce::canonicalize(&worktree_path).unwrap(); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + let (persistence, harness) = + acknowledged_persistence(Err(RepositoryPersistenceError::Database { + details: "injected failure".to_string(), + })); + let model = create_model( + &mut app, + vec![persisted_repository(repository_id, &repository_path)], + vec![persisted_workspace( + workspace_id, + repository_id, + "feature/test", + &worktree_path, + )], + persistence, + ); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + + let error = model + .update(&mut app, |model, ctx| { + model.remove_workspace(workspace_id, ctx) + }) + .unwrap_err(); + + assert_persistence_failure(error, "repository workspace removal"); + model.read(&app, |model, _| { + assert!(model.workspace(workspace_id).is_some()); + assert_eq!( + model + .workspace_ids_by_repository_branch + .get(&(repository_id, "feature/test".to_string())), + Some(&workspace_id) + ); + assert_eq!( + model.workspace_ids_by_path.get(&canonical_worktree_path), + Some(&workspace_id) + ); + }); + assert!(emitted_events.lock().unwrap().is_empty()); + let operations = harness.operations.try_iter().collect::>(); + assert_eq!(operations.len(), 1); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::DeleteRepositoryWorkspace { workspace_id: id } + if id == &workspace_id.to_string() + )); + }); +} + +#[test] +fn unavailable_persistence_does_not_change_memory() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let canonical_path = dunce::canonicalize(&repository_path).unwrap(); + let model = create_model(&mut app, vec![], vec![], RepositoryPersistence::new(None)); + let (emitted_events, _event_probe) = capture_project_organization_events(&mut app, &model); + + let error = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .unwrap_err(); + + match error { + ProjectOrganizationError::Persistence { operation, details } => { + assert_eq!(operation, "repository addition"); + assert!(details.contains("unavailable")); + } + error => panic!("expected persistence error, got {error:?}"), + } + model.read(&app, |model, _| { + assert_eq!(model.repositories().count(), 0); + assert!(!model.repository_ids_by_path.contains_key(&canonical_path)); + }); + assert!(emitted_events.lock().unwrap().is_empty()); + }); +} + +#[test] +fn insert_workspace_rejects_duplicate_repository_branch() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + let first_worktree = tempdir.path().join("first-worktree"); + let second_worktree = tempdir.path().join("second-worktree"); + for path in [&repository_path, &first_worktree, &second_worktree] { + std::fs::create_dir(path).expect("test directory should be created"); + } + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("repository should be added"); + let first_workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + first_workspace_id, + repository_id, + "feature/branch", + &first_worktree, + ), + ctx, + ) + }) + .expect("first workspace should be inserted"); + + let error = model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id, + "feature/branch", + &second_worktree, + ), + ctx, + ) + }) + .expect_err("duplicate branch should be rejected"); + + assert!(matches!( + error, + ProjectOrganizationError::WorkspaceBranchAlreadyExists { + repository_id: duplicate_repository_id, + branch, + existing_workspace_id, + } if duplicate_repository_id == repository_id + && branch == "feature/branch" + && existing_workspace_id == first_workspace_id + )); + }); +} + +#[test] +fn remove_repository_is_blocked_while_workspace_exists() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + let worktree_path = tempdir.path().join("worktree"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + std::fs::create_dir(&worktree_path).expect("worktree directory should be created"); + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("repository should be added"); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace(workspace_id, repository_id, "main", &worktree_path), + ctx, + ) + }) + .expect("workspace should be inserted"); + + let error = model + .update(&mut app, |model, ctx| { + model.remove_repository(repository_id, ctx) + }) + .expect_err("repository with workspaces should not be removed"); + + assert!(matches!( + error, + ProjectOrganizationError::RepositoryHasWorkspaces { + repository_id: blocked_repository_id, + } if blocked_repository_id == repository_id + )); + }); +} + +#[test] +fn remove_workspace_allows_reusing_branch_and_worktree_path() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + let worktree_path = tempdir.path().join("worktree"); + for path in [&repository_path, &worktree_path] { + std::fs::create_dir(path).expect("test directory should be created"); + } + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("repository should be added"); + let first_workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + first_workspace_id, + repository_id, + "feature/reusable", + &worktree_path, + ), + ctx, + ) + }) + .expect("workspace should be inserted"); + + model + .update(&mut app, |model, ctx| { + model.remove_workspace(first_workspace_id, ctx) + }) + .expect("workspace should be removed"); + let replacement_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + replacement_id, + repository_id, + "feature/reusable", + &worktree_path, + ), + ctx, + ) + }) + .expect("removed workspace indexes should be reusable"); + + assert!(model.read(&app, |model, _| model.workspace(replacement_id).is_some())); + }); +} + +#[test] +fn remove_repository_allows_reusing_path() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let first_repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("repository should be added"); + + model + .update(&mut app, |model, ctx| { + model.remove_repository(first_repository_id, ctx) + }) + .expect("repository should be removed"); + let replacement_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("removed repository path should be reusable"); + + assert_ne!(replacement_id, first_repository_id); + }); +} + +#[test] +fn update_repository_replaces_path_index_and_rejects_new_duplicate() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let old_path = tempdir.path().join("old-repository"); + let new_path = tempdir.path().join("new-repository"); + for path in [&old_path, &new_path] { + std::fs::create_dir(path).expect("repository directory should be created"); + } + let canonical_new_path = + dunce::canonicalize(&new_path).expect("new repository path should canonicalize"); + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&old_path, ctx) + }) + .expect("repository should be added"); + let mut repository = model.read(&app, |model, _| { + model + .repository(repository_id) + .expect("repository should exist") + .clone() + }); + repository.path = new_path.clone(); + + model + .update(&mut app, |model, ctx| { + model.update_repository(repository, ctx) + }) + .expect("repository path should be updated"); + model + .update(&mut app, |model, ctx| { + model.add_local_repository(&old_path, ctx) + }) + .expect("old repository path index should be removed"); + let error = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&new_path, ctx) + }) + .expect_err("new repository path should remain indexed"); + + assert!(matches!( + error, + ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path, + } if existing_repository_id == repository_id && canonical_path == canonical_new_path + )); + }); +} + +#[test] +fn update_workspace_replaces_branch_and_path_indexes_and_rejects_new_duplicates() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + let old_path = tempdir.path().join("old-worktree"); + let new_path = tempdir.path().join("new-worktree"); + let branch_conflict_path = tempdir.path().join("branch-conflict-worktree"); + for path in [ + &repository_path, + &old_path, + &new_path, + &branch_conflict_path, + ] { + std::fs::create_dir(path).expect("test directory should be created"); + } + let canonical_new_path = + dunce::canonicalize(&new_path).expect("new worktree path should canonicalize"); + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("repository should be added"); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace(workspace_id, repository_id, "feature/old", &old_path), + ctx, + ) + }) + .expect("workspace should be inserted"); + let mut workspace = model.read(&app, |model, _| { + model + .workspace(workspace_id) + .expect("workspace should exist") + .clone() + }); + workspace.branch = "feature/new".to_string(); + workspace.worktree_path = new_path.clone(); + + model + .update(&mut app, |model, ctx| { + model.update_workspace(workspace, ctx) + }) + .expect("workspace branch and path should be updated"); + model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id, + "feature/old", + &old_path, + ), + ctx, + ) + }) + .expect("old workspace indexes should be removed"); + let branch_error = model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id, + "feature/new", + &branch_conflict_path, + ), + ctx, + ) + }) + .expect_err("new branch should remain indexed"); + let path_error = model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id, + "feature/path-conflict", + &new_path, + ), + ctx, + ) + }) + .expect_err("new worktree path should remain indexed"); + + assert!(matches!( + branch_error, + ProjectOrganizationError::WorkspaceBranchAlreadyExists { + repository_id: duplicate_repository_id, + branch, + existing_workspace_id, + } if duplicate_repository_id == repository_id + && branch == "feature/new" + && existing_workspace_id == workspace_id + )); + assert!(matches!( + path_error, + ProjectOrganizationError::WorkspacePathAlreadyExists { + existing_workspace_id, + canonical_path, + } if existing_workspace_id == workspace_id && canonical_path == canonical_new_path + )); + }); +} + +#[test] +fn insert_repository_rejects_duplicate_id() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let first_path = tempdir.path().join("first-repository"); + let second_path = tempdir.path().join("second-repository"); + for path in [&first_path, &second_path] { + std::fs::create_dir(path).expect("repository directory should be created"); + } + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&first_path, ctx) + }) + .expect("repository should be added"); + let mut duplicate = model.read(&app, |model, _| { + model + .repository(repository_id) + .expect("repository should exist") + .clone() + }); + duplicate.path = second_path; + + let error = model + .update(&mut app, |model, ctx| { + model.insert_repository(duplicate, ctx) + }) + .expect_err("duplicate repository ID should be rejected"); + + assert!(matches!( + error, + ProjectOrganizationError::RepositoryIdAlreadyExists { + repository_id: duplicate_id, + } if duplicate_id == repository_id + )); + }); +} + +#[test] +fn insert_workspace_rejects_duplicate_id() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + let first_path = tempdir.path().join("first-worktree"); + let second_path = tempdir.path().join("second-worktree"); + for path in [&repository_path, &first_path, &second_path] { + std::fs::create_dir(path).expect("test directory should be created"); + } + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("repository should be added"); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace(workspace_id, repository_id, "feature/first", &first_path), + ctx, + ) + }) + .expect("workspace should be inserted"); + + let error = model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + workspace_id, + repository_id, + "feature/second", + &second_path, + ), + ctx, + ) + }) + .expect_err("duplicate workspace ID should be rejected"); + + assert!(matches!( + error, + ProjectOrganizationError::WorkspaceIdAlreadyExists { + workspace_id: duplicate_id, + } if duplicate_id == workspace_id + )); + }); +} + +#[test] +fn insert_workspace_rejects_orphan_repository() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let worktree_path = tempdir.path().join("worktree"); + std::fs::create_dir(&worktree_path).expect("worktree directory should be created"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + + let error = model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id, + "feature/orphan", + &worktree_path, + ), + ctx, + ) + }) + .expect_err("orphan workspace should be rejected"); + + assert!(matches!( + error, + ProjectOrganizationError::RepositoryNotFound { + repository_id: missing_repository_id, + } if missing_repository_id == repository_id + )); + }); +} + +#[test] +fn insert_workspace_commits_memory_after_persistence_succeeds() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + let worktree_path = tempdir.path().join("worktree"); + std::fs::create_dir(&repository_path).unwrap(); + std::fs::create_dir(&worktree_path).unwrap(); + let canonical_worktree_path = dunce::canonicalize(&worktree_path).unwrap(); + let workspace_id = RepositoryWorkspaceId::from(Uuid::from_u128(1)); + let phantom_id = RepositoryWorkspaceId::from(Uuid::from_u128(2)); + let (model, operations) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .unwrap(); + operations.operations.recv().unwrap(); + model.update(&mut app, |model, _| { + model + .workspace_ids_by_path + .insert(canonical_worktree_path, phantom_id); + }); + + let result = model.update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace(workspace_id, repository_id, "feature/test", &worktree_path), + ctx, + ) + }); + let operations = operations.operations.try_iter().collect::>(); + + assert_eq!(operations.len(), 1); + result.expect("memory commit should be infallible after persistence"); + assert!(matches!( + &operations[0], + RepositoryPersistenceOperation::UpsertRepositoryWorkspace { workspace } + if workspace.id == workspace_id.to_string() + )); + assert!(model.read(&app, |model, _| model.workspace(workspace_id).is_some())); + }); +} + +#[test] +fn rename_repository_changes_only_display_name() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let canonical_path = + dunce::canonicalize(&repository_path).expect("repository path should canonicalize"); + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("repository should be added"); + + model + .update(&mut app, |model, ctx| { + model.rename_repository(repository_id, "Renamed repository".to_string(), ctx) + }) + .expect("repository should be renamed"); + let repository = model.read(&app, |model, _| { + model + .repository(repository_id) + .expect("repository should exist") + .clone() + }); + + assert_eq!(repository.display_name, "Renamed repository"); + assert_eq!(repository.path, canonical_path); + assert_eq!(repository.source, RepositorySource::Local); + }); +} + +#[test] +fn rename_workspace_changes_only_display_name() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + let worktree_path = tempdir.path().join("worktree"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + std::fs::create_dir(&worktree_path).expect("worktree directory should be created"); + let canonical_worktree_path = + dunce::canonicalize(&worktree_path).expect("worktree path should canonicalize"); + let (model, _events) = create_acknowledged_model(&mut app, vec![], vec![]); + let repository_id = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .expect("repository should be added"); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + workspace_id, + repository_id, + "feature/branch", + &worktree_path, + ), + ctx, + ) + }) + .expect("workspace should be inserted"); + + model + .update(&mut app, |model, ctx| { + model.rename_workspace(workspace_id, "Renamed workspace".to_string(), ctx) + }) + .expect("workspace should be renamed"); + let workspace = model.read(&app, |model, _| { + model + .workspace(workspace_id) + .expect("workspace should exist") + .clone() + }); + + assert_eq!(workspace.display_name, "Renamed workspace"); + assert_eq!(workspace.branch, "feature/branch"); + assert_eq!(workspace.worktree_path, canonical_worktree_path); + }); +} + +#[test] +fn touch_repository_path_adds_repository_and_persistence_event() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let canonical_path = + dunce::canonicalize(&repository_path).expect("repository path should canonicalize"); + let (model, operations) = create_acknowledged_model(&mut app, vec![], vec![]); + + let repository_id = model + .update(&mut app, |model, ctx| { + model.touch_repository_path(&repository_path, ctx) + }) + .expect("repository path should be touched"); + let operation = operations + .operations + .recv() + .expect("persistence operation should be sent"); + + assert!(matches!( + operation, + RepositoryPersistenceOperation::UpsertRepository { repository } + if repository.id == repository_id.to_string() + && repository.path == canonical_path.to_string_lossy() + )); + }); +} + +#[test] +fn touch_repository_path_updates_existing_timestamp_and_persistence_event() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let persisted_repository = persisted_repository(repository_id, &repository_path); + let previous_last_opened_at = persisted_repository.last_opened_at; + let (model, operations) = + create_acknowledged_model(&mut app, vec![persisted_repository], vec![]); + + let touched_id = model + .update(&mut app, |model, ctx| { + model.touch_repository_path(&repository_path, ctx) + }) + .expect("repository path should be touched"); + let operation = operations + .operations + .recv() + .expect("persistence operation should be sent"); + + assert_eq!(touched_id, repository_id); + assert!(matches!( + operation, + RepositoryPersistenceOperation::UpsertRepository { repository } + if repository.id == repository_id.to_string() + && repository.last_opened_at > previous_last_opened_at + )); + }); +} + +#[test] +fn touch_repository_rejects_ambiguous_recovered_aliases() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let target = tempdir.path().join("repository"); + let first_alias = tempdir.path().join("first").join("..").join("repository"); + let second_alias = tempdir.path().join("second").join("..").join("repository"); + let first_id = RepositoryId::from(Uuid::from_u128(1)); + let second_id = RepositoryId::from(Uuid::from_u128(2)); + let (model, _operations) = create_acknowledged_model( + &mut app, + vec![ + persisted_repository(first_id, &first_alias), + persisted_repository(second_id, &second_alias), + ], + vec![], + ); + std::fs::create_dir(tempdir.path().join("first")).unwrap(); + std::fs::create_dir(tempdir.path().join("second")).unwrap(); + std::fs::create_dir(&target).unwrap(); + + let error = model + .update(&mut app, |model, ctx| { + model.touch_repository_path(&target, ctx) + }) + .unwrap_err(); + + assert!(matches!( + error, + ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path, + repository_ids, + } if canonical_path == dunce::canonicalize(&target).unwrap() + && repository_ids == vec![first_id, second_id] + )); + }); +} + +#[test] +fn touch_repository_migrates_unique_recovered_alias_to_canonical_path() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let target = tempdir.path().join("repository"); + let alias = tempdir.path().join("alias").join("..").join("repository"); + let repository_id = RepositoryId::from(Uuid::from_u128(1)); + let (model, operations) = create_acknowledged_model( + &mut app, + vec![persisted_repository(repository_id, &alias)], + vec![], + ); + std::fs::create_dir(tempdir.path().join("alias")).unwrap(); + std::fs::create_dir(&target).unwrap(); + let canonical_path = dunce::canonicalize(&target).unwrap(); + + let touched_id = model + .update(&mut app, |model, ctx| { + model.touch_repository_path(&target, ctx) + }) + .unwrap(); + let duplicate_error = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&canonical_path, ctx) + }) + .unwrap_err(); + let stored_path = model.read(&app, |model, _| { + model + .repository(repository_id) + .expect("repository should exist") + .path + .clone() + }); + + assert_eq!(touched_id, repository_id); + assert_eq!(stored_path, canonical_path); + assert_eq!(model.read(&app, |model, _| model.repositories().count()), 1); + assert_eq!(operations.operations.try_iter().count(), 1); + assert!(matches!( + duplicate_error, + ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path: duplicate_path, + } if existing_repository_id == repository_id && duplicate_path == canonical_path + )); + }); +} + +#[test] +fn insert_workspace_rejects_ambiguous_recovered_aliases() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let target = tempdir.path().join("worktree"); + let first_alias = tempdir.path().join("first").join("..").join("worktree"); + let second_alias = tempdir.path().join("second").join("..").join("worktree"); + let repository_id = RepositoryId::from(Uuid::from_u128(1)); + let first_id = RepositoryWorkspaceId::from(Uuid::from_u128(2)); + let second_id = RepositoryWorkspaceId::from(Uuid::from_u128(3)); + let (model, _operations) = create_acknowledged_model( + &mut app, + vec![persisted_repository(repository_id, &repository_path)], + vec![ + persisted_workspace(first_id, repository_id, "main", &first_alias), + persisted_workspace(second_id, repository_id, "feature/second", &second_alias), + ], + ); + std::fs::create_dir(tempdir.path().join("first")).unwrap(); + std::fs::create_dir(tempdir.path().join("second")).unwrap(); + std::fs::create_dir(&target).unwrap(); + + let error = model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + RepositoryWorkspaceId::from(Uuid::from_u128(4)), + repository_id, + "feature/new", + &target, + ), + ctx, + ) + }) + .unwrap_err(); + + assert!(matches!( + error, + ProjectOrganizationError::AmbiguousWorkspacePath { + canonical_path, + workspace_ids, + } if canonical_path == dunce::canonicalize(&target).unwrap() + && workspace_ids == vec![first_id, second_id] + )); + }); +} + +#[test] +fn update_workspace_rejects_ambiguous_recovered_aliases_excluding_itself() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let target = tempdir.path().join("worktree"); + let first_alias = tempdir.path().join("first").join("..").join("worktree"); + let second_alias = tempdir.path().join("second").join("..").join("worktree"); + let updated_alias = tempdir.path().join("updated").join("..").join("worktree"); + let repository_id = RepositoryId::from(Uuid::from_u128(1)); + let first_id = RepositoryWorkspaceId::from(Uuid::from_u128(2)); + let second_id = RepositoryWorkspaceId::from(Uuid::from_u128(3)); + let updated_id = RepositoryWorkspaceId::from(Uuid::from_u128(4)); + let (model, _operations) = create_acknowledged_model( + &mut app, + vec![persisted_repository(repository_id, &repository_path)], + vec![ + persisted_workspace(first_id, repository_id, "main", &first_alias), + persisted_workspace(second_id, repository_id, "feature/second", &second_alias), + persisted_workspace(updated_id, repository_id, "feature/updated", &updated_alias), + ], + ); + std::fs::create_dir(tempdir.path().join("first")).unwrap(); + std::fs::create_dir(tempdir.path().join("second")).unwrap(); + std::fs::create_dir(tempdir.path().join("updated")).unwrap(); + std::fs::create_dir(&target).unwrap(); + let mut workspace = model.read(&app, |model, _| { + model + .workspace(updated_id) + .expect("updated workspace should exist") + .clone() + }); + workspace.worktree_path = target.clone(); + + let error = model + .update(&mut app, |model, ctx| { + model.update_workspace(workspace, ctx) + }) + .unwrap_err(); + + assert!(matches!( + error, + ProjectOrganizationError::AmbiguousWorkspacePath { + canonical_path, + workspace_ids, + } if canonical_path == dunce::canonicalize(&target).unwrap() + && workspace_ids == vec![first_id, second_id] + )); + }); +} + +#[test] +fn persisted_repository_alias_is_normalized_and_rejected_as_duplicate() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let alias_path = repository_path.join("..").join("repository"); + let canonical_path = + dunce::canonicalize(&repository_path).expect("repository path should canonicalize"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let repository = persisted_repository(repository_id, &alias_path); + + let (model, _events) = create_acknowledged_model(&mut app, vec![repository], vec![]); + let loaded_path = model.read(&app, |model, _| { + model + .repository(repository_id) + .expect("persisted repository should be retained") + .path + .clone() + }); + let error = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&canonical_path, ctx) + }) + .expect_err("canonical duplicate should be rejected"); + + assert_eq!(loaded_path, canonical_path); + assert!(matches!( + error, + ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path: existing_path, + } if existing_repository_id == repository_id && existing_path == canonical_path + )); + }); +} + +#[test] +fn persisted_workspace_alias_is_normalized_and_rejected_as_duplicate() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + let worktree_path = tempdir.path().join("worktree"); + for path in [&repository_path, &worktree_path] { + std::fs::create_dir(path).expect("test directory should be created"); + } + let alias_path = worktree_path.join("..").join("worktree"); + let canonical_path = + dunce::canonicalize(&worktree_path).expect("worktree path should canonicalize"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + let repository = persisted_repository(repository_id, &repository_path); + let workspace = persisted_workspace(workspace_id, repository_id, "main", &alias_path); + + let (model, _events) = + create_acknowledged_model(&mut app, vec![repository], vec![workspace]); + let loaded_path = model.read(&app, |model, _| { + model + .workspace(workspace_id) + .expect("persisted workspace should be retained") + .worktree_path + .clone() + }); + let error = model + .update(&mut app, |model, ctx| { + model.insert_workspace( + repository_workspace( + RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id, + "feature/duplicate-path", + &canonical_path, + ), + ctx, + ) + }) + .expect_err("canonical worktree duplicate should be rejected"); + + assert_eq!(loaded_path, canonical_path); + assert!(matches!( + error, + ProjectOrganizationError::WorkspacePathAlreadyExists { + existing_workspace_id, + canonical_path: existing_path, + } if existing_workspace_id == workspace_id && existing_path == canonical_path + )); + }); +} + +#[test] +fn persisted_repository_alias_duplicate_fails_initialization() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let alias_path = repository_path.join("..").join("repository"); + let canonical_path = + dunce::canonicalize(&repository_path).expect("repository path should canonicalize"); + let first_id = RepositoryId::from(Uuid::new_v4()); + let second_id = RepositoryId::from(Uuid::new_v4()); + + let error = initialization_error( + &mut app, + vec![ + persisted_repository(first_id, &alias_path), + persisted_repository(second_id, &repository_path), + ], + vec![], + ); + + assert!(matches!( + error, + ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path: existing_path, + } if existing_repository_id == first_id && existing_path == canonical_path + )); + }); +} + +#[test] +fn persisted_workspace_alias_duplicate_fails_initialization() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + let worktree_path = tempdir.path().join("worktree"); + for path in [&repository_path, &worktree_path] { + std::fs::create_dir(path).expect("test directory should be created"); + } + let alias_path = worktree_path.join("..").join("worktree"); + let canonical_path = + dunce::canonicalize(&worktree_path).expect("worktree path should canonicalize"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let first_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + let second_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + let repository = persisted_repository(repository_id, &repository_path); + + let error = initialization_error( + &mut app, + vec![repository], + vec![ + persisted_workspace(first_id, repository_id, "main", &alias_path), + persisted_workspace( + second_id, + repository_id, + "feature/duplicate-path", + &worktree_path, + ), + ], + ); + + assert!(matches!( + error, + ProjectOrganizationError::WorkspacePathAlreadyExists { + existing_workspace_id, + canonical_path: existing_path, + } if existing_workspace_id == first_id && existing_path == canonical_path + )); + }); +} + +#[test] +fn persisted_repository_with_missing_path_is_loaded_unchanged() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let missing_path = tempdir.path().join("missing-repository"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let repository = persisted_repository(repository_id, &missing_path); + + let (model, _events) = create_acknowledged_model(&mut app, vec![repository], vec![]); + let loaded = model.read(&app, |model, _| { + model + .repository(repository_id) + .expect("persisted repository should be retained") + .clone() + }); + + assert_eq!(loaded.id, repository_id); + assert_eq!(loaded.source, RepositorySource::Local); + assert_eq!(loaded.path, missing_path); + }); +} + +#[test] +fn persisted_workspace_with_missing_path_is_loaded_unchanged() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let missing_worktree_path = tempdir.path().join("missing-worktree"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + let repository = persisted_repository(repository_id, &repository_path); + let workspace = persisted_workspace( + workspace_id, + repository_id, + "feature/missing-worktree", + &missing_worktree_path, + ); + + let (model, _events) = + create_acknowledged_model(&mut app, vec![repository], vec![workspace]); + let loaded = model.read(&app, |model, _| { + model + .workspace(workspace_id) + .expect("persisted workspace should be retained") + .clone() + }); + + assert_eq!(loaded.repository_id, repository_id); + assert_eq!(loaded.worktree_path, missing_worktree_path); + }); +} + +#[test] +fn persisted_repository_with_invalid_uuid_fails_initialization() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let mut repository = persisted_repository( + RepositoryId::from(Uuid::new_v4()), + &tempdir.path().join("repository"), + ); + repository.id = "not-a-repository-uuid".to_string(); + + let error = initialization_error(&mut app, vec![repository], vec![]); + + assert!(matches!( + error, + ProjectOrganizationError::InvalidPersistedRepositoryId { value, .. } + if value == "not-a-repository-uuid" + )); + }); +} + +#[test] +fn persisted_repository_with_invalid_source_fails_initialization() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let mut repository = persisted_repository( + RepositoryId::from(Uuid::new_v4()), + &tempdir.path().join("repository"), + ); + repository.source = "remote".to_string(); + + let error = initialization_error(&mut app, vec![repository], vec![]); + + assert!(matches!( + error, + ProjectOrganizationError::InvalidPersistedRepositorySource { value, .. } + if value == "remote" + )); + }); +} + +#[test] +fn persisted_workspace_with_invalid_uuid_fails_initialization() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let repository = persisted_repository(repository_id, &repository_path); + let mut workspace = persisted_workspace( + RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id, + "main", + &tempdir.path().join("worktree"), + ); + workspace.id = "not-a-workspace-uuid".to_string(); + + let error = initialization_error(&mut app, vec![repository], vec![workspace]); + + assert!(matches!( + error, + ProjectOrganizationError::InvalidPersistedWorkspaceId { value, .. } + if value == "not-a-workspace-uuid" + )); + }); +} + +#[test] +fn persisted_workspace_with_invalid_repository_id_fails_initialization() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().expect("temporary directory should be created"); + let repository_id = RepositoryId::from(Uuid::new_v4()); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let repository = persisted_repository(repository_id, &repository_path); + let mut workspace = persisted_workspace( + RepositoryWorkspaceId::from(Uuid::new_v4()), + repository_id, + "main", + &tempdir.path().join("worktree"), + ); + workspace.repository_id = "not-a-repository-uuid".to_string(); + + let error = initialization_error(&mut app, vec![repository], vec![workspace]); + + assert!(matches!( + error, + ProjectOrganizationError::InvalidPersistedWorkspaceRepositoryId { value, .. } + if value == "not-a-repository-uuid" + )); + }); +} diff --git a/app/src/project_organization/view/create_workspace_modal.rs b/app/src/project_organization/view/create_workspace_modal.rs new file mode 100644 index 00000000000..7e50a892749 --- /dev/null +++ b/app/src/project_organization/view/create_workspace_modal.rs @@ -0,0 +1,1239 @@ +use std::{collections::HashMap, path::PathBuf}; + +use warpui::{ + elements::{ + ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, + MainAxisAlignment, MainAxisSize, ParentElement, Text, + }, + AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, +}; + +use crate::project_organization::{ + domain::{RepositoryId, RepositoryWorkspaceId}, + git::{ + existing_worktree_options, is_primary_worktree_path, workspace_dir_name, BranchRef, + ExistingWorktreeOption, WorktreeInfo, + }, +}; +use crate::{ + appearance::Appearance, + editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions}, + view_components::action_button::{ + ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme, + }, + view_components::{DropdownItem, FilterableDropdown}, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum CreateWorkspaceMode { + #[default] + RemoteBranch, + ExistingLocalBranch, + ExistingWorktree, +} + +fn submit_is_disabled( + mode: CreateWorkspaceMode, + has_remote_fetch_error: bool, + has_existing_worktree_fetch_error: bool, + has_existing_worktree_selection: bool, +) -> bool { + match mode { + CreateWorkspaceMode::RemoteBranch => has_remote_fetch_error, + CreateWorkspaceMode::ExistingLocalBranch => false, + CreateWorkspaceMode::ExistingWorktree => { + has_existing_worktree_fetch_error || !has_existing_worktree_selection + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CreateWorkspaceForm { + mode: CreateWorkspaceMode, + remote_ref: Option, + local_branch: Option, + existing_worktree_branch: Option, + new_branch: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateWorkspaceRequest { + pub repository_id: RepositoryId, + pub workspace_id: RepositoryWorkspaceId, + pub display_name: String, + pub worktree_path: PathBuf, + pub source: CreateWorkspaceSource, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CreateWorkspaceSource { + RemoteBranch { + remote_ref: String, + new_branch: String, + }, + ExistingLocalBranch { + local_branch: String, + }, + ExistingWorktree { + local_branch: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoteBranchOption { + full_ref: String, + remote: String, + branch_name: String, + display_label: String, +} + +impl RemoteBranchOption { + fn new( + full_ref: impl Into, + remote: impl Into, + branch_name: impl Into, + display_label: impl Into, + ) -> Self { + Self { + full_ref: full_ref.into(), + remote: remote.into(), + branch_name: branch_name.into(), + display_label: display_label.into(), + } + } +} + +pub fn branch_ref_options( + refs: impl IntoIterator, +) -> (Vec, Vec) { + let refs = refs.into_iter().collect::>(); + let mut remote_branch_counts = HashMap::::new(); + for branch_ref in &refs { + if let BranchRef::Remote { name, .. } = branch_ref { + *remote_branch_counts.entry(name.clone()).or_default() += 1; + } + } + + let mut remote_options = Vec::new(); + let mut local_branches = Vec::new(); + for branch_ref in refs { + match branch_ref { + BranchRef::Remote { + remote, + name, + full_ref, + } => { + let display_label = if remote_branch_counts[&name] > 1 { + format!("{remote}/{name}") + } else { + name.clone() + }; + remote_options.push(RemoteBranchOption::new( + full_ref, + remote, + name, + display_label, + )); + } + BranchRef::Local { name, .. } => local_branches.push(name), + } + } + remote_options.sort_unstable_by(|left, right| { + left.display_label + .cmp(&right.display_label) + .then_with(|| left.full_ref.cmp(&right.full_ref)) + }); + local_branches.sort_unstable(); + (remote_options, local_branches) +} + +pub fn default_worktree_path(home: PathBuf, repository_name: &str, branch_name: &str) -> PathBuf { + home.join(".warp") + .join("worktrees") + .join(workspace_dir_name(repository_name, "")) + .join(workspace_dir_name(branch_name, "")) +} + +fn existing_worktree_display_label(worktree: &ExistingWorktreeOption) -> String { + if worktree.is_primary { + format!("{} (local)", worktree.branch_name) + } else { + worktree.branch_name.clone() + } +} + +fn existing_worktree_default_name(worktree: &ExistingWorktreeOption) -> &str { + if worktree.is_primary { + "local" + } else { + &worktree.branch_name + } +} + +fn primary_worktree_error( + repository_root: &std::path::Path, + worktrees: &[WorktreeInfo], +) -> Option { + worktrees + .iter() + .find(|worktree| is_primary_worktree_path(repository_root, &worktree.path)) + .filter(|worktree| { + worktree.is_bare + || worktree.is_detached + || worktree + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("refs/heads/")) + .is_none_or(str::is_empty) + }) + .map(|_| { + "The repository root worktree is detached and cannot be used as the local workspace." + .to_string() + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateWorkspaceDefaults { + home: PathBuf, + repository_name: String, + new_branch: String, + workspace_name: String, + worktree_path: PathBuf, +} + +impl CreateWorkspaceDefaults { + pub fn new(home: PathBuf, repository_name: String) -> Self { + Self { + home, + repository_name, + new_branch: String::new(), + workspace_name: String::new(), + worktree_path: PathBuf::new(), + } + } + + pub fn apply_branch(&mut self, branch_name: &str) { + self.new_branch = branch_name.to_string(); + self.workspace_name = branch_name.to_string(); + self.worktree_path = + default_worktree_path(self.home.clone(), &self.repository_name, branch_name); + } +} + +impl CreateWorkspaceForm { + pub fn new() -> Self { + Self::default() + } + + pub fn mode(&self) -> CreateWorkspaceMode { + self.mode + } + + pub fn remote_ref(&self) -> Option<&str> { + self.remote_ref.as_deref() + } + + pub fn new_branch(&self) -> &str { + &self.new_branch + } + + pub fn set_mode(&mut self, mode: CreateWorkspaceMode) { + if self.mode == mode { + return; + } + self.mode = mode; + match mode { + CreateWorkspaceMode::RemoteBranch => { + self.local_branch = None; + self.existing_worktree_branch = None; + } + CreateWorkspaceMode::ExistingLocalBranch => { + self.remote_ref = None; + self.existing_worktree_branch = None; + self.new_branch.clear(); + } + CreateWorkspaceMode::ExistingWorktree => { + self.remote_ref = None; + self.local_branch = None; + self.new_branch.clear(); + } + } + } + + pub fn set_remote_ref(&mut self, remote_ref: String) { + self.remote_ref = Some(remote_ref); + } + + pub fn set_local_branch(&mut self, local_branch: String) { + self.local_branch = Some(local_branch); + } + + pub fn set_existing_worktree_branch(&mut self, branch: String) { + self.existing_worktree_branch = Some(branch); + } + + pub fn set_new_branch(&mut self, new_branch: String) { + self.new_branch = new_branch; + } + + pub fn can_submit(&self) -> bool { + match self.mode { + CreateWorkspaceMode::RemoteBranch => { + self.remote_ref + .as_deref() + .is_some_and(|remote_ref| remote_ref.starts_with("refs/remotes/")) + && !self.new_branch.trim().is_empty() + } + CreateWorkspaceMode::ExistingLocalBranch => self + .local_branch + .as_deref() + .is_some_and(|branch| !branch.trim().is_empty() && !branch.starts_with("refs/")), + CreateWorkspaceMode::ExistingWorktree => self + .existing_worktree_branch + .as_deref() + .is_some_and(|branch| !branch.trim().is_empty() && !branch.starts_with("refs/")), + } + } + + pub fn build_request( + &self, + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, + display_name: String, + worktree_path: PathBuf, + ) -> Option { + if !self.can_submit() + || display_name.trim().is_empty() + || worktree_path.as_os_str().is_empty() + { + return None; + } + + let source = match self.mode { + CreateWorkspaceMode::RemoteBranch => CreateWorkspaceSource::RemoteBranch { + remote_ref: self.remote_ref.clone()?, + new_branch: self.new_branch.trim().to_string(), + }, + CreateWorkspaceMode::ExistingLocalBranch => { + CreateWorkspaceSource::ExistingLocalBranch { + local_branch: self.local_branch.clone()?, + } + } + CreateWorkspaceMode::ExistingWorktree => CreateWorkspaceSource::ExistingWorktree { + local_branch: self.existing_worktree_branch.clone()?, + }, + }; + Some(CreateWorkspaceRequest { + repository_id, + workspace_id, + display_name, + worktree_path, + source, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CreateWorkspaceModalEvent { + Close, + RetryBranchRefs { + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, + }, + RetryExistingWorktrees { + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, + }, + Submit(CreateWorkspaceRequest), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CreateWorkspaceModalAction { + Close, + NoOp, + SetMode(CreateWorkspaceMode), + SelectRemoteBranch(RemoteBranchOption), + SelectLocalBranch(String), + SelectExistingWorktree(ExistingWorktreeOption), + RetryBranchRefs, + RetryExistingWorktrees, + Submit, +} + +#[derive(Clone, Copy)] +struct CreateWorkspaceTarget { + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, +} + +impl CreateWorkspaceTarget { + fn retry_branch_refs_event(self) -> CreateWorkspaceModalEvent { + CreateWorkspaceModalEvent::RetryBranchRefs { + repository_id: self.repository_id, + workspace_id: self.workspace_id, + } + } + + fn retry_existing_worktrees_event(self) -> CreateWorkspaceModalEvent { + CreateWorkspaceModalEvent::RetryExistingWorktrees { + repository_id: self.repository_id, + workspace_id: self.workspace_id, + } + } +} + +/// 创建 repository workspace 的表单视图。 +/// +/// Git 操作和持久化不在此视图执行。视图仅生成经过基础校验的 +/// [`CreateWorkspaceRequest`],由窗口根协调 Git、SQLite 和首个终端页签。 +pub struct CreateWorkspaceModal { + target: Option, + repository_root: Option, + form: CreateWorkspaceForm, + defaults: Option, + remote_branch_picker: ViewHandle>, + local_branch_picker: ViewHandle>, + existing_worktree_picker: ViewHandle>, + new_branch_editor: ViewHandle, + display_name_editor: ViewHandle, + worktree_path_editor: ViewHandle, + remote_mode_button: ViewHandle, + local_mode_button: ViewHandle, + existing_worktree_mode_button: ViewHandle, + cancel_button: ViewHandle, + retry_remote_button: ViewHandle, + retry_existing_worktree_button: ViewHandle, + submit_button: ViewHandle, + validation_error: Option, + remote_fetch_error: Option, + existing_worktree_fetch_error: Option, + primary_worktree_error: Option, + remote_branch_options: Vec, + local_branches: Vec, + existing_worktree_options: Vec, + local_branch_fallback_loaded: bool, + selected_remote_branch: Option, + selected_local_branch: Option, + selected_existing_worktree: Option, +} + +impl CreateWorkspaceModal { + pub fn new(ctx: &mut ViewContext) -> Self { + let remote_branch_picker = ctx.add_typed_action_view(|ctx| { + let mut picker = FilterableDropdown::new(ctx); + picker.set_top_bar_max_width(480.); + picker.set_menu_width(480., ctx); + picker.set_disabled(ctx); + picker + }); + let local_branch_picker = ctx.add_typed_action_view(|ctx| { + let mut picker = FilterableDropdown::new(ctx); + picker.set_top_bar_max_width(480.); + picker.set_menu_width(480., ctx); + picker.set_disabled(ctx); + picker + }); + let existing_worktree_picker = ctx.add_typed_action_view(|ctx| { + let mut picker = FilterableDropdown::new(ctx); + picker.set_top_bar_max_width(480.); + picker.set_menu_width(480., ctx); + picker.set_disabled(ctx); + picker + }); + let new_branch_editor = Self::build_editor("New local branch", ctx); + let display_name_editor = Self::build_editor("Workspace name", ctx); + let worktree_path_editor = Self::build_editor("Worktree path", ctx); + let remote_mode_button = ctx.add_view(|_| { + ActionButton::new("From remote branch", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| { + ctx.dispatch_typed_action(CreateWorkspaceModalAction::SetMode( + CreateWorkspaceMode::RemoteBranch, + )); + }) + }); + let local_mode_button = ctx.add_view(|_| { + ActionButton::new("Use local branch", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| { + ctx.dispatch_typed_action(CreateWorkspaceModalAction::SetMode( + CreateWorkspaceMode::ExistingLocalBranch, + )); + }) + }); + let existing_worktree_mode_button = ctx.add_view(|_| { + ActionButton::new("Use existing worktree", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| { + ctx.dispatch_typed_action(CreateWorkspaceModalAction::SetMode( + CreateWorkspaceMode::ExistingWorktree, + )); + }) + }); + let cancel_button = ctx.add_view(|_| { + ActionButton::new("Cancel", NakedTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(CreateWorkspaceModalAction::Close)) + }); + let retry_remote_button = ctx.add_view(|_| { + ActionButton::new("Retry", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| { + ctx.dispatch_typed_action(CreateWorkspaceModalAction::RetryBranchRefs) + }) + }); + let retry_existing_worktree_button = ctx.add_view(|_| { + ActionButton::new("Retry", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| { + ctx.dispatch_typed_action(CreateWorkspaceModalAction::RetryExistingWorktrees) + }) + }); + let submit_button = ctx.add_view(|_| { + ActionButton::new("Create workspace", PrimaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(CreateWorkspaceModalAction::Submit)) + }); + + let mut modal = Self { + target: None, + repository_root: None, + form: CreateWorkspaceForm::new(), + defaults: None, + remote_branch_picker, + local_branch_picker, + existing_worktree_picker, + new_branch_editor, + display_name_editor, + worktree_path_editor, + remote_mode_button, + local_mode_button, + existing_worktree_mode_button, + cancel_button, + retry_remote_button, + retry_existing_worktree_button, + submit_button, + validation_error: None, + remote_fetch_error: None, + existing_worktree_fetch_error: None, + primary_worktree_error: None, + remote_branch_options: Vec::new(), + local_branches: Vec::new(), + existing_worktree_options: Vec::new(), + local_branch_fallback_loaded: false, + selected_remote_branch: None, + selected_local_branch: None, + selected_existing_worktree: None, + }; + modal.subscribe_to_editors(ctx); + modal + } + + pub fn configure( + &mut self, + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, + repository_root: PathBuf, + home: PathBuf, + repository_name: String, + ctx: &mut ViewContext, + ) { + self.target = Some(CreateWorkspaceTarget { + repository_id, + workspace_id, + }); + self.repository_root = Some(repository_root); + self.form = CreateWorkspaceForm::new(); + self.defaults = Some(CreateWorkspaceDefaults::new(home, repository_name)); + self.validation_error = None; + self.primary_worktree_error = None; + self.local_branches.clear(); + self.local_branch_fallback_loaded = false; + self.selected_remote_branch = None; + self.selected_local_branch = None; + self.selected_existing_worktree = None; + self.reset_editor(&self.new_branch_editor, "", ctx); + self.reset_editor(&self.display_name_editor, "", ctx); + self.reset_editor(&self.worktree_path_editor, "", ctx); + self.local_branch_picker.update(ctx, |picker, ctx| { + picker.set_items(Vec::new(), ctx); + picker.set_disabled(ctx); + }); + self.begin_branch_fetch(ctx); + self.begin_existing_worktree_fetch(ctx); + } + + pub fn on_close(&mut self, ctx: &mut ViewContext) { + self.target = None; + self.repository_root = None; + self.validation_error = None; + self.remote_fetch_error = None; + self.existing_worktree_fetch_error = None; + self.primary_worktree_error = None; + self.sync_submit_button_disabled_state(ctx); + ctx.notify(); + } + + pub fn matches_target( + &self, + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, + ) -> bool { + self.target.is_some_and(|target| { + target.repository_id == repository_id && target.workspace_id == workspace_id + }) + } + + pub fn begin_branch_fetch(&mut self, ctx: &mut ViewContext) { + self.remote_fetch_error = None; + self.sync_submit_button_disabled_state(ctx); + self.remote_branch_options.clear(); + self.selected_remote_branch = None; + self.remote_branch_picker.update(ctx, |picker, ctx| { + picker.set_items( + vec![DropdownItem::new( + "Fetching remote branches...", + CreateWorkspaceModalAction::NoOp, + )], + ctx, + ); + picker.set_selected_by_action(CreateWorkspaceModalAction::NoOp, ctx); + picker.set_disabled(ctx); + }); + if self.local_branch_fallback_loaded { + self.local_branch_picker.update(ctx, |picker, ctx| { + picker.set_enabled(ctx); + }); + } else { + self.local_branch_picker.update(ctx, |picker, ctx| { + picker.set_disabled(ctx); + }); + } + ctx.notify(); + } + + pub fn begin_existing_worktree_fetch(&mut self, ctx: &mut ViewContext) { + self.existing_worktree_fetch_error = None; + self.primary_worktree_error = None; + self.existing_worktree_options.clear(); + self.selected_existing_worktree = None; + self.existing_worktree_picker.update(ctx, |picker, ctx| { + picker.set_items( + vec![DropdownItem::new( + "Fetching existing worktrees...", + CreateWorkspaceModalAction::NoOp, + )], + ctx, + ); + picker.set_selected_by_action(CreateWorkspaceModalAction::NoOp, ctx); + picker.set_disabled(ctx); + }); + self.sync_submit_button_disabled_state(ctx); + ctx.notify(); + } + + pub fn set_existing_worktrees( + &mut self, + worktrees: Vec, + ctx: &mut ViewContext, + ) { + let Some(repository_root) = self.repository_root.as_deref() else { + return; + }; + self.existing_worktree_fetch_error = None; + self.primary_worktree_error = primary_worktree_error(repository_root, &worktrees); + self.existing_worktree_options = existing_worktree_options(repository_root, worktrees); + self.selected_existing_worktree = None; + let existing_worktree_items = self + .existing_worktree_options + .iter() + .cloned() + .map(|worktree| { + DropdownItem::new( + existing_worktree_display_label(&worktree), + CreateWorkspaceModalAction::SelectExistingWorktree(worktree), + ) + }) + .collect(); + self.existing_worktree_picker.update(ctx, |picker, ctx| { + picker.set_items(existing_worktree_items, ctx); + picker.set_enabled(ctx); + }); + + if let Some(worktree) = self.existing_worktree_options.first().cloned() { + self.existing_worktree_picker.update(ctx, |picker, ctx| { + picker.set_selected_by_action( + CreateWorkspaceModalAction::SelectExistingWorktree(worktree.clone()), + ctx, + ); + }); + self.selected_existing_worktree = Some(worktree.clone()); + if self.form.mode() == CreateWorkspaceMode::ExistingWorktree { + self.select_existing_worktree(worktree, ctx); + } + } + self.sync_submit_button_disabled_state(ctx); + ctx.notify(); + } + + pub fn set_existing_worktree_fetch_error( + &mut self, + message: String, + ctx: &mut ViewContext, + ) { + self.existing_worktree_fetch_error = Some(message); + self.existing_worktree_picker.update(ctx, |picker, ctx| { + picker.set_disabled(ctx); + }); + self.sync_submit_button_disabled_state(ctx); + ctx.notify(); + } + + pub fn set_branch_refs(&mut self, refs: Vec, ctx: &mut ViewContext) { + let (remote_branch_options, local_branches) = branch_ref_options(refs); + self.remote_fetch_error = None; + self.sync_submit_button_disabled_state(ctx); + self.remote_branch_options = remote_branch_options; + self.local_branches = local_branches; + self.local_branch_fallback_loaded = false; + self.selected_remote_branch = None; + self.selected_local_branch = None; + let remote_items = self + .remote_branch_options + .iter() + .cloned() + .map(|branch| { + DropdownItem::new( + branch.display_label.clone(), + CreateWorkspaceModalAction::SelectRemoteBranch(branch), + ) + }) + .collect(); + let local_items = self + .local_branches + .iter() + .cloned() + .map(|branch| { + DropdownItem::new( + branch.clone(), + CreateWorkspaceModalAction::SelectLocalBranch(branch), + ) + }) + .collect(); + + self.remote_branch_picker.update(ctx, |picker, ctx| { + picker.set_items(remote_items, ctx); + picker.set_enabled(ctx); + }); + self.local_branch_picker.update(ctx, |picker, ctx| { + picker.set_items(local_items, ctx); + picker.set_enabled(ctx); + }); + + if let Some(branch) = self.remote_branch_options.first().cloned() { + self.remote_branch_picker.update(ctx, |picker, ctx| { + picker.set_selected_by_action( + CreateWorkspaceModalAction::SelectRemoteBranch(branch.clone()), + ctx, + ); + }); + self.selected_remote_branch = Some(branch.clone()); + if self.form.mode() == CreateWorkspaceMode::RemoteBranch { + self.select_remote_branch(branch, ctx); + } + } + ctx.notify(); + } + + pub fn set_local_branch_refs(&mut self, refs: Vec, ctx: &mut ViewContext) { + let (_, local_branches) = branch_ref_options(refs); + self.remote_branch_options.clear(); + self.selected_remote_branch = None; + self.local_branches = local_branches; + self.selected_local_branch = None; + self.local_branch_fallback_loaded = true; + let local_items = self + .local_branches + .iter() + .cloned() + .map(|branch| { + DropdownItem::new( + branch.clone(), + CreateWorkspaceModalAction::SelectLocalBranch(branch), + ) + }) + .collect(); + self.local_branch_picker.update(ctx, |picker, ctx| { + picker.set_items(local_items, ctx); + picker.set_enabled(ctx); + }); + self.remote_branch_picker.update(ctx, |picker, ctx| { + picker.set_items(Vec::new(), ctx); + picker.set_disabled(ctx); + }); + ctx.notify(); + } + + pub fn set_branch_fetch_error(&mut self, message: String, ctx: &mut ViewContext) { + self.remote_fetch_error = Some(message); + self.sync_submit_button_disabled_state(ctx); + self.remote_branch_picker.update(ctx, |picker, ctx| { + picker.set_disabled(ctx); + }); + ctx.notify(); + } + + pub fn set_validation_error(&mut self, message: String, ctx: &mut ViewContext) { + self.validation_error = Some(message); + ctx.notify(); + } + + fn build_editor(placeholder: &str, ctx: &mut ViewContext) -> ViewHandle { + let placeholder = placeholder.to_string(); + ctx.add_typed_action_view(move |ctx| { + let mut editor = EditorView::single_line(SingleLineEditorOptions::default(), ctx); + editor.set_placeholder_text(&placeholder, ctx); + editor + }) + } + + fn subscribe_to_editors(&mut self, ctx: &mut ViewContext) { + for editor in [ + self.new_branch_editor.clone(), + self.display_name_editor.clone(), + self.worktree_path_editor.clone(), + ] { + ctx.subscribe_to_view(&editor, |modal, _, event, ctx| match event { + EditorEvent::Enter => modal.try_submit(ctx), + EditorEvent::Escape => ctx.emit(CreateWorkspaceModalEvent::Close), + EditorEvent::Edited(_) => { + modal.validation_error = None; + ctx.notify(); + } + _ => {} + }); + } + } + + fn reset_editor( + &self, + editor: &ViewHandle, + text: &str, + ctx: &mut ViewContext, + ) { + editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(text, ctx); + }); + } + + fn editor_text(editor: &ViewHandle, app: &AppContext) -> String { + editor.as_ref(app).buffer_text(app).trim().to_string() + } + + fn set_mode(&mut self, mode: CreateWorkspaceMode, ctx: &mut ViewContext) { + if self.form.mode() == mode { + return; + } + self.form.set_mode(mode); + self.sync_submit_button_disabled_state(ctx); + self.validation_error = None; + match mode { + CreateWorkspaceMode::RemoteBranch => { + if let Some(branch) = self.selected_remote_branch.clone() { + self.select_remote_branch(branch, ctx); + } + } + CreateWorkspaceMode::ExistingLocalBranch => { + if let Some(branch) = self.selected_local_branch.clone() { + self.select_local_branch(branch, ctx); + } + } + CreateWorkspaceMode::ExistingWorktree => { + if let Some(worktree) = self.selected_existing_worktree.clone() { + self.select_existing_worktree(worktree, ctx); + } + } + } + ctx.notify(); + } + + fn sync_submit_button_disabled_state(&mut self, ctx: &mut ViewContext) { + let disabled = submit_is_disabled( + self.form.mode(), + self.remote_fetch_error.is_some(), + self.existing_worktree_fetch_error.is_some(), + self.selected_existing_worktree.is_some(), + ); + self.submit_button.update(ctx, |button, ctx| { + button.set_disabled(disabled, ctx); + }); + } + + fn select_remote_branch(&mut self, branch: RemoteBranchOption, ctx: &mut ViewContext) { + self.form.set_remote_ref(branch.full_ref.clone()); + self.selected_remote_branch = Some(branch.clone()); + self.apply_defaults(&branch.branch_name, true, ctx); + self.validation_error = None; + self.sync_submit_button_disabled_state(ctx); + ctx.notify(); + } + + fn select_local_branch(&mut self, branch: String, ctx: &mut ViewContext) { + self.form.set_local_branch(branch.clone()); + self.selected_local_branch = Some(branch.clone()); + self.apply_defaults(&branch, false, ctx); + self.validation_error = None; + self.sync_submit_button_disabled_state(ctx); + ctx.notify(); + } + + fn select_existing_worktree( + &mut self, + worktree: ExistingWorktreeOption, + ctx: &mut ViewContext, + ) { + self.form + .set_existing_worktree_branch(worktree.branch_name.clone()); + self.selected_existing_worktree = Some(worktree.clone()); + self.reset_editor( + &self.display_name_editor, + existing_worktree_default_name(&worktree), + ctx, + ); + self.validation_error = None; + self.sync_submit_button_disabled_state(ctx); + ctx.notify(); + } + + fn apply_defaults( + &mut self, + branch_name: &str, + reset_new_branch: bool, + ctx: &mut ViewContext, + ) { + let (new_branch, workspace_name, worktree_path) = { + let Some(defaults) = self.defaults.as_mut() else { + return; + }; + defaults.apply_branch(branch_name); + ( + defaults.new_branch.clone(), + defaults.workspace_name.clone(), + defaults.worktree_path.clone(), + ) + }; + if reset_new_branch { + self.form.set_new_branch(new_branch.clone()); + self.reset_editor(&self.new_branch_editor, &new_branch, ctx); + } + self.reset_editor(&self.display_name_editor, &workspace_name, ctx); + self.reset_editor( + &self.worktree_path_editor, + worktree_path.to_string_lossy().as_ref(), + ctx, + ); + } + + fn try_submit(&mut self, ctx: &mut ViewContext) { + let Some(target) = self.target else { + self.validation_error = + Some("Select a repository before creating a workspace.".to_string()); + ctx.notify(); + return; + }; + + match self.form.mode() { + CreateWorkspaceMode::RemoteBranch => { + if self.remote_fetch_error.is_some() { + self.validation_error = + Some("Retry remote branch loading or choose a local branch.".to_string()); + ctx.notify(); + return; + } + let Some(branch) = self.selected_remote_branch.as_ref() else { + self.validation_error = + Some("Select a remote branch before creating a workspace.".to_string()); + ctx.notify(); + return; + }; + self.form.set_remote_ref(branch.full_ref.clone()); + self.form + .set_new_branch(Self::editor_text(&self.new_branch_editor, ctx)); + } + CreateWorkspaceMode::ExistingLocalBranch => { + if let Some(branch) = self.selected_local_branch.clone() { + self.form.set_local_branch(branch); + } + } + CreateWorkspaceMode::ExistingWorktree => { + if self.existing_worktree_fetch_error.is_some() { + self.validation_error = Some( + "Retry existing worktree loading before creating a workspace.".to_string(), + ); + ctx.notify(); + return; + } + let Some(worktree) = self.selected_existing_worktree.clone() else { + self.validation_error = Some( + "Select an existing worktree before creating a workspace.".to_string(), + ); + ctx.notify(); + return; + }; + self.form.set_existing_worktree_branch(worktree.branch_name); + } + } + + let source_branch = match self.form.mode() { + CreateWorkspaceMode::RemoteBranch => self.form.new_branch().to_string(), + CreateWorkspaceMode::ExistingLocalBranch => { + self.selected_local_branch.clone().unwrap_or_default() + } + CreateWorkspaceMode::ExistingWorktree => self + .selected_existing_worktree + .as_ref() + .map_or_else(String::new, |worktree| worktree.branch_name.clone()), + }; + let display_name = { + let name = Self::editor_text(&self.display_name_editor, ctx); + if name.is_empty() { + source_branch.clone() + } else { + name + } + }; + let worktree_path = if self.form.mode() == CreateWorkspaceMode::ExistingWorktree { + self.selected_existing_worktree + .as_ref() + .expect("existing worktree selection was checked before request construction") + .path + .clone() + } else { + PathBuf::from(Self::editor_text(&self.worktree_path_editor, ctx)) + }; + + let Some(request) = self.form.build_request( + target.repository_id, + target.workspace_id, + display_name, + worktree_path, + ) else { + self.validation_error = Some( + "Enter a valid branch reference, workspace name, and worktree path.".to_string(), + ); + ctx.notify(); + return; + }; + ctx.emit(CreateWorkspaceModalEvent::Submit(request)); + } + + fn section(label: &str, child: Box, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child( + Text::new_inline( + label.to_string(), + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .with_color(theme.sub_text_color(theme.background()).into()) + .finish(), + ) + .with_child(Container::new(child).with_margin_top(4.).finish()) + .finish() + } + + fn constrain_editor(child: Box) -> Box { + ConstrainedBox::new(Clipped::new(child).finish()) + .with_max_width(480.) + .finish() + } +} + +impl Entity for CreateWorkspaceModal { + type Event = CreateWorkspaceModalEvent; +} + +impl TypedActionView for CreateWorkspaceModal { + type Action = CreateWorkspaceModalAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + CreateWorkspaceModalAction::Close => ctx.emit(CreateWorkspaceModalEvent::Close), + CreateWorkspaceModalAction::NoOp => {} + CreateWorkspaceModalAction::SetMode(mode) => self.set_mode(*mode, ctx), + CreateWorkspaceModalAction::SelectRemoteBranch(branch) => { + self.select_remote_branch(branch.clone(), ctx) + } + CreateWorkspaceModalAction::SelectLocalBranch(branch) => { + self.select_local_branch(branch.clone(), ctx) + } + CreateWorkspaceModalAction::SelectExistingWorktree(worktree) => { + self.select_existing_worktree(worktree.clone(), ctx) + } + CreateWorkspaceModalAction::RetryBranchRefs => { + if let Some(target) = self.target { + ctx.emit(target.retry_branch_refs_event()); + } + } + CreateWorkspaceModalAction::RetryExistingWorktrees => { + if let Some(target) = self.target { + ctx.emit(target.retry_existing_worktrees_event()); + } + } + CreateWorkspaceModalAction::Submit => self.try_submit(ctx), + } + } +} + +impl View for CreateWorkspaceModal { + fn ui_name() -> &'static str { + "CreateWorkspaceModal" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let is_remote = self.form.mode() == CreateWorkspaceMode::RemoteBranch; + let is_existing_worktree = self.form.mode() == CreateWorkspaceMode::ExistingWorktree; + let (branch_label, branch_picker) = match self.form.mode() { + CreateWorkspaceMode::RemoteBranch => ( + "Remote branch", + ChildView::new(&self.remote_branch_picker).finish(), + ), + CreateWorkspaceMode::ExistingLocalBranch => ( + "Local branch", + ChildView::new(&self.local_branch_picker).finish(), + ), + CreateWorkspaceMode::ExistingWorktree => ( + "Existing worktree", + ChildView::new(&self.existing_worktree_picker).finish(), + ), + }; + let mut form = Flex::column() + .with_main_axis_size(MainAxisSize::Min) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(12.) + .with_child( + Text::new_inline( + "Create workspace", + appearance.ui_font_family(), + appearance.ui_font_heading_3(), + ) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(), + ) + .with_child( + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(8.) + .with_child(ChildView::new(&self.remote_mode_button).finish()) + .with_child(ChildView::new(&self.local_mode_button).finish()) + .with_child(ChildView::new(&self.existing_worktree_mode_button).finish()) + .finish(), + ) + .with_child(Self::section(branch_label, branch_picker, appearance)); + if is_remote { + if let Some(error) = &self.remote_fetch_error { + form.add_child(Self::constrain_editor( + Text::new_inline( + error.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(theme.ui_error_color()) + .finish(), + )); + form.add_child(ChildView::new(&self.retry_remote_button).finish()); + } + } + if is_existing_worktree { + if let Some(error) = &self.primary_worktree_error { + form.add_child(Self::constrain_editor( + Text::new_inline( + error.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(theme.ui_error_color()) + .finish(), + )); + } + if let Some(error) = &self.existing_worktree_fetch_error { + form.add_child(Self::constrain_editor( + Text::new_inline( + error.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(theme.ui_error_color()) + .finish(), + )); + form.add_child(ChildView::new(&self.retry_existing_worktree_button).finish()); + } + } + if is_remote { + form.add_child(Self::section( + "New local branch", + Self::constrain_editor(ChildView::new(&self.new_branch_editor).finish()), + appearance, + )); + } + form.add_child(Self::section( + "Workspace name", + Self::constrain_editor(ChildView::new(&self.display_name_editor).finish()), + appearance, + )); + let worktree_path = if is_existing_worktree { + let path = self + .selected_existing_worktree + .as_ref() + .map_or_else(String::new, |worktree| { + worktree.path.to_string_lossy().into_owned() + }); + Self::constrain_editor( + Text::new_inline(path, appearance.ui_font_family(), appearance.ui_font_body()) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(), + ) + } else { + Self::constrain_editor(ChildView::new(&self.worktree_path_editor).finish()) + }; + form.add_child(Self::section("Worktree path", worktree_path, appearance)); + if let Some(error) = &self.validation_error { + form.add_child(Self::constrain_editor( + Text::new_inline( + error.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(theme.ui_error_color()) + .finish(), + )); + } + + let footer = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::End) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(8.) + .with_child(ChildView::new(&self.cancel_button).finish()) + .with_child(ChildView::new(&self.submit_button).finish()) + .finish(); + Flex::column() + .with_main_axis_size(MainAxisSize::Min) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child( + Container::new(form.finish()) + .with_uniform_padding(20.) + .finish(), + ) + .with_child(Container::new(footer).with_uniform_padding(12.).finish()) + .finish() + } +} + +#[cfg(test)] +#[path = "create_workspace_modal_tests.rs"] +mod tests; diff --git a/app/src/project_organization/view/create_workspace_modal_tests.rs b/app/src/project_organization/view/create_workspace_modal_tests.rs new file mode 100644 index 00000000000..dd708e88c0c --- /dev/null +++ b/app/src/project_organization/view/create_workspace_modal_tests.rs @@ -0,0 +1,359 @@ +use std::path::PathBuf; + +use pathfinder_geometry::vector::vec2f; +use warp_core::ui::appearance::Appearance; +use warpui::{ + elements::{ChildView, ConstrainedBox}, + platform::WindowStyle, + App, Element, Entity, Presenter, TypedActionView, View, ViewContext, ViewHandle, + WindowInvalidation, +}; + +use crate::project_organization::domain::{RepositoryId, RepositoryWorkspaceId}; +use crate::project_organization::git::{BranchRef, ExistingWorktreeOption, WorktreeInfo}; +use crate::settings_view::keybindings::KeybindingChangedNotifier; +use crate::test_util::settings::initialize_settings_for_tests; + +use super::{ + branch_ref_options, default_worktree_path, existing_worktree_default_name, + existing_worktree_display_label, primary_worktree_error, submit_is_disabled, + CreateWorkspaceDefaults, CreateWorkspaceForm, CreateWorkspaceModal, CreateWorkspaceModalEvent, + CreateWorkspaceMode, CreateWorkspaceSource, CreateWorkspaceTarget, RemoteBranchOption, +}; + +struct CreateWorkspaceModalTestHost { + modal: ViewHandle, +} + +impl CreateWorkspaceModalTestHost { + fn new(ctx: &mut ViewContext) -> Self { + Self { + modal: ctx.add_typed_action_view(CreateWorkspaceModal::new), + } + } +} + +impl Entity for CreateWorkspaceModalTestHost { + type Event = (); +} + +impl View for CreateWorkspaceModalTestHost { + fn ui_name() -> &'static str { + "CreateWorkspaceModalTestHost" + } + + fn render(&self, _app: &warpui::AppContext) -> Box { + ConstrainedBox::new(ChildView::new(&self.modal).finish()) + .with_width(520.) + .finish() + } +} + +impl TypedActionView for CreateWorkspaceModalTestHost { + type Action = (); +} + +#[test] +fn primary_existing_worktree_uses_local_label_and_name() { + let option = ExistingWorktreeOption::primary(PathBuf::from("/repo"), "main"); + + assert_eq!(existing_worktree_display_label(&option), "main (local)"); + assert_eq!(existing_worktree_default_name(&option), "local"); +} + +#[test] +fn detached_primary_worktree_warning_keeps_linked_worktree_available() { + let repository_root = PathBuf::from("/repo"); + let linked_path = PathBuf::from("/repo-feature"); + let worktrees = vec![ + WorktreeInfo { + path: repository_root.clone(), + head: Some("a".to_string()), + branch: None, + is_bare: false, + is_detached: true, + is_locked: false, + locked_reason: None, + is_prunable: false, + prunable_reason: None, + }, + WorktreeInfo { + path: linked_path.clone(), + head: Some("b".to_string()), + branch: Some("refs/heads/feature/existing".to_string()), + is_bare: false, + is_detached: false, + is_locked: false, + locked_reason: None, + is_prunable: false, + prunable_reason: None, + }, + ]; + + assert!(primary_worktree_error(&repository_root, &worktrees).is_some()); + assert_eq!( + super::existing_worktree_options(&repository_root, worktrees), + vec![ExistingWorktreeOption::new(linked_path, "feature/existing")], + ); +} + +#[test] +fn remote_fetch_error_disables_submit_only_in_remote_mode() { + assert!(submit_is_disabled( + CreateWorkspaceMode::RemoteBranch, + true, + false, + false, + )); + assert!(!submit_is_disabled( + CreateWorkspaceMode::RemoteBranch, + false, + true, + false, + )); + assert!(!submit_is_disabled( + CreateWorkspaceMode::ExistingLocalBranch, + true, + true, + false, + )); +} + +#[test] +fn existing_worktree_submit_is_disabled_until_a_selection_is_available() { + assert!(submit_is_disabled( + CreateWorkspaceMode::ExistingWorktree, + false, + false, + false, + )); + assert!(submit_is_disabled( + CreateWorkspaceMode::ExistingWorktree, + false, + true, + true, + )); + assert!(!submit_is_disabled( + CreateWorkspaceMode::ExistingWorktree, + false, + false, + true, + )); +} + +#[test] +fn existing_worktree_fetch_error_renders_with_finite_flex_constraints() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + app.add_singleton_model(|_| Appearance::mock()); + app.add_singleton_model(|_| KeybindingChangedNotifier::mock()); + let (window_id, host) = app.add_window( + WindowStyle::NotStealFocus, + CreateWorkspaceModalTestHost::new, + ); + let modal = host.read(&app, |host, _| host.modal.clone()); + let root_view_id = app + .root_view_id(window_id) + .expect("window should have a root view"); + let mut presenter = Presenter::new(window_id); + + modal.update(&mut app, |modal, ctx| { + modal.configure( + RepositoryId(uuid::Uuid::from_u128(1)), + RepositoryWorkspaceId(uuid::Uuid::from_u128(2)), + PathBuf::from("/repo"), + PathBuf::from("/Users/example"), + "repo".to_string(), + ctx, + ); + modal.set_mode(CreateWorkspaceMode::ExistingWorktree, ctx); + modal.set_existing_worktree_fetch_error("fatal: not a git repository".to_string(), ctx); + }); + + app.update(|ctx| { + presenter.invalidate( + WindowInvalidation { + updated: [root_view_id, modal.id()].into_iter().collect(), + ..Default::default() + }, + ctx, + ); + presenter.build_scene(vec2f(520., 420.), 1., None, ctx); + }); + }); +} + +#[test] +fn retry_event_targets_the_configured_workspace() { + let target = CreateWorkspaceTarget { + repository_id: RepositoryId(uuid::Uuid::from_u128(1)), + workspace_id: RepositoryWorkspaceId(uuid::Uuid::from_u128(2)), + }; + + assert_eq!( + target.retry_branch_refs_event(), + CreateWorkspaceModalEvent::RetryBranchRefs { + repository_id: RepositoryId(uuid::Uuid::from_u128(1)), + workspace_id: RepositoryWorkspaceId(uuid::Uuid::from_u128(2)), + }, + ); +} + +#[test] +fn selecting_remote_branch_overwrites_all_derived_workspace_fields() { + let mut defaults = + CreateWorkspaceDefaults::new(PathBuf::from("/Users/example"), "dip-agent".to_string()); + defaults.apply_branch("feature/one"); + defaults.new_branch = "custom".to_string(); + defaults.workspace_name = "custom workspace".to_string(); + defaults.worktree_path = PathBuf::from("/tmp/custom"); + + defaults.apply_branch("feature/two"); + + assert_eq!(defaults.new_branch, "feature/two"); + assert_eq!(defaults.workspace_name, "feature/two"); + assert_eq!( + defaults.worktree_path, + PathBuf::from("/Users/example/.warp/worktrees/dip-agent/feature-two") + ); +} + +#[test] +fn switching_workspace_creation_modes_clears_incompatible_branch_selection() { + let mut form = CreateWorkspaceForm::new(); + form.set_remote_ref("refs/remotes/origin/main".to_string()); + form.set_new_branch("feature/project-tree".to_string()); + + form.set_mode(CreateWorkspaceMode::ExistingLocalBranch); + + assert_eq!(form.remote_ref(), None); + assert_eq!(form.new_branch(), ""); + assert_eq!(form.mode(), CreateWorkspaceMode::ExistingLocalBranch); +} + +#[test] +fn local_branch_mode_rejects_remote_refs() { + let mut form = CreateWorkspaceForm::new(); + form.set_mode(CreateWorkspaceMode::ExistingLocalBranch); + form.set_local_branch("refs/remotes/origin/main".to_string()); + + assert!(!form.can_submit()); + + form.set_local_branch("feature/project-tree".to_string()); + assert!(form.can_submit()); +} + +#[test] +fn remote_branch_form_builds_a_workspace_creation_request() { + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut form = CreateWorkspaceForm::new(); + form.set_remote_ref("refs/remotes/origin/main".to_string()); + form.set_new_branch("feature/project-tree".to_string()); + + let request = form + .build_request( + repository_id, + workspace_id, + "Project tree".to_string(), + PathBuf::from("/tmp/project-tree"), + ) + .unwrap(); + + assert_eq!(request.repository_id, repository_id); + assert_eq!(request.workspace_id, workspace_id); + assert_eq!(request.display_name, "Project tree"); + assert_eq!(request.worktree_path, PathBuf::from("/tmp/project-tree")); + assert!(matches!( + request.source, + CreateWorkspaceSource::RemoteBranch { + remote_ref, + new_branch, + } if remote_ref == "refs/remotes/origin/main" && new_branch == "feature/project-tree" + )); +} + +#[test] +fn existing_worktree_form_builds_a_workspace_creation_request() { + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut form = CreateWorkspaceForm::new(); + form.set_mode(CreateWorkspaceMode::ExistingWorktree); + form.set_existing_worktree_branch("feature/adopt".to_string()); + + let request = form + .build_request( + repository_id, + workspace_id, + "Adopt workspace".to_string(), + PathBuf::from("/tmp/adopt"), + ) + .unwrap(); + + assert_eq!(request.repository_id, repository_id); + assert_eq!(request.workspace_id, workspace_id); + assert_eq!(request.display_name, "Adopt workspace"); + assert_eq!(request.worktree_path, PathBuf::from("/tmp/adopt")); + assert!(matches!( + request.source, + CreateWorkspaceSource::ExistingWorktree { local_branch } + if local_branch == "feature/adopt" + )); +} + +#[test] +fn remote_branch_options_hide_ref_prefix_and_disambiguate_duplicate_names() { + let (remote_options, local_branches) = branch_ref_options([ + BranchRef::Remote { + remote: "origin".to_string(), + name: "main".to_string(), + full_ref: "refs/remotes/origin/main".to_string(), + }, + BranchRef::Remote { + remote: "upstream".to_string(), + name: "main".to_string(), + full_ref: "refs/remotes/upstream/main".to_string(), + }, + BranchRef::Remote { + remote: "origin".to_string(), + name: "feature/tree".to_string(), + full_ref: "refs/remotes/origin/feature/tree".to_string(), + }, + BranchRef::Local { + name: "feature/local".to_string(), + full_ref: "refs/heads/feature/local".to_string(), + }, + ]); + + assert_eq!( + remote_options, + vec![ + RemoteBranchOption::new( + "refs/remotes/origin/feature/tree", + "origin", + "feature/tree", + "feature/tree", + ), + RemoteBranchOption::new("refs/remotes/origin/main", "origin", "main", "origin/main",), + RemoteBranchOption::new( + "refs/remotes/upstream/main", + "upstream", + "main", + "upstream/main", + ), + ] + ); + assert_eq!(local_branches, vec!["feature/local"]); +} + +#[test] +fn default_worktree_path_uses_repository_and_branch_names() { + assert_eq!( + default_worktree_path( + PathBuf::from("/Users/example"), + "dip-agent", + "feature/project-tree", + ), + PathBuf::from("/Users/example/.warp/worktrees/dip-agent/feature-project-tree"), + ); +} diff --git a/app/src/project_organization/view/delete_workspace_dialog.rs b/app/src/project_organization/view/delete_workspace_dialog.rs new file mode 100644 index 00000000000..a27e73aa39e --- /dev/null +++ b/app/src/project_organization/view/delete_workspace_dialog.rs @@ -0,0 +1,232 @@ +use warpui::{ + elements::{ + ChildView, Container, CrossAxisAlignment, Element, Flex, MainAxisAlignment, MainAxisSize, + MouseStateHandle, ParentElement, Text, + }, + platform::Cursor, + ui_components::components::UiComponent, + AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, +}; + +use crate::{ + appearance::Appearance, + project_organization::domain::RepositoryWorkspaceId, + view_components::action_button::{ActionButton, ButtonSize, DangerPrimaryTheme, NakedTheme}, +}; + +#[derive(Clone, Debug)] +pub enum DeleteWorkspaceDialogEvent { + Close, + Confirm { + workspace_id: RepositoryWorkspaceId, + delete_branch: bool, + force_branch: bool, + }, +} + +#[derive(Clone, Debug)] +pub enum DeleteWorkspaceDialogAction { + Close, + ToggleDeleteBranch, + Confirm, +} + +#[derive(Clone, Copy)] +struct DeleteWorkspaceTarget { + workspace_id: RepositoryWorkspaceId, +} + +/// 删除 repository workspace 的确认界面。 +/// +/// 预检和 Git 操作均由窗口根执行。该视图仅保存用户的分支删除选择,并在未合并 +/// 分支场景要求再次明确确认。 +pub struct DeleteWorkspaceDialog { + target: Option, + display_name: String, + branch: String, + delete_branch: bool, + force_branch: bool, + checkbox_mouse_state: MouseStateHandle, + cancel_button: ViewHandle, + confirm_button: ViewHandle, +} + +impl DeleteWorkspaceDialog { + pub fn new(ctx: &mut ViewContext) -> Self { + let cancel_button = ctx.add_view(|_| { + ActionButton::new("Cancel", NakedTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(DeleteWorkspaceDialogAction::Close)) + }); + let confirm_button = ctx.add_view(|_| { + ActionButton::new("Remove workspace", DangerPrimaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(DeleteWorkspaceDialogAction::Confirm)) + }); + Self { + target: None, + display_name: String::new(), + branch: String::new(), + delete_branch: true, + force_branch: false, + checkbox_mouse_state: Default::default(), + cancel_button, + confirm_button, + } + } + + pub fn configure( + &mut self, + workspace_id: RepositoryWorkspaceId, + display_name: String, + branch: String, + ctx: &mut ViewContext, + ) { + self.target = Some(DeleteWorkspaceTarget { workspace_id }); + self.display_name = display_name; + self.branch = branch; + self.delete_branch = true; + self.force_branch = false; + ctx.notify(); + } + + pub fn require_force_confirmation(&mut self, ctx: &mut ViewContext) { + self.force_branch = true; + ctx.notify(); + } + + pub fn reset(&mut self, ctx: &mut ViewContext) { + self.target = None; + self.force_branch = false; + ctx.notify(); + } +} + +impl Entity for DeleteWorkspaceDialog { + type Event = DeleteWorkspaceDialogEvent; +} + +impl TypedActionView for DeleteWorkspaceDialog { + type Action = DeleteWorkspaceDialogAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + DeleteWorkspaceDialogAction::Close => ctx.emit(DeleteWorkspaceDialogEvent::Close), + DeleteWorkspaceDialogAction::ToggleDeleteBranch => { + self.delete_branch = !self.delete_branch; + ctx.notify(); + } + DeleteWorkspaceDialogAction::Confirm => { + let Some(target) = self.target else { + return; + }; + ctx.emit(DeleteWorkspaceDialogEvent::Confirm { + workspace_id: target.workspace_id, + delete_branch: self.delete_branch, + force_branch: self.force_branch, + }); + } + } + } +} + +impl View for DeleteWorkspaceDialog { + fn ui_name() -> &'static str { + "DeleteWorkspaceDialog" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let title = if self.force_branch { + "Force delete unmerged branch" + } else { + "Remove workspace" + }; + let details = if self.force_branch { + format!( + "`{}` is not merged. Removing it will permanently delete local branch `{}`.", + self.display_name, self.branch + ) + } else { + format!( + "Remove worktree for `{}`. Git will reject the operation when the worktree has changes.", + self.display_name + ) + }; + + let mut content = Flex::column() + .with_main_axis_size(MainAxisSize::Min) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(12.) + .with_child( + Text::new_inline( + title, + appearance.ui_font_family(), + appearance.ui_font_heading_3(), + ) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(), + ) + .with_child( + Text::new_inline( + details, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(theme.sub_text_color(theme.background()).into()) + .finish(), + ); + + if !self.force_branch { + let checkbox = appearance + .ui_builder() + .checkbox(self.checkbox_mouse_state.clone(), Some(12.)) + .check(self.delete_branch) + .build() + .with_cursor(Cursor::PointingHand) + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action(DeleteWorkspaceDialogAction::ToggleDeleteBranch); + }) + .finish(); + content.add_child( + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(checkbox) + .with_child( + Container::new( + Text::new_inline( + "Also delete local branch", + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(), + ) + .with_margin_left(8.) + .finish(), + ) + .finish(), + ); + } + + let footer = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::End) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(8.) + .with_child(ChildView::new(&self.cancel_button).finish()) + .with_child(ChildView::new(&self.confirm_button).finish()) + .finish(); + Flex::column() + .with_main_axis_size(MainAxisSize::Min) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child( + Container::new(content.finish()) + .with_uniform_padding(20.) + .finish(), + ) + .with_child(Container::new(footer).with_uniform_padding(12.).finish()) + .finish() + } +} diff --git a/app/src/project_organization/view/mod.rs b/app/src/project_organization/view/mod.rs new file mode 100644 index 00000000000..81318e8ec8c --- /dev/null +++ b/app/src/project_organization/view/mod.rs @@ -0,0 +1,3 @@ +pub mod create_workspace_modal; +pub mod delete_workspace_dialog; +pub mod project_tree; diff --git a/app/src/project_organization/view/project_tree.rs b/app/src/project_organization/view/project_tree.rs new file mode 100644 index 00000000000..90aef8488c5 --- /dev/null +++ b/app/src/project_organization/view/project_tree.rs @@ -0,0 +1,1074 @@ +use std::{ + collections::{HashMap, HashSet}, + hash::Hash, +}; + +use pathfinder_geometry::vector::vec2f; +use warp_core::ui::color::coloru_with_opacity; +use warpui::{ + elements::{ + Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, + Element, Empty, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, + ParentElement, Radius, SavePosition, Shrinkable, Text, + }, + platform::Cursor, + text_layout::ClipConfig, + ui_components::components::UiComponent, + AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, + ViewHandle, +}; + +use crate::{ + appearance::Appearance, + project_organization::model::ProjectOrganizationModel, + ui_components::{ + buttons::icon_button, + icons, + spinner::{BrailleSpinner, SpinnerStateHandle}, + }, + view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme}, +}; + +use crate::project_organization::domain::{ + Repository, RepositoryId, RepositoryWorkspace, RepositoryWorkspaceId, +}; + +const WORKSPACE_TAB_COUNT_BADGE_HEIGHT: f32 = 24.; +const WORKSPACE_TAB_COUNT_BADGE_SINGLE_DIGIT_WIDTH: f32 = 24.; +const WORKSPACE_TAB_COUNT_BADGE_WIDE_WIDTH: f32 = 30.; +const WORKSPACE_RUNNING_INDICATOR_SLOT_WIDTH: f32 = 16.; +const WORKSPACE_STATUS_SLOT_GAP: f32 = 6.; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TabLayout { + Horizontal, + Vertical, +} + +/// 解析项目组织模式下的页签布局。 +/// +/// 启用 repository workspaces 时强制使用水平 TabBar, 但不会修改用户原有的 +/// Vertical Tabs 设置值。 +pub fn resolved_project_organization_tab_layout( + repository_workspaces_enabled: bool, + vertical_tabs_enabled: bool, +) -> TabLayout { + if repository_workspaces_enabled || !vertical_tabs_enabled { + TabLayout::Horizontal + } else { + TabLayout::Vertical + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorkspaceTreeNode { + pub workspace_id: RepositoryWorkspaceId, + pub display_name: String, + pub branch: String, + pub tab_count: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepositoryTreeNode { + pub repository_id: RepositoryId, + pub display_name: String, + pub expanded: bool, + pub workspaces: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectTreeRow { + Repository(RepositoryId), + Workspace(RepositoryWorkspaceId), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectTreeState { + repositories: Vec, + selected_workspace_id: Option, +} + +impl ProjectTreeState { + pub fn new(repositories: Vec) -> Self { + Self { + repositories, + selected_workspace_id: None, + } + } + + pub fn repositories(&self) -> &[RepositoryTreeNode] { + &self.repositories + } + + pub fn from_records( + repositories: Vec, + workspaces: Vec, + tab_counts: &HashMap, + ) -> Self { + let mut workspaces_by_repository = HashMap::>::new(); + for workspace in workspaces { + workspaces_by_repository + .entry(workspace.repository_id) + .or_default() + .push(workspace); + } + + let mut repositories = repositories; + repositories.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.display_name.cmp(&right.display_name)) + }); + let repositories = repositories + .into_iter() + .map(|repository| { + let mut workspaces = workspaces_by_repository + .remove(&repository.id) + .unwrap_or_default(); + workspaces.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.display_name.cmp(&right.display_name)) + }); + let workspaces = workspaces + .into_iter() + .map(|workspace| WorkspaceTreeNode { + workspace_id: workspace.id, + display_name: workspace.display_name, + branch: workspace.branch, + tab_count: tab_counts.get(&workspace.id).copied().unwrap_or_default(), + }) + .collect(); + RepositoryTreeNode { + repository_id: repository.id, + display_name: repository.display_name, + expanded: true, + workspaces, + } + }) + .collect::>(); + Self::new(repositories) + } + + pub fn visible_rows(&self) -> Vec { + self.repositories + .iter() + .flat_map(|repository| { + let repository_row = + std::iter::once(ProjectTreeRow::Repository(repository.repository_id)); + let workspace_rows = repository + .expanded + .then(|| { + repository + .workspaces + .iter() + .map(|workspace| ProjectTreeRow::Workspace(workspace.workspace_id)) + }) + .into_iter() + .flatten(); + repository_row.chain(workspace_rows) + }) + .collect() + } + + pub fn toggle_repository(&mut self, repository_id: RepositoryId) -> bool { + let Some(repository) = self + .repositories + .iter_mut() + .find(|repository| repository.repository_id == repository_id) + else { + return false; + }; + repository.expanded = !repository.expanded; + true + } + + pub fn select_workspace(&mut self, workspace_id: RepositoryWorkspaceId) -> bool { + let exists = self.repositories.iter().any(|repository| { + repository + .workspaces + .iter() + .any(|workspace| workspace.workspace_id == workspace_id) + }); + if exists { + self.selected_workspace_id = Some(workspace_id); + } + exists + } + + pub fn set_active_workspace(&mut self, workspace_id: Option) { + if let Some(workspace_id) = workspace_id { + if self.select_workspace(workspace_id) { + return; + } + } + self.selected_workspace_id = None; + } + + pub fn selected_workspace_id(&self) -> Option { + self.selected_workspace_id + } +} + +#[derive(Clone, Debug)] +pub enum ProjectTreeAction { + AddRepository, + CreateWorkspace { + repository_id: RepositoryId, + }, + DeleteWorkspace { + workspace_id: RepositoryWorkspaceId, + }, + ToggleRepository { + repository_id: RepositoryId, + }, + SelectWorkspace { + workspace_id: Option, + }, +} + +#[derive(Clone, Debug)] +pub enum ProjectTreeEvent { + AddRepositoryRequested, + CreateWorkspaceRequested { + repository_id: RepositoryId, + }, + DeleteWorkspaceRequested { + workspace_id: RepositoryWorkspaceId, + }, + WorkspaceSelected { + workspace_id: Option, + }, +} + +fn repository_add_workspace_position_id(repository_id: RepositoryId) -> String { + format!("project_tree:repository:{repository_id}:add_workspace") +} + +fn should_show_workspace_delete_button(workspace_row_hovered: bool) -> bool { + workspace_row_hovered +} + +fn workspace_row_is_selected( + selected_workspace_id: Option, + workspace_id: RepositoryWorkspaceId, +) -> bool { + selected_workspace_id == Some(workspace_id) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceVisualState { + is_selected: bool, + has_running_terminal: bool, +} + +impl WorkspaceVisualState { + pub(crate) fn new(is_selected: bool, has_running_terminal: bool) -> Self { + Self { + is_selected, + has_running_terminal, + } + } + + pub(crate) fn should_render_selection_frame(&self) -> bool { + self.is_selected + } + + pub(crate) fn should_render_running_spinner(&self) -> bool { + self.has_running_terminal + } +} + +fn workspace_count_label(workspace_count: usize) -> String { + let noun = if workspace_count == 1 { + "workspace" + } else { + "workspaces" + }; + format!("{workspace_count} {noun}") +} + +fn tab_count_badge_label(tab_count: usize) -> String { + if tab_count > 99 { + "99+".to_string() + } else { + tab_count.to_string() + } +} + +fn workspace_tab_count_badge_width(tab_count: usize) -> f32 { + if tab_count < 10 { + WORKSPACE_TAB_COUNT_BADGE_SINGLE_DIGIT_WIDTH + } else { + WORKSPACE_TAB_COUNT_BADGE_WIDE_WIDTH + } +} + +fn apply_workspace_selection_frame( + row_container: Container, + visual_state: WorkspaceVisualState, + selected_border_color: pathfinder_color::ColorU, + selected_shadow_color: pathfinder_color::ColorU, +) -> Container { + let row_container = row_container.with_border(Border::all(1.).with_border_fill( + if visual_state.should_render_selection_frame() { + selected_border_color + } else { + coloru_with_opacity(selected_border_color, 0) + }, + )); + if !visual_state.should_render_selection_frame() { + return row_container; + } + + row_container.with_drop_shadow( + DropShadow::new_with_standard_offset_and_spread(selected_shadow_color) + .with_offset(vec2f(0., 0.)), + ) +} + +fn synchronize_mouse_states(mouse_states: &mut HashMap, ids: &HashSet) +where + Id: Copy + Eq + Hash, +{ + mouse_states.retain(|id, _| ids.contains(id)); + for id in ids { + mouse_states.entry(*id).or_default(); + } +} + +/// 左侧 repository/workspace 树。 +/// +/// 该视图只维护展示和选择状态。所有 Git、持久化和页签生命周期操作均通过 +/// [`ProjectTreeEvent`] 交由窗口根处理,避免视图跨越领域边界。 +pub struct ProjectTreePanel { + project_organization_model: ModelHandle, + state: ProjectTreeState, + tab_counts: HashMap, + running_workspace_ids: HashSet, + workspace_spinner_states: HashMap, + repository_mouse_states: HashMap, + workspace_mouse_states: HashMap, + workspace_delete_mouse_states: HashMap, + repository_add_workspace_mouse_states: HashMap, + unclassified_mouse_state: MouseStateHandle, + add_repository_button: ViewHandle, +} + +impl ProjectTreePanel { + pub fn new(ctx: &mut ViewContext) -> Self { + let project_organization_model = ProjectOrganizationModel::handle(ctx); + let add_repository_button = ctx.add_view(|_| { + ActionButton::new("Add repository", SecondaryTheme) + .with_icon(icons::Icon::Plus) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(ProjectTreeAction::AddRepository)) + }); + + let mut panel = Self { + project_organization_model: project_organization_model.clone(), + state: ProjectTreeState::default(), + tab_counts: HashMap::new(), + running_workspace_ids: HashSet::new(), + workspace_spinner_states: HashMap::new(), + repository_mouse_states: HashMap::new(), + workspace_mouse_states: HashMap::new(), + workspace_delete_mouse_states: HashMap::new(), + repository_add_workspace_mouse_states: HashMap::new(), + unclassified_mouse_state: Default::default(), + add_repository_button, + }; + panel.refresh_tree(ctx); + ctx.subscribe_to_model(&project_organization_model, |panel, _, _, ctx| { + panel.refresh_tree(ctx); + }); + panel + } + + pub fn set_tab_counts( + &mut self, + tab_counts: HashMap, + ctx: &mut ViewContext, + ) { + if self.tab_counts == tab_counts { + return; + } + self.tab_counts = tab_counts; + self.refresh_tree(ctx); + } + + pub fn set_running_workspaces( + &mut self, + running_workspace_ids: HashSet, + ctx: &mut ViewContext, + ) { + if self.running_workspace_ids == running_workspace_ids { + return; + } + self.running_workspace_ids = running_workspace_ids; + ctx.notify(); + } + + pub fn set_active_workspace( + &mut self, + workspace_id: Option, + ctx: &mut ViewContext, + ) { + if self.state.selected_workspace_id() == workspace_id { + return; + } + self.state.set_active_workspace(workspace_id); + ctx.notify(); + } + + fn refresh_tree(&mut self, ctx: &mut ViewContext) { + let expanded_by_repository = self + .state + .repositories() + .iter() + .map(|repository| (repository.repository_id, repository.expanded)) + .collect::>(); + let selected_workspace_id = self.state.selected_workspace_id(); + let repositories = self + .project_organization_model + .as_ref(ctx) + .repositories() + .cloned() + .collect(); + let workspaces = self + .project_organization_model + .as_ref(ctx) + .workspaces() + .cloned() + .collect(); + + self.state = ProjectTreeState::from_records(repositories, workspaces, &self.tab_counts); + for repository in &mut self.state.repositories { + if let Some(expanded) = expanded_by_repository.get(&repository.repository_id) { + repository.expanded = *expanded; + } + } + if let Some(workspace_id) = selected_workspace_id { + self.state.select_workspace(workspace_id); + } + + let repository_ids = self + .state + .repositories() + .iter() + .map(|repository| repository.repository_id) + .collect::>(); + let workspace_ids = self + .state + .repositories() + .iter() + .flat_map(|repository| repository.workspaces.iter()) + .map(|workspace| workspace.workspace_id) + .collect::>(); + synchronize_mouse_states(&mut self.repository_mouse_states, &repository_ids); + synchronize_mouse_states( + &mut self.repository_add_workspace_mouse_states, + &repository_ids, + ); + synchronize_mouse_states(&mut self.workspace_mouse_states, &workspace_ids); + synchronize_mouse_states(&mut self.workspace_delete_mouse_states, &workspace_ids); + self.running_workspace_ids + .retain(|workspace_id| workspace_ids.contains(workspace_id)); + self.workspace_spinner_states + .retain(|workspace_id, _| workspace_ids.contains(workspace_id)); + for workspace_id in &workspace_ids { + self.workspace_spinner_states + .entry(*workspace_id) + .or_default(); + } + ctx.notify(); + } + + fn render_header(&self, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + let title = Text::new_inline( + "Repositories", + appearance.ui_font_family(), + appearance.ui_font_subheading(), + ) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(); + + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(title) + .with_child(ChildView::new(&self.add_repository_button).finish()) + .finish() + } + + fn render_repository_row( + &self, + repository: &RepositoryTreeNode, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let icon_color = if repository.expanded { + theme.main_text_color(theme.background()) + } else { + theme.sub_text_color(theme.background()) + }; + let chevron = if repository.expanded { + icons::Icon::ChevronDown + } else { + icons::Icon::ChevronRight + }; + let repository_id = repository.repository_id; + let add_workspace_action = ProjectTreeAction::CreateWorkspace { repository_id }; + let add_workspace_tooltip = appearance + .ui_builder() + .tool_tip("Create workspace".to_string()) + .build() + .finish(); + let add_workspace = icon_button( + appearance, + icons::Icon::Plus, + false, + self.repository_add_workspace_mouse_states + .get(&repository_id) + .expect("repository add-workspace mouse state should be initialized during tree refresh") + .clone(), + ) + .with_tooltip(move || add_workspace_tooltip) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(add_workspace_action.clone()); + }) + .with_cursor(Cursor::PointingHand) + .finish(); + let add_workspace_position_id = repository_add_workspace_position_id(repository_id); + let add_workspace = SavePosition::new(add_workspace, &add_workspace_position_id).finish(); + + let repository_icon = Container::new( + ConstrainedBox::new(icons::Icon::Folder.to_warpui_icon(icon_color).finish()) + .with_width(16.) + .with_height(16.) + .finish(), + ) + .with_uniform_padding(4.) + .with_background(theme.surface_overlay_1()) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .finish(); + + let workspace_count = Text::new_inline( + workspace_count_label(repository.workspaces.len()), + appearance.ui_font_family(), + appearance.ui_font_footnote(), + ) + .with_color(theme.sub_text_color(theme.background()).into()) + .finish(); + + let row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + ConstrainedBox::new(chevron.to_warpui_icon(icon_color).finish()) + .with_width(16.) + .with_height(16.) + .finish(), + ) + .with_child( + Container::new(repository_icon) + .with_margin_left(4.) + .finish(), + ) + .with_child( + Shrinkable::new( + 1.0, + Container::new( + Text::new_inline( + repository.display_name.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_clip(ClipConfig::ellipsis()) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(), + ) + .with_margin_left(8.) + .finish(), + ) + .finish(), + ) + .with_child( + Container::new(workspace_count) + .with_margin_left(12.) + .with_margin_right(8.) + .finish(), + ) + .with_child(add_workspace) + .finish(); + let toggle_action = ProjectTreeAction::ToggleRepository { repository_id }; + + Hoverable::new( + self.repository_mouse_states + .get(&repository_id) + .expect("repository row mouse state should be initialized during tree refresh") + .clone(), + move |mouse_state| { + let mut container = Container::new(row) + .with_horizontal_padding(8.) + .with_vertical_padding(6.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); + if mouse_state.is_hovered() { + container = container.with_background(theme.surface_overlay_1()); + } + container.finish() + }, + ) + .with_cursor(Cursor::PointingHand) + .with_defer_events_to_children() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(toggle_action.clone()); + }) + .finish() + } + + fn render_workspace_activity_badge( + &self, + tab_count: usize, + visual_state: WorkspaceVisualState, + workspace_id: RepositoryWorkspaceId, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let metadata_color = theme.sub_text_color(theme.background()); + let running_color: pathfinder_color::ColorU = theme.terminal_colors().normal.green.into(); + let badge_background = if visual_state.should_render_running_spinner() { + coloru_with_opacity(running_color, 14).into() + } else { + theme.surface_2() + }; + let border_fill = if visual_state.should_render_running_spinner() { + coloru_with_opacity(running_color, 42).into() + } else { + theme.surface_3() + }; + + let spinner = if visual_state.should_render_running_spinner() { + let spinner_state = self + .workspace_spinner_states + .get(&workspace_id) + .expect("workspace spinner state should be initialized during tree refresh") + .clone(); + Box::new(BrailleSpinner::new( + appearance.ui_font_family(), + appearance.ui_font_footnote(), + running_color, + spinner_state, + )) as Box + } else { + Empty::new().finish() + }; + let spinner_slot = ConstrainedBox::new(spinner) + .with_width(WORKSPACE_RUNNING_INDICATOR_SLOT_WIDTH) + .with_height(WORKSPACE_TAB_COUNT_BADGE_HEIGHT) + .finish(); + + let count = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::Center) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Text::new_inline( + tab_count_badge_label(tab_count), + appearance.ui_font_family(), + appearance.ui_font_footnote(), + ) + .with_color(metadata_color.into()) + .finish(), + ) + .finish(); + let count_badge = Container::new( + ConstrainedBox::new(count) + .with_width(workspace_tab_count_badge_width(tab_count)) + .with_height(WORKSPACE_TAB_COUNT_BADGE_HEIGHT) + .finish(), + ) + .with_background(badge_background) + .with_border(Border::all(1.).with_border_fill(border_fill)) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(12.))); + let count_badge = if visual_state.should_render_running_spinner() { + count_badge.with_drop_shadow( + DropShadow::new_with_standard_offset_and_spread(coloru_with_opacity( + running_color, + 30, + )) + .with_offset(vec2f(0., 0.)), + ) + } else { + count_badge + }; + + ConstrainedBox::new( + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::End) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(WORKSPACE_STATUS_SLOT_GAP) + .with_child(spinner_slot) + .with_child(count_badge.finish()) + .finish(), + ) + .with_width( + WORKSPACE_RUNNING_INDICATOR_SLOT_WIDTH + + WORKSPACE_STATUS_SLOT_GAP + + workspace_tab_count_badge_width(tab_count), + ) + .with_height(WORKSPACE_TAB_COUNT_BADGE_HEIGHT) + .finish() + } + + fn render_workspace_row( + &self, + workspace: &WorkspaceTreeNode, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let selected = + workspace_row_is_selected(self.state.selected_workspace_id(), workspace.workspace_id); + let selection_accent = theme.accent(); + let label_color = theme.main_text_color(theme.background()); + let metadata_color = theme.sub_text_color(theme.background()); + let workspace_id = workspace.workspace_id; + let action = ProjectTreeAction::SelectWorkspace { + workspace_id: Some(workspace_id), + }; + let delete_action = ProjectTreeAction::DeleteWorkspace { workspace_id }; + let branch = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(3.) + .with_child( + ConstrainedBox::new( + icons::Icon::GitBranch + .to_warpui_icon(metadata_color) + .finish(), + ) + .with_width(12.) + .with_height(12.) + .finish(), + ) + .with_child( + Shrinkable::new( + 1., + Text::new_inline( + workspace.branch.clone(), + appearance.ui_font_family(), + appearance.ui_font_footnote(), + ) + .with_clip(ClipConfig::ellipsis()) + .with_color(metadata_color.into()) + .finish(), + ) + .finish(), + ) + .finish(); + let content = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Start) + .with_spacing(2.) + .with_child( + Text::new_inline( + workspace.display_name.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_clip(ClipConfig::ellipsis()) + .with_color(label_color.into()) + .finish(), + ) + .with_child(branch) + .finish(); + + let visual_state = WorkspaceVisualState::new( + selected, + self.running_workspace_ids.contains(&workspace.workspace_id), + ); + let activity_badge = self.render_workspace_activity_badge( + workspace.tab_count, + visual_state, + workspace_id, + appearance, + ); + let selected_color: pathfinder_color::ColorU = theme.terminal_colors().normal.blue.into(); + let selected_border_color = coloru_with_opacity(selected_color, 58); + let selected_shadow_color = coloru_with_opacity(selected_color, 34); + + let delete_tooltip = appearance + .ui_builder() + .tool_tip("Remove workspace".to_string()) + .build() + .finish(); + let delete = icon_button( + appearance, + icons::Icon::Trash, + false, + self.workspace_delete_mouse_states + .get(&workspace_id) + .expect("workspace delete mouse state should be initialized during tree refresh") + .clone(), + ) + .with_tooltip(move || delete_tooltip) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(delete_action.clone()); + }) + .with_cursor(Cursor::PointingHand) + .finish(); + let delete_placeholder = ConstrainedBox::new(Empty::new().finish()) + .with_width(icons::ICON_DIMENSIONS) + .with_height(icons::ICON_DIMENSIONS) + .finish(); + + Hoverable::new( + self.workspace_mouse_states + .get(&workspace_id) + .expect("workspace row mouse state should be initialized during tree refresh") + .clone(), + move |mouse_state| { + let delete = if should_show_workspace_delete_button(mouse_state.is_hovered()) { + delete + } else { + delete_placeholder + }; + let row_content = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(Shrinkable::new(1.0, content).finish()) + .with_child( + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(8.) + .with_child(activity_badge) + .with_child(delete) + .finish(), + ) + .finish(); + let mut row_container = Container::new(row_content) + .with_horizontal_padding(8.) + .with_vertical_padding(6.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); + if selected { + row_container = + row_container.with_background(selection_accent.with_opacity(10)); + } else if mouse_state.is_hovered() { + row_container = row_container.with_background(theme.surface_overlay_2()); + } else { + row_container = row_container.with_background(theme.surface_overlay_1()); + } + row_container = apply_workspace_selection_frame( + row_container, + visual_state, + selected_border_color, + selected_shadow_color, + ); + + let indicator = Container::new( + ConstrainedBox::new(Empty::new().finish()) + .with_width(2.) + .finish(), + ); + let indicator = if selected { + indicator + .with_background(selection_accent) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(1.))) + } else { + indicator + }; + + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(indicator.finish()) + .with_child( + Shrinkable::new( + 1.0, + Container::new(row_container.finish()) + .with_margin_left(4.) + .finish(), + ) + .finish(), + ) + .finish() + }, + ) + .with_cursor(Cursor::PointingHand) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(action.clone()); + }) + .finish() + } + + fn render_unclassified_row(&self, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + let action = ProjectTreeAction::SelectWorkspace { workspace_id: None }; + let content = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + ConstrainedBox::new( + icons::Icon::Terminal + .to_warpui_icon(theme.sub_text_color(theme.background())) + .finish(), + ) + .with_width(16.) + .with_height(16.) + .finish(), + ) + .with_child( + Container::new( + Text::new_inline( + "Unclassified tabs", + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(), + ) + .with_margin_left(8.) + .finish(), + ) + .finish(); + + Hoverable::new(self.unclassified_mouse_state.clone(), move |mouse_state| { + let mut container = Container::new(content) + .with_horizontal_padding(8.) + .with_vertical_padding(6.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); + if mouse_state.is_hovered() { + container = container.with_background(theme.surface_overlay_1()); + } + container.finish() + }) + .with_cursor(Cursor::PointingHand) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(action.clone()); + }) + .finish() + } +} + +impl Entity for ProjectTreePanel { + type Event = ProjectTreeEvent; +} + +impl TypedActionView for ProjectTreePanel { + type Action = ProjectTreeAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + ProjectTreeAction::AddRepository => ctx.emit(ProjectTreeEvent::AddRepositoryRequested), + ProjectTreeAction::CreateWorkspace { repository_id } => { + ctx.emit(ProjectTreeEvent::CreateWorkspaceRequested { + repository_id: *repository_id, + }); + } + ProjectTreeAction::DeleteWorkspace { workspace_id } => { + ctx.emit(ProjectTreeEvent::DeleteWorkspaceRequested { + workspace_id: *workspace_id, + }); + } + ProjectTreeAction::ToggleRepository { repository_id } => { + self.state.toggle_repository(*repository_id); + ctx.notify(); + } + ProjectTreeAction::SelectWorkspace { workspace_id } => { + if let Some(workspace_id) = workspace_id { + self.state.select_workspace(*workspace_id); + } else { + self.state.selected_workspace_id = None; + } + ctx.emit(ProjectTreeEvent::WorkspaceSelected { + workspace_id: *workspace_id, + }); + ctx.notify(); + } + } + } +} + +impl View for ProjectTreePanel { + fn ui_name() -> &'static str { + "ProjectTreePanel" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let mut tree = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(8.); + for repository in self.state.repositories() { + let mut repository_group = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(self.render_repository_row(repository, appearance)); + if repository.expanded { + let mut workspaces = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(2.); + for workspace in &repository.workspaces { + workspaces.add_child(self.render_workspace_row(workspace, appearance)); + } + repository_group.add_child( + Container::new(workspaces.finish()) + .with_margin_left(22.) + .with_margin_top(2.) + .finish(), + ); + } + tree.add_child(repository_group.finish()); + } + + let body: Box = if self.state.repositories().is_empty() { + Container::new( + Text::new_inline( + "Add a local Git repository to create workspaces.", + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color( + appearance + .theme() + .sub_text_color(appearance.theme().background()) + .into(), + ) + .finish(), + ) + .with_uniform_padding(8.) + .finish() + } else { + Container::new(tree.finish()) + .with_horizontal_padding(4.) + .with_vertical_padding(6.) + .finish() + }; + + Flex::column() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child( + Container::new(self.render_header(appearance)) + .with_horizontal_padding(12.) + .with_vertical_padding(10.) + .with_border(Border::bottom(1.).with_border_fill(theme.surface_2())) + .finish(), + ) + .with_child(Shrinkable::new(1.0, body).finish()) + .with_child( + Container::new(self.render_unclassified_row(appearance)) + .with_uniform_padding(8.) + .with_border(Border::top(1.).with_border_fill(theme.surface_2())) + .finish(), + ) + .finish() + } +} + +#[cfg(test)] +#[path = "project_tree_tests.rs"] +mod tests; diff --git a/app/src/project_organization/view/project_tree_tests.rs b/app/src/project_organization/view/project_tree_tests.rs new file mode 100644 index 00000000000..88bce9ad042 --- /dev/null +++ b/app/src/project_organization/view/project_tree_tests.rs @@ -0,0 +1,492 @@ +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + rc::Rc, + sync::Arc, +}; + +use pathfinder_geometry::vector::vec2f; +use warp_core::ui::appearance::Appearance; +use warpui::{ + elements::{ChildView, MouseStateHandle, ParentElement, Stack}, + platform::WindowStyle, + App, Element, Entity, Event, Presenter, TypedActionView, View, ViewContext, ViewHandle, + WindowInvalidation, +}; + +use crate::{ + persistence::{ + model::{ + Repository as PersistedRepository, RepositoryWorkspace as PersistedRepositoryWorkspace, + }, + RepositoryPersistence, + }, + project_organization::model::ProjectOrganizationModel, +}; + +use crate::project_organization::domain::{ + Repository, RepositoryId, RepositorySource, RepositoryWorkspace, RepositoryWorkspaceId, +}; + +use super::{ + repository_add_workspace_position_id, resolved_project_organization_tab_layout, + should_show_workspace_delete_button, synchronize_mouse_states, tab_count_badge_label, + workspace_count_label, workspace_row_is_selected, workspace_tab_count_badge_width, + ProjectTreeEvent, ProjectTreePanel, ProjectTreeState, RepositoryTreeNode, TabLayout, + WorkspaceTreeNode, WorkspaceVisualState, +}; + +struct ProjectTreeTestHost { + project_tree: ViewHandle, + events: Rc>>, +} + +impl ProjectTreeTestHost { + fn new(ctx: &mut ViewContext) -> Self { + let events = Rc::new(RefCell::new(Vec::new())); + let captured_events = events.clone(); + let project_tree = ctx.add_typed_action_view(ProjectTreePanel::new); + ctx.subscribe_to_view(&project_tree, move |_, _, event, _| { + captured_events.borrow_mut().push(event.clone()); + }); + Self { + project_tree, + events, + } + } +} + +impl Entity for ProjectTreeTestHost { + type Event = (); +} + +impl View for ProjectTreeTestHost { + fn ui_name() -> &'static str { + "ProjectTreeTestHost" + } + + fn render(&self, _app: &warpui::AppContext) -> Box { + Stack::new() + .with_child(ChildView::new(&self.project_tree).finish()) + .finish() + } +} + +impl TypedActionView for ProjectTreeTestHost { + type Action = (); +} + +#[test] +fn tree_renders_two_levels_and_selects_workspace() { + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut state = ProjectTreeState::new(vec![RepositoryTreeNode { + repository_id, + display_name: "zap".to_string(), + expanded: true, + workspaces: vec![WorkspaceTreeNode { + workspace_id, + display_name: "Feature A".to_string(), + branch: "feature/a".to_string(), + tab_count: 2, + }], + }]); + + assert_eq!(state.visible_rows().len(), 2); + state.select_workspace(workspace_id); + assert_eq!(state.selected_workspace_id(), Some(workspace_id)); +} + +#[test] +fn repository_workspace_mode_keeps_setting_but_uses_horizontal_tabbar() { + assert_eq!( + resolved_project_organization_tab_layout(true, true), + TabLayout::Horizontal + ); + assert_eq!( + resolved_project_organization_tab_layout(false, true), + TabLayout::Vertical + ); +} + +#[test] +fn tree_sorts_repositories_and_workspaces_by_creation_time() { + let repository_a = RepositoryId(uuid::Uuid::from_u128(1)); + let repository_b = RepositoryId(uuid::Uuid::from_u128(2)); + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(3)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(4)); + let earlier = chrono::DateTime::from_timestamp(0, 0).unwrap().naive_utc(); + let later = chrono::DateTime::from_timestamp(1, 0).unwrap().naive_utc(); + + let state = ProjectTreeState::from_records( + vec![ + Repository { + id: repository_b, + display_name: "zebra".to_string(), + path: "/tmp/zebra".into(), + remote_url: None, + source: RepositorySource::Local, + created_at: earlier, + last_opened_at: earlier, + }, + Repository { + id: repository_a, + display_name: "alpha".to_string(), + path: "/tmp/alpha".into(), + remote_url: None, + source: RepositorySource::Local, + created_at: later, + last_opened_at: later, + }, + ], + vec![ + RepositoryWorkspace { + id: workspace_a, + repository_id: repository_a, + display_name: "zeta".to_string(), + branch: "feature/zeta".to_string(), + worktree_path: "/tmp/alpha-zeta".into(), + created_at: earlier, + last_opened_at: earlier, + }, + RepositoryWorkspace { + id: workspace_b, + repository_id: repository_a, + display_name: "beta".to_string(), + branch: "feature/beta".to_string(), + worktree_path: "/tmp/alpha-beta".into(), + created_at: later, + last_opened_at: later, + }, + ], + &[(workspace_a, 2), (workspace_b, 1)].into_iter().collect(), + ); + + assert_eq!(state.repositories()[0].display_name, "zebra"); + assert_eq!(state.repositories()[1].display_name, "alpha"); + assert_eq!(state.repositories()[1].workspaces[0].display_name, "zeta"); + assert_eq!(state.repositories()[1].workspaces[0].tab_count, 2); + assert_eq!(state.repositories()[1].workspaces[1].display_name, "beta"); + assert_eq!(state.repositories()[1].workspaces[1].tab_count, 1); +} + +#[test] +fn tree_can_clear_and_restore_the_active_workspace_selection() { + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut state = ProjectTreeState::new(vec![RepositoryTreeNode { + repository_id, + display_name: "zap".to_string(), + expanded: true, + workspaces: vec![WorkspaceTreeNode { + workspace_id, + display_name: "Feature A".to_string(), + branch: "feature/a".to_string(), + tab_count: 0, + }], + }]); + + state.set_active_workspace(Some(workspace_id)); + assert_eq!(state.selected_workspace_id(), Some(workspace_id)); + + state.set_active_workspace(None); + assert_eq!(state.selected_workspace_id(), None); +} + +#[test] +fn synchronize_mouse_states_removes_stale_entries_and_preserves_existing_handles() { + let retained_id = RepositoryId(uuid::Uuid::from_u128(1)); + let stale_id = RepositoryId(uuid::Uuid::from_u128(2)); + let retained_handle = MouseStateHandle::default(); + let mut mouse_states = HashMap::from([ + (retained_id, retained_handle.clone()), + (stale_id, MouseStateHandle::default()), + ]); + + synchronize_mouse_states(&mut mouse_states, &HashSet::from([retained_id])); + + assert_eq!(mouse_states.len(), 1); + assert!(!mouse_states.contains_key(&stale_id)); + assert!(Arc::ptr_eq( + &retained_handle, + mouse_states + .get(&retained_id) + .expect("retained mouse state should remain cached"), + )); +} + +#[test] +fn workspace_delete_button_only_shows_when_workspace_row_is_hovered() { + assert!(!should_show_workspace_delete_button(false)); + assert!(should_show_workspace_delete_button(true)); +} + +#[test] +fn workspace_row_selection_matches_only_the_active_workspace() { + let selected = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let other = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + + assert!(workspace_row_is_selected(Some(selected), selected)); + assert!(!workspace_row_is_selected(Some(selected), other)); + assert!(!workspace_row_is_selected(None, selected)); +} + +#[test] +fn project_tree_count_labels_use_correct_singular_and_plural_forms() { + assert_eq!(workspace_count_label(0), "0 workspaces"); + assert_eq!(workspace_count_label(1), "1 workspace"); + assert_eq!(workspace_count_label(2), "2 workspaces"); +} + +#[test] +fn tab_count_badge_label_is_numeric_and_caps_large_counts() { + assert_eq!(tab_count_badge_label(0), "0"); + assert_eq!(tab_count_badge_label(1), "1"); + assert_eq!(tab_count_badge_label(99), "99"); + assert_eq!(tab_count_badge_label(100), "99+"); +} + +#[test] +fn tab_count_badge_width_keeps_single_digit_counts_circular() { + assert_eq!(workspace_tab_count_badge_width(1), 24.); + assert_eq!(workspace_tab_count_badge_width(9), 24.); + assert_eq!(workspace_tab_count_badge_width(10), 30.); + assert_eq!(workspace_tab_count_badge_width(100), 30.); +} + +#[test] +fn workspace_visual_state_keeps_selection_and_running_separate() { + let selected_static = WorkspaceVisualState::new(true, false); + assert!(selected_static.should_render_selection_frame()); + assert!(!selected_static.should_render_running_spinner()); + + let running_unselected = WorkspaceVisualState::new(false, true); + assert!(!running_unselected.should_render_selection_frame()); + assert!(running_unselected.should_render_running_spinner()); + + let selected_running = WorkspaceVisualState::new(true, true); + assert!(selected_running.should_render_selection_frame()); + assert!(selected_running.should_render_running_spinner()); +} + +#[test] +fn project_tree_renders_workspace_rows_with_finite_flex_constraints() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let tempdir = tempfile::tempdir().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("dip-agent"); + let worktree_path = tempdir.path().join("feature-worktree"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + std::fs::create_dir(&worktree_path).expect("worktree directory should be created"); + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let timestamp = chrono::DateTime::from_timestamp(0, 0) + .expect("timestamp should be valid") + .naive_utc(); + app.add_singleton_model(|ctx| { + ProjectOrganizationModel::try_new( + vec![PersistedRepository { + id: repository_id.to_string(), + display_name: "dip-agent".to_string(), + path: repository_path.to_string_lossy().to_string(), + remote_url: None, + source: "local".to_string(), + created_at: timestamp, + last_opened_at: timestamp, + }], + vec![PersistedRepositoryWorkspace { + id: workspace_id.to_string(), + repository_id: repository_id.to_string(), + display_name: "feature-workspace".to_string(), + branch: "feature/workspace".to_string(), + worktree_path: worktree_path.to_string_lossy().to_string(), + created_at: timestamp, + last_opened_at: timestamp, + }], + RepositoryPersistence::new(None), + ctx, + ) + .expect("project organization model should initialize") + }); + + let (window_id, host) = + app.add_window(WindowStyle::NotStealFocus, ProjectTreeTestHost::new); + let project_tree = host.read(&app, |host, _| host.project_tree.clone()); + let root_view_id = app + .root_view_id(window_id) + .expect("window should have a root view"); + let mut presenter = Presenter::new(window_id); + + app.update(|ctx| { + presenter.invalidate( + WindowInvalidation { + updated: [root_view_id, project_tree.id()].into_iter().collect(), + ..Default::default() + }, + ctx, + ); + presenter.build_scene(vec2f(320., 240.), 1., None, ctx); + }); + }); +} + +#[test] +fn project_tree_renders_running_selected_workspace_activity_badge() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let tempdir = tempfile::tempdir().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("dip-agent"); + let worktree_path = tempdir.path().join("feature-worktree"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + std::fs::create_dir(&worktree_path).expect("worktree directory should be created"); + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let timestamp = chrono::DateTime::from_timestamp(0, 0) + .expect("timestamp should be valid") + .naive_utc(); + app.add_singleton_model(|ctx| { + ProjectOrganizationModel::try_new( + vec![PersistedRepository { + id: repository_id.to_string(), + display_name: "dip-agent".to_string(), + path: repository_path.to_string_lossy().to_string(), + remote_url: None, + source: "local".to_string(), + created_at: timestamp, + last_opened_at: timestamp, + }], + vec![PersistedRepositoryWorkspace { + id: workspace_id.to_string(), + repository_id: repository_id.to_string(), + display_name: "feature-workspace".to_string(), + branch: "feature/workspace".to_string(), + worktree_path: worktree_path.to_string_lossy().to_string(), + created_at: timestamp, + last_opened_at: timestamp, + }], + RepositoryPersistence::new(None), + ctx, + ) + .expect("project organization model should initialize") + }); + + let (window_id, host) = + app.add_window(WindowStyle::NotStealFocus, ProjectTreeTestHost::new); + let project_tree = host.read(&app, |host, _| host.project_tree.clone()); + project_tree.update(&mut app, |project_tree, ctx| { + project_tree.set_tab_counts(HashMap::from([(workspace_id, 3)]), ctx); + project_tree.set_active_workspace(Some(workspace_id), ctx); + project_tree.set_running_workspaces(HashSet::from([workspace_id]), ctx); + }); + let root_view_id = app + .root_view_id(window_id) + .expect("window should have a root view"); + let mut presenter = Presenter::new(window_id); + + app.update(|ctx| { + presenter.invalidate( + WindowInvalidation { + updated: [root_view_id, project_tree.id()].into_iter().collect(), + ..Default::default() + }, + ctx, + ); + presenter.build_scene(vec2f(360., 240.), 1., None, ctx); + }); + }); +} + +#[test] +fn create_workspace_button_does_not_toggle_its_repository() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let tempdir = tempfile::tempdir().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("dip-agent"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let timestamp = chrono::DateTime::from_timestamp(0, 0) + .expect("timestamp should be valid") + .naive_utc(); + app.add_singleton_model(|ctx| { + ProjectOrganizationModel::try_new( + vec![PersistedRepository { + id: repository_id.to_string(), + display_name: "dip-agent".to_string(), + path: repository_path.to_string_lossy().to_string(), + remote_url: None, + source: "local".to_string(), + created_at: timestamp, + last_opened_at: timestamp, + }], + vec![], + RepositoryPersistence::new(None), + ctx, + ) + .expect("project organization model should initialize") + }); + + let (window_id, host) = + app.add_window(WindowStyle::NotStealFocus, ProjectTreeTestHost::new); + let (project_tree, events) = host.read(&app, |host, _| { + (host.project_tree.clone(), host.events.clone()) + }); + let root_view_id = app + .root_view_id(window_id) + .expect("window should have a root view"); + let mut presenter = Presenter::new(window_id); + let invalidation = WindowInvalidation { + updated: [root_view_id, project_tree.id()].into_iter().collect(), + ..Default::default() + }; + + app.update(|ctx| { + presenter.invalidate(invalidation, ctx); + presenter.build_scene(vec2f(320., 160.), 1., None, ctx); + let button_position_id = repository_add_workspace_position_id(repository_id); + let click_position = presenter + .position_cache() + .get_position(&button_position_id) + .expect("create workspace button should have a saved position") + .center(); + let presenter = Rc::new(RefCell::new(presenter)); + ctx.simulate_window_event( + Event::LeftMouseDown { + position: click_position, + modifiers: Default::default(), + click_count: 1, + is_first_mouse: false, + }, + window_id, + presenter.clone(), + ); + presenter.borrow_mut().invalidate( + WindowInvalidation { + updated: [root_view_id, project_tree.id()].into_iter().collect(), + ..Default::default() + }, + ctx, + ); + presenter + .borrow_mut() + .build_scene(vec2f(320., 160.), 1., None, ctx); + ctx.simulate_window_event( + Event::LeftMouseUp { + position: click_position, + modifiers: Default::default(), + }, + window_id, + presenter, + ); + }); + + assert!(matches!( + events.borrow().as_slice(), + [ProjectTreeEvent::CreateWorkspaceRequested { + repository_id: event_repository_id + }] if *event_repository_id == repository_id + )); + project_tree.read(&app, |project_tree, _| { + assert!(project_tree.state.repositories()[0].expanded); + }); + }); +} diff --git a/app/src/projects.rs b/app/src/projects.rs deleted file mode 100644 index c9ea9e512ee..00000000000 --- a/app/src/projects.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::sync::mpsc::SyncSender; -use std::{collections::HashMap, path::PathBuf}; - -use chrono::Utc; -use warpui::{Entity, ModelContext, SingletonEntity}; - -use crate::persistence::{model::Project, ModelEvent}; - -#[derive(Debug)] -pub enum ProjectEvent { - Added { - #[expect(unused, reason = "TODO(jparker): #pod-code-mode wip")] - path: PathBuf, - }, - #[expect(unused, reason = "TODO(jparker): #pod-code-mode wip")] - Removed { path: PathBuf }, - #[expect(unused, reason = "TODO(jparker): #pod-code-mode wip")] - Updated { path: PathBuf }, -} - -pub struct ProjectManagementModel { - projects: HashMap, - model_event_sender: Option>, -} - -impl Entity for ProjectManagementModel { - type Event = ProjectEvent; -} - -impl SingletonEntity for ProjectManagementModel {} - -impl ProjectManagementModel { - /// Create a new Projects model with persisted data - pub fn new( - persisted_projects: Vec, - model_event_sender: Option>, - _ctx: &mut ModelContext, - ) -> Self { - log::debug!("Loading {} persisted projects", persisted_projects.len()); - - let projects = persisted_projects - .into_iter() - .map(|project| (PathBuf::from(&project.path), project)) - .collect(); - - Self { - projects, - model_event_sender, - } - } - - /// Add a project to the list. If it already exists, update the last_opened_ts. - pub fn upsert_project(&mut self, path: PathBuf, ctx: &mut ModelContext) { - let now = Utc::now().naive_utc(); - - let project = if let Some(existing_project) = self.projects.get_mut(&path) { - // Update existing project's last opened time - existing_project.last_opened_ts = Some(now); - existing_project.clone() - } else { - // Create new project - let project = Project { - path: path.to_string_lossy().to_string(), - added_ts: now, - last_opened_ts: Some(now), - }; - self.projects.insert(path.clone(), project.clone()); - project - }; - self.save_project(project); - ctx.emit(ProjectEvent::Added { path }); - } - - pub fn all_projects(&self) -> impl Iterator { - self.projects.values() - } - - /// Save a project to the database - fn save_project(&self, project: Project) { - if let Some(sender) = &self.model_event_sender { - let event = ModelEvent::UpsertProject { project }; - if let Err(err) = sender.send(event) { - log::error!("Failed to save project to database: {err}"); - } - } - } -} diff --git a/app/src/remote_server/server_model.rs b/app/src/remote_server/server_model.rs index 9f1ccb84f00..700d44947fc 100644 --- a/app/src/remote_server/server_model.rs +++ b/app/src/remote_server/server_model.rs @@ -655,7 +655,9 @@ impl ServerModel { #[cfg(feature = "local_fs")] Some(client_message::Message::ResolvePath(msg)) => self.handle_resolve_path(msg), #[cfg(feature = "local_fs")] - Some(client_message::Message::CreateDirectory(msg)) => self.handle_create_directory(msg), + Some(client_message::Message::CreateDirectory(msg)) => { + self.handle_create_directory(msg) + } #[cfg(feature = "local_fs")] Some(client_message::Message::ReadFileChunk(msg)) => self.handle_read_file_chunk(msg), #[cfg(feature = "local_fs")] @@ -1525,8 +1527,7 @@ impl ServerModel { let metadata = entry.metadata().ok(); let kind = entry_kind(file_type.as_ref(), metadata.as_ref()); let is_dir = kind == FileSystemEntryKind::Directory as i32; - let size_bytes = - metadata.as_ref().filter(|m| m.is_file()).map(|m| m.len()); + let size_bytes = metadata.as_ref().filter(|m| m.is_file()).map(|m| m.len()); let modified_epoch_millis = metadata .as_ref() .and_then(|m| m.modified().ok()) @@ -1732,10 +1733,7 @@ fn expand_user_path(path: &str) -> PathBuf { } #[cfg(feature = "local_fs")] -fn entry_kind( - file_type: Option<&std::fs::FileType>, - metadata: Option<&std::fs::Metadata>, -) -> i32 { +fn entry_kind(file_type: Option<&std::fs::FileType>, metadata: Option<&std::fs::Metadata>) -> i32 { if file_type.is_some_and(|ft| ft.is_symlink()) { return FileSystemEntryKind::Symlink as i32; } diff --git a/app/src/remote_server/server_model_tests.rs b/app/src/remote_server/server_model_tests.rs index cccc4c12d11..09e9d6d92d0 100644 --- a/app/src/remote_server/server_model_tests.rs +++ b/app/src/remote_server/server_model_tests.rs @@ -128,7 +128,10 @@ fn resolve_path_reports_file_metadata() { success.canonical_path, fs::canonicalize(&file_path).unwrap().to_string_lossy() ); - assert_eq!(success.kind, super::super::proto::FileSystemEntryKind::File as i32); + assert_eq!( + success.kind, + super::super::proto::FileSystemEntryKind::File as i32 + ); assert_eq!(success.size_bytes, Some(5)); } diff --git a/app/src/root_view.rs b/app/src/root_view.rs index a72c5a27d01..2fc35a24474 100644 --- a/app/src/root_view.rs +++ b/app/src/root_view.rs @@ -2436,9 +2436,7 @@ impl RootView { return; }; - if FeatureFlag::ZapNewSettingsModes.is_enabled() - && FeatureFlag::TabConfigs.is_enabled() - { + if FeatureFlag::ZapNewSettingsModes.is_enabled() && FeatureFlag::TabConfigs.is_enabled() { let intention = tutorial.intention(); // Terminal-intent users skip the session config modal. if matches!(intention, OnboardingIntention::AgentDrivenDevelopment) { diff --git a/app/src/search/command_search/projects/project_data_source.rs b/app/src/search/command_search/projects/project_data_source.rs index b80ee9f003c..0076bd70a33 100644 --- a/app/src/search/command_search/projects/project_data_source.rs +++ b/app/src/search/command_search/projects/project_data_source.rs @@ -3,21 +3,21 @@ use itertools::Itertools; use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity}; use super::ProjectSearchItem; -use crate::projects::ProjectManagementModel; +use crate::project_organization::model::ProjectOrganizationModel; use crate::search::command_palette::mixer::CommandPaletteItemAction; use crate::search::data_source::{Query, QueryResult}; use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource}; /// Manages querying projects for Command Search (existing, user-added projects only). pub struct ProjectDataSource { - project_model: ModelHandle, + project_model: ModelHandle, } impl ProjectDataSource { /// Creates a new ProjectsDataSource with access to the project management model. pub fn new(app: &mut ModelContext) -> Self { Self { - project_model: ProjectManagementModel::handle(app), + project_model: ProjectOrganizationModel::handle(app), } } @@ -25,12 +25,16 @@ impl ProjectDataSource { // Create search items and sort them using the Ord implementation self.project_model .as_ref(app) - .all_projects() + .repositories() .map(|project| { ProjectSearchItem::new( - project.path.clone(), + project + .path + .to_str() + .expect("repository paths are validated as UTF-8 before insertion") + .to_string(), fuzzy_match::FuzzyMatchResult::no_match(), - project.last_used_at(), + project.last_opened_at, ) }) .k_largest(limit) @@ -56,7 +60,7 @@ impl SyncDataSource for ProjectDataSource { let projects = self .project_model .as_ref(app) - .all_projects() + .repositories() .collect::>(); // Create search items and sort them using the Ord implementation @@ -64,9 +68,13 @@ impl SyncDataSource for ProjectDataSource { .into_iter() .filter_map(|project| { let mut search_item = ProjectSearchItem::new( - project.path.clone(), + project + .path + .to_str() + .expect("repository paths are validated as UTF-8 before insertion") + .to_string(), fuzzy_match::FuzzyMatchResult::no_match(), - project.last_used_at(), + project.last_opened_at, ); // Perform fuzzy matching on the project display name diff --git a/app/src/server/telemetry.rs b/app/src/server/telemetry.rs index 954077665f9..41bd10d2432 100644 --- a/app/src/server/telemetry.rs +++ b/app/src/server/telemetry.rs @@ -2931,9 +2931,7 @@ impl TelemetryEvent { ("warp_markdown_viewer", Some(*layout), None) } FileTarget::CodeEditor(layout) => ("warp_code_editor", Some(*layout), None), - FileTarget::ImageViewer(layout) => { - ("warp_image_viewer", Some(*layout), None) - } + FileTarget::ImageViewer(layout) => ("warp_image_viewer", Some(*layout), None), FileTarget::EnvEditor => ("env_editor", None, None), FileTarget::SystemDefault => ("system_default", None, None), FileTarget::SystemGeneric => ("system_generic", None, None), diff --git a/app/src/settings/cloud_sync_secrets.rs b/app/src/settings/cloud_sync_secrets.rs index 532e91d6626..e9f1963c805 100644 --- a/app/src/settings/cloud_sync_secrets.rs +++ b/app/src/settings/cloud_sync_secrets.rs @@ -149,7 +149,9 @@ mod tests { let mut map = HashMap::new(); map.insert(GITHUB_TOKEN_KEY.to_string(), "old_token".to_string()); let mut store = make_store(map); - store.tokens.insert(GITHUB_TOKEN_KEY.to_string(), "new_token".to_string()); + store + .tokens + .insert(GITHUB_TOKEN_KEY.to_string(), "new_token".to_string()); assert_eq!(store.get(GITHUB_TOKEN_KEY), Some("new_token")); } @@ -205,6 +207,9 @@ mod tests { #[test] fn test_event_equality() { - assert_eq!(CloudSyncTokenStoreEvent::TokensChanged, CloudSyncTokenStoreEvent::TokensChanged); + assert_eq!( + CloudSyncTokenStoreEvent::TokensChanged, + CloudSyncTokenStoreEvent::TokensChanged + ); } } diff --git a/app/src/settings/font.rs b/app/src/settings/font.rs index f44e707c32a..45e594f9e74 100644 --- a/app/src/settings/font.rs +++ b/app/src/settings/font.rs @@ -2,9 +2,7 @@ use warp_core::ui::builder::MIN_FONT_SIZE; // 重导出底层 warp_core 定义的 UI 字号常量, // 使 `crate::settings::DEFAULT_UI_FONT_SIZE` / `UI_FONT_SIZE_MIN` / `UI_FONT_SIZE_MAX` 仍可用。 -pub use warp_core::ui::appearance::{ - DEFAULT_UI_FONT_SIZE, UI_FONT_SIZE_MAX, UI_FONT_SIZE_MIN, -}; +pub use warp_core::ui::appearance::{DEFAULT_UI_FONT_SIZE, UI_FONT_SIZE_MAX, UI_FONT_SIZE_MIN}; use warpui::{fonts::Weight, rendering::ThinStrokes, AppContext, SingletonEntity}; use settings::{ diff --git a/app/src/settings/import/view.rs b/app/src/settings/import/view.rs index ccb60a117f8..a8a83f49383 100644 --- a/app/src/settings/import/view.rs +++ b/app/src/settings/import/view.rs @@ -316,9 +316,13 @@ impl SettingsImportView { let font_family = appearance.monospace_font_family(); let font_color = blended_colors::text_sub(theme, theme.background()); let description = Container::new( - Text::new_inline(setting.setting_type.get_name(), font_family, appearance.ui_font_subheading()) - .with_color(font_color) - .finish(), + Text::new_inline( + setting.setting_type.get_name(), + font_family, + appearance.ui_font_subheading(), + ) + .with_color(font_color) + .finish(), ) .with_margin_left(CHECKBOX_SPACING); let setting_type = setting.setting_type.to_owned(); @@ -362,9 +366,13 @@ impl SettingsImportView { let font_color = theme.main_text_color(theme.background()); let terminal_text = Container::new( - Text::new_inline(config_menu_item.config_name.clone(), font_family, appearance.ui_font_subheading()) - .with_color(font_color.into_solid()) - .finish(), + Text::new_inline( + config_menu_item.config_name.clone(), + font_family, + appearance.ui_font_subheading(), + ) + .with_color(font_color.into_solid()) + .finish(), ) .with_horizontal_margin(8.) .finish(); diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index 2dea2fb4e5a..9cd02422e99 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -33,13 +33,13 @@ use warp_core::semantic_selection::SemanticSelection; use super::{ app_icon::AppIconSettings, app_installation_detection::UserAppInstallDetectionSettings, cloud_preferences::PreferencesSettings, cloud_sync::CloudSyncSettings, - initializer::SettingsInitializer, - language::LanguageSettings, native_preference::NativePreferenceSettings, - network::NetworkSettings, AISettings, AccessibilitySettings, AliasExpansionSettings, - AppEditorSettings, AutoupdateSettings, BlockVisibilitySettings, CodeSettings, DebugSettings, - EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent, GPUSettings, InputBoxType, - InputModeSettings, InputSettings, PaneSettings, SameLinePromptBlockSettings, ScrollSettings, - SelectionSettings, SshSettings, ThemeSettings, VimBannerSettings, WarpDrivePrivacySettings, + initializer::SettingsInitializer, language::LanguageSettings, + native_preference::NativePreferenceSettings, network::NetworkSettings, AISettings, + AccessibilitySettings, AliasExpansionSettings, AppEditorSettings, AutoupdateSettings, + BlockVisibilitySettings, CodeSettings, DebugSettings, EmacsBindingsSettings, FontSettings, + FontSettingsChangedEvent, GPUSettings, InputBoxType, InputModeSettings, InputSettings, + PaneSettings, SameLinePromptBlockSettings, ScrollSettings, SelectionSettings, SshSettings, + ThemeSettings, VimBannerSettings, WarpDrivePrivacySettings, }; pub struct UserDefaultsOnStartup { diff --git a/app/src/settings_view/about_page.rs b/app/src/settings_view/about_page.rs index a2f4fc52e49..5cee91b8a9f 100644 --- a/app/src/settings_view/about_page.rs +++ b/app/src/settings_view/about_page.rs @@ -186,13 +186,7 @@ impl SettingsWidget for AboutPageWidget { .with_max_width(350.) .finish(), ) - .with_child( - ui_builder - .span("Zap") - .build() - .with_margin_top(12.) - .finish(), - ) + .with_child(ui_builder.span("Zap").build().with_margin_top(12.).finish()) .with_child(version_row.finish()); // 更新状态区域:显示当前是否有新版本,并提供"检查更新"或"前往 GitHub 下载"链接。 @@ -295,7 +289,9 @@ impl AboutPageWidget { // - UpdateReady / UpdatedPendingRestart:可以安装 + "立即安装"按钮 // - UnableTo*: 自动安装失败 + "前往 GitHub 下载"兜底链接 let stage = autoupdate::get_update_state(app); - let progress = autoupdate::AutoupdateState::as_ref(app).download_progress().cloned(); + let progress = autoupdate::AutoupdateState::as_ref(app) + .download_progress() + .cloned(); let (status_text, action) = match &stage { AutoupdateStage::CheckingForUpdate => ( diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index 0d3b896514f..8524511a838 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -30,8 +30,8 @@ use crate::settings::{ ShowAgentZeroStateHints, ShowConversationHistory, ShowHintText, ThinkingDisplayMode, VoiceInputEnabled, }; -use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent}; use crate::terminal::cli_agent::{CLIAgentInstallEvent, CLIAgentInstallModel}; +use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent}; use crate::terminal::CLIAgent; use crate::view_components::{ action_button::{ActionButton, ButtonSize, SecondaryTheme}, @@ -49,14 +49,14 @@ use warp_core::features::FeatureFlag; use warp_core::ui::theme::color::internal_colors; use warpui::elements::{ Border, ChildView, ConstrainedBox, CornerRadius, CrossAxisAlignment, Dismiss, Empty, Expanded, - Fill, Hoverable, HyperlinkLens, MainAxisAlignment, MainAxisSize, MouseStateHandle, Radius, Shrinkable, - Text, + Fill, Hoverable, HyperlinkLens, MainAxisAlignment, MainAxisSize, MouseStateHandle, Radius, + Shrinkable, Text, }; use warpui::fonts::{Properties, Weight}; -use warpui::platform::Cursor; -use warpui::text_layout::TextAlignment; use warpui::id; use warpui::keymap::ContextPredicate; +use warpui::platform::Cursor; +use warpui::text_layout::TextAlignment; use warpui::ui_components::slider::SliderStateHandle; use warpui::{ elements::{ @@ -5088,7 +5088,9 @@ impl AgentsWidget { styles::description_font_color(ai_settings.is_any_ai_enabled(app), app).into(), HighlightedHyperlink::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(appearance.theme().accent().into_solid()) .register_default_click_handlers_with_action_support(|hyperlink_lens, ctx, _app| { match hyperlink_lens { @@ -5419,7 +5421,9 @@ impl AIInputWidget { styles::description_font_color(is_toggleable, app).into(), incorrect_autodetection_highlight_index, ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(appearance.theme().accent().into_solid()) .register_default_click_handlers(|url, ctx, _| { ctx.dispatch_typed_action(AISettingsPageAction::HyperlinkClick(url)); @@ -5470,7 +5474,9 @@ impl AIInputWidget { styles::description_font_color(is_toggleable, app).into(), incorrect_autodetection_highlight_index, ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(appearance.theme().accent().into_solid()) .register_default_click_handlers(|url, ctx, _| { ctx.dispatch_typed_action(AISettingsPageAction::HyperlinkClick(url)); @@ -5567,7 +5573,9 @@ impl SettingsWidget for MCPServersWidget { styles::description_font_color(is_any_ai_enabled, app).into(), self.mcp_docs_link_index.clone(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(appearance.theme().accent().into_solid()) .register_default_click_handlers(|url, ctx, _| { ctx.dispatch_typed_action(AISettingsPageAction::HyperlinkClick(url)); @@ -5696,7 +5704,9 @@ impl AIFactWidget { styles::description_font_color(ai_settings.is_any_ai_enabled(app), app).into(), self.rules_link_index.clone(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(appearance.theme().accent().into_solid()) .register_default_click_handlers(|url, ctx, _| { ctx.dispatch_typed_action(AISettingsPageAction::HyperlinkClick(url)); @@ -6556,11 +6566,13 @@ impl CLIAgentWidget { }); if is_clickable { - chip = chip.with_cursor(Cursor::PointingHand).on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(AISettingsPageAction::ToggleCLIAgentPerAgent( - agent, dimension, - )); - }); + chip = chip + .with_cursor(Cursor::PointingHand) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(AISettingsPageAction::ToggleCLIAgentPerAgent( + agent, dimension, + )); + }); } chip.finish() diff --git a/app/src/settings_view/appearance_page.rs b/app/src/settings_view/appearance_page.rs index 16e23409894..918a42d3928 100644 --- a/app/src/settings_view/appearance_page.rs +++ b/app/src/settings_view/appearance_page.rs @@ -2081,33 +2081,39 @@ impl AppearanceSettingsPageView { fn set_markdown_heading_scale(&mut self, ctx: &mut ViewContext) { let parsed: [Option; 6] = { let editors = self.markdown_heading_scale_editors(); - std::array::from_fn(|i| { - editors[i] - .as_ref(ctx) - .buffer_text(ctx) - .parse::() - .ok() - }) + std::array::from_fn(|i| editors[i].as_ref(ctx).buffer_text(ctx).parse::().ok()) }; FontSettings::handle(ctx).update(ctx, |font_settings, ctx| { let clamp = |v: f32| v.clamp(MARKDOWN_HEADING_SCALE_MIN, MARKDOWN_HEADING_SCALE_MAX); if let Some(v) = parsed[0] { - report_if_error!(font_settings.markdown_heading_h1_scale.set_value(clamp(v), ctx)); + report_if_error!(font_settings + .markdown_heading_h1_scale + .set_value(clamp(v), ctx)); } if let Some(v) = parsed[1] { - report_if_error!(font_settings.markdown_heading_h2_scale.set_value(clamp(v), ctx)); + report_if_error!(font_settings + .markdown_heading_h2_scale + .set_value(clamp(v), ctx)); } if let Some(v) = parsed[2] { - report_if_error!(font_settings.markdown_heading_h3_scale.set_value(clamp(v), ctx)); + report_if_error!(font_settings + .markdown_heading_h3_scale + .set_value(clamp(v), ctx)); } if let Some(v) = parsed[3] { - report_if_error!(font_settings.markdown_heading_h4_scale.set_value(clamp(v), ctx)); + report_if_error!(font_settings + .markdown_heading_h4_scale + .set_value(clamp(v), ctx)); } if let Some(v) = parsed[4] { - report_if_error!(font_settings.markdown_heading_h5_scale.set_value(clamp(v), ctx)); + report_if_error!(font_settings + .markdown_heading_h5_scale + .set_value(clamp(v), ctx)); } if let Some(v) = parsed[5] { - report_if_error!(font_settings.markdown_heading_h6_scale.set_value(clamp(v), ctx)); + report_if_error!(font_settings + .markdown_heading_h6_scale + .set_value(clamp(v), ctx)); } }); // 兜底:对任何 parse 失败、或 parse 成功但 clamp 后等于当前值(set_value 不发事件)的格子, @@ -3752,9 +3758,7 @@ impl SettingsWidget for WindowBlurWidget { let blur_value = *window_settings.background_blur_radius; let label_info = AdditionalInfo { mouse_state: self.info_button.clone(), - on_click_action: Some(AppearancePageAction::OpenUrl( - "".into(), - )), + on_click_action: Some(AppearancePageAction::OpenUrl("".into())), secondary_text: None, tooltip_override_text: None, }; @@ -4941,7 +4945,9 @@ impl SettingsWidget for MarkdownHeadingScaleWidget { // 标题行:标题 + 重置按钮同行 let title_label = appearance .ui_builder() - .span(crate::t!("settings-appearance-markdown-heading-scale-label")) + .span(crate::t!( + "settings-appearance-markdown-heading-scale-label" + )) .build() .finish(); @@ -4985,7 +4991,9 @@ impl SettingsWidget for MarkdownHeadingScaleWidget { rows.add_child( appearance .ui_builder() - .span(crate::t!("settings-appearance-markdown-heading-scale-description")) + .span(crate::t!( + "settings-appearance-markdown-heading-scale-description" + )) .with_style(UiComponentStyles { font_size: Some(appearance.ui_font_overline()), font_color: Some( @@ -5948,9 +5956,7 @@ impl SettingsWidget for AltScreenPaddingWidget { crate::t!("settings-appearance-alt-screen-padding-label"), Some(AdditionalInfo { mouse_state: self.additional_info_mouse_state.clone(), - on_click_action: Some(AppearancePageAction::OpenUrl( - "".into(), - )), + on_click_action: Some(AppearancePageAction::OpenUrl("".into())), secondary_text: None, tooltip_override_text: None, }), diff --git a/app/src/settings_view/cloud_sync_page.rs b/app/src/settings_view/cloud_sync_page.rs index a7992d00014..d5dbd69cda4 100644 --- a/app/src/settings_view/cloud_sync_page.rs +++ b/app/src/settings_view/cloud_sync_page.rs @@ -20,19 +20,19 @@ use warpui::{ }; use super::settings_page::{ - render_body_item, AdditionalInfo, LocalOnlyIconState, MatchData, PageType, - SettingsPageEvent, SettingsPageMeta, SettingsWidget, ToggleState, + render_body_item, AdditionalInfo, LocalOnlyIconState, MatchData, PageType, SettingsPageEvent, + SettingsPageMeta, SettingsWidget, ToggleState, }; use super::SettingsSection; use crate::appearance::Appearance; use crate::editor::{EditorView, SingleLineEditorOptions, TextOptions}; -use crate::settings::SyncPlatformSetting; use crate::settings::CloudSyncSettings; -use crate::settings::{CloudSyncTokenStore, GITHUB_TOKEN_KEY, GITEE_TOKEN_KEY}; +use crate::settings::SyncPlatformSetting; +use crate::settings::{CloudSyncTokenStore, GITEE_TOKEN_KEY, GITHUB_TOKEN_KEY}; use crate::ssh_manager::{SshTreeChangedEvent, SshTreeChangedNotifier}; use crate::view_components::dropdown::{Dropdown, DropdownItem}; -use warp_ssh_manager::{with_conn, DbVersionStore, SyncMetaRepository, SshSyncProvider}; +use warp_ssh_manager::{with_conn, DbVersionStore, SshSyncProvider, SyncMetaRepository}; use zap_sync::{GistClient, SyncEngine, SyncPlatform, SyncResult}; const INPUT_AREA_MAX_WIDTH: f32 = 420.0; @@ -103,9 +103,7 @@ pub enum CloudSyncPageAction { result: Result, }, /// 强制上传(覆盖远程) - ForceUpload { - platform: SyncPlatform, - }, + ForceUpload { platform: SyncPlatform }, /// 取消冲突弹窗 CancelConflict, /// 确认下载 @@ -252,7 +250,8 @@ impl CloudSyncPageView { ); }); - let token_editor = build_token_editor(ctx, &crate::t!("settings-cloud-sync-token-placeholder")); + let token_editor = + build_token_editor(ctx, &crate::t!("settings-cloud-sync-token-placeholder")); ctx.subscribe_to_model( &CloudSyncSettings::handle(ctx), @@ -363,7 +362,8 @@ impl CloudSyncPageView { view.conflict_remote_version = *remote_version; view.conflict_platform = sync_platform; if view.conflict_token.is_empty() { - view.conflict_token = token_for_platform(ctx, sync_platform); + view.conflict_token = + token_for_platform(ctx, sync_platform); } ctx.notify(); } @@ -375,9 +375,7 @@ impl CloudSyncPageView { } Err(e) => { // 非冲突结局:恢复 Failed 并清理 conflict_token - view.sync_state = SyncState::Failed { - message: e.clone(), - }; + view.sync_state = SyncState::Failed { message: e.clone() }; view.conflict_token.clear(); log::warn!("Auto sync download failed: {e}"); ctx.notify(); @@ -424,7 +422,9 @@ impl CloudSyncPageView { .on_click({ let platform = self.conflict_platform; move |ctx, _, _| { - ctx.dispatch_typed_action(CloudSyncPageAction::ForceUpload { platform }); + ctx.dispatch_typed_action(CloudSyncPageAction::ForceUpload { + platform, + }); } }) .finish(), @@ -483,7 +483,9 @@ impl CloudSyncPageView { .on_click({ let platform = self.download_confirm_platform; move |ctx, _, _| { - ctx.dispatch_typed_action(CloudSyncPageAction::ConfirmDownload { platform }); + ctx.dispatch_typed_action(CloudSyncPageAction::ConfirmDownload { + platform, + }); } }) .finish(), @@ -493,7 +495,10 @@ impl CloudSyncPageView { let cancel_button = appearance .ui_builder() - .button(ButtonVariant::Secondary, self.download_confirm_cancel_mouse.clone()) + .button( + ButtonVariant::Secondary, + self.download_confirm_cancel_mouse.clone(), + ) .with_style(UiComponentStyles { font_size: Some(appearance.ui_font_body()), padding: Some(Coords::uniform(BUTTON_PADDING)), @@ -508,7 +513,9 @@ impl CloudSyncPageView { let dialog = Dialog::new( crate::t!("settings-cloud-sync-download-confirm-title"), - Some(crate::t!("settings-cloud-sync-download-confirm-description")), + Some(crate::t!( + "settings-cloud-sync-download-confirm-description" + )), dialog_styles(appearance), ) .with_bottom_row_child(cancel_button) @@ -543,7 +550,9 @@ impl CloudSyncPageView { .on_click({ let platform = self.upload_confirm_platform; move |ctx, _, _| { - ctx.dispatch_typed_action(CloudSyncPageAction::ConfirmUpload { platform }); + ctx.dispatch_typed_action(CloudSyncPageAction::ConfirmUpload { + platform, + }); } }) .finish(), @@ -553,7 +562,10 @@ impl CloudSyncPageView { let cancel_button = appearance .ui_builder() - .button(ButtonVariant::Secondary, self.upload_confirm_cancel_mouse.clone()) + .button( + ButtonVariant::Secondary, + self.upload_confirm_cancel_mouse.clone(), + ) .with_style(UiComponentStyles { font_size: Some(appearance.ui_font_body()), padding: Some(Coords::uniform(BUTTON_PADDING)), @@ -600,8 +612,8 @@ impl CloudSyncPageView { log::debug!("Failed to get last sync time: {e}"); crate::t!("settings-cloud-sync-never") }); - self.cached_last_sync_platform = with_conn(|c| Ok(SyncMetaRepository::get_last_sync_platform(c)?)) - .unwrap_or_else(|e| { + self.cached_last_sync_platform = + with_conn(|c| Ok(SyncMetaRepository::get_last_sync_platform(c)?)).unwrap_or_else(|e| { log::debug!("Failed to get last sync platform: {e}"); crate::t!("settings-cloud-sync-na") }); @@ -612,7 +624,10 @@ impl CloudSyncPageView { if token.is_empty() { let label = platform.label(); self.sync_state = SyncState::Failed { - message: crate::t!("settings-cloud-sync-token-not-configured", platform = label.to_string()), + message: crate::t!( + "settings-cloud-sync-token-not-configured", + platform = label.to_string() + ), }; ctx.notify(); return; @@ -652,12 +667,20 @@ impl CloudSyncPageView { } /// 启动下载同步。token 由调用方在弹窗打开时捕获。 - fn spawn_download(&mut self, platform: SyncPlatform, spawn_token: String, ctx: &mut ViewContext) { + fn spawn_download( + &mut self, + platform: SyncPlatform, + spawn_token: String, + ctx: &mut ViewContext, + ) { let token = spawn_token; if token.is_empty() { let label = platform.label(); self.sync_state = SyncState::Failed { - message: crate::t!("settings-cloud-sync-token-not-configured", platform = label.to_string()), + message: crate::t!( + "settings-cloud-sync-token-not-configured", + platform = label.to_string() + ), }; ctx.notify(); return; @@ -693,11 +716,19 @@ impl CloudSyncPageView { } /// 启动强制上传同步(覆盖远程)。token 来自冲突弹出时的快照。 - fn spawn_force_upload(&mut self, platform: SyncPlatform, token: String, ctx: &mut ViewContext) { + fn spawn_force_upload( + &mut self, + platform: SyncPlatform, + token: String, + ctx: &mut ViewContext, + ) { if token.is_empty() { let label = platform.label(); self.sync_state = SyncState::Failed { - message: crate::t!("settings-cloud-sync-token-not-configured", platform = label.to_string()), + message: crate::t!( + "settings-cloud-sync-token-not-configured", + platform = label.to_string() + ), }; ctx.notify(); return; @@ -886,9 +917,7 @@ impl TypedActionView for CloudSyncPageView { Err(e) => { if *platform_setting == current_platform { self.has_valid_token = false; - self.sync_state = SyncState::Failed { - message: e.clone(), - }; + self.sync_state = SyncState::Failed { message: e.clone() }; } } } @@ -900,9 +929,12 @@ impl TypedActionView for CloudSyncPageView { SyncPlatformSetting::GitHub => GITHUB_TOKEN_KEY, SyncPlatformSetting::Gitee => GITEE_TOKEN_KEY, }; - CloudSyncTokenStore::handle(ctx).update(ctx, |store: &mut CloudSyncTokenStore, ctx| { - store.set(key, String::new(), ctx); - }); + CloudSyncTokenStore::handle(ctx).update( + ctx, + |store: &mut CloudSyncTokenStore, ctx| { + store.set(key, String::new(), ctx); + }, + ); self.token_editor.update(ctx, |editor, ctx| { editor.set_buffer_text("", ctx); }); @@ -919,7 +951,10 @@ impl TypedActionView for CloudSyncPageView { if token.is_empty() { let label = platform.label(); self.sync_state = SyncState::Failed { - message: crate::t!("settings-cloud-sync-token-not-configured", platform = label.to_string()), + message: crate::t!( + "settings-cloud-sync-token-not-configured", + platform = label.to_string() + ), }; ctx.notify(); return; @@ -941,7 +976,10 @@ impl TypedActionView for CloudSyncPageView { if token.is_empty() { let label = platform.label(); self.sync_state = SyncState::Failed { - message: crate::t!("settings-cloud-sync-token-not-configured", platform = label.to_string()), + message: crate::t!( + "settings-cloud-sync-token-not-configured", + platform = label.to_string() + ), }; ctx.notify(); return; @@ -997,15 +1035,11 @@ impl TypedActionView for CloudSyncPageView { } } Ok(SyncResult::AlreadyUpToDate { version }) => { - self.sync_state = SyncState::AlreadyUpToDate { - version: *version, - }; + self.sync_state = SyncState::AlreadyUpToDate { version: *version }; self.conflict_token.clear(); } Err(e) => { - self.sync_state = SyncState::Failed { - message: e.clone(), - }; + self.sync_state = SyncState::Failed { message: e.clone() }; self.conflict_token.clear(); } } @@ -1149,10 +1183,7 @@ impl SettingsWidget for CloudSyncPageWidget { let editor_element = appearance .ui_builder() .text_input(view.token_editor.clone()) - .with_style( - UiComponentStyles::default() - .set_width(INPUT_AREA_MAX_WIDTH - 120.0), - ) + .with_style(UiComponentStyles::default().set_width(INPUT_AREA_MAX_WIDTH - 120.0)) .build() .finish(); let is_validating = matches!(view.sync_state, SyncState::Validating); @@ -1161,27 +1192,25 @@ impl SettingsWidget for CloudSyncPageWidget { } else { crate::t!("common-save") }; - let save_button = Container::new( - { - let mut btn = appearance - .ui_builder() - .button(ButtonVariant::Accent, view.save_state.clone()) - .with_style(UiComponentStyles { - font_size: Some(appearance.ui_font_body()), - padding: Some(Coords::uniform(BUTTON_PADDING)), - ..Default::default() - }) - .with_text_label(save_label) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(CloudSyncPageAction::SaveToken); - }); - if is_validating { - btn = btn.disable(); - } - btn.finish() - }, - ) + let save_button = Container::new({ + let mut btn = appearance + .ui_builder() + .button(ButtonVariant::Accent, view.save_state.clone()) + .with_style(UiComponentStyles { + font_size: Some(appearance.ui_font_body()), + padding: Some(Coords::uniform(BUTTON_PADDING)), + ..Default::default() + }) + .with_text_label(save_label) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(CloudSyncPageAction::SaveToken); + }); + if is_validating { + btn = btn.disable(); + } + btn.finish() + }) .with_margin_left(8.) .finish(); let clear_button = Container::new( @@ -1248,17 +1277,18 @@ impl SettingsWidget for CloudSyncPageWidget { // 同步操作 content.add_child( - Container::new( - super::settings_page::render_sub_header( - appearance, - crate::t!("settings-cloud-sync-operations-header"), - None, - ), - ) + Container::new(super::settings_page::render_sub_header( + appearance, + crate::t!("settings-cloud-sync-operations-header"), + None, + )) .with_margin_top(12.) .finish(), ); - let is_syncing = matches!(view.sync_state, SyncState::Syncing { .. } | SyncState::Validating); + let is_syncing = matches!( + view.sync_state, + SyncState::Syncing { .. } | SyncState::Validating + ); let can_sync = view.has_valid_token && !is_syncing; let render_sync_button = |label: &str, @@ -1304,11 +1334,7 @@ impl SettingsWidget for CloudSyncPageWidget { .finish(); // 与下方版本信息列表保持 12px 间距,避免按钮贴着 本地版本 标签 - content.add_child( - Container::new(buttons_row) - .with_margin_bottom(12.) - .finish(), - ); + content.add_child(Container::new(buttons_row).with_margin_bottom(12.).finish()); // 同步状态区域(使用缓存) let version = &view.cached_version; @@ -1317,9 +1343,13 @@ impl SettingsWidget for CloudSyncPageWidget { let info_color = theme.nonactive_ui_text_color(); - let version_text = Text::new(version.clone(), appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(info_color.into()) - .finish(); + let version_text = Text::new( + version.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(info_color.into()) + .finish(); content.add_child(render_body_item::( crate::t!("settings-cloud-sync-local-version-label"), None::>, @@ -1330,9 +1360,13 @@ impl SettingsWidget for CloudSyncPageWidget { None, )); - let time_text = Text::new(last_sync_time.clone(), appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(info_color.into()) - .finish(); + let time_text = Text::new( + last_sync_time.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(info_color.into()) + .finish(); content.add_child(render_body_item::( crate::t!("settings-cloud-sync-last-time-label"), None::>, @@ -1343,9 +1377,13 @@ impl SettingsWidget for CloudSyncPageWidget { None, )); - let platform_text = Text::new(last_sync_platform.clone(), appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(info_color.into()) - .finish(); + let platform_text = Text::new( + last_sync_platform.clone(), + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(info_color.into()) + .finish(); content.add_child(render_body_item::( crate::t!("settings-cloud-sync-last-platform-label"), None::>, @@ -1370,34 +1408,48 @@ impl SettingsWidget for CloudSyncPageWidget { let state_text = match &view.sync_state { SyncState::Idle => None, - SyncState::Validating => { - Some(crate::t!("settings-cloud-sync-validating")) - } - SyncState::TokenValid { username } => { - Some(crate::t!("settings-cloud-sync-token-valid", username = username.clone())) - } - SyncState::Syncing { platform, direction } => { - match direction { - SyncDirection::Upload => Some(crate::t!("settings-cloud-sync-syncing-upload", platform = platform.label().to_string())), - SyncDirection::Download => Some(crate::t!("settings-cloud-sync-syncing-download", platform = platform.label().to_string())), - } - } + SyncState::Validating => Some(crate::t!("settings-cloud-sync-validating")), + SyncState::TokenValid { username } => Some(crate::t!( + "settings-cloud-sync-token-valid", + username = username.clone() + )), + SyncState::Syncing { + platform, + direction, + } => match direction { + SyncDirection::Upload => Some(crate::t!( + "settings-cloud-sync-syncing-upload", + platform = platform.label().to_string() + )), + SyncDirection::Download => Some(crate::t!( + "settings-cloud-sync-syncing-download", + platform = platform.label().to_string() + )), + }, SyncState::Success { platform, direction, version, - } => { - match direction { - SyncDirection::Upload => Some(crate::t!("settings-cloud-sync-success-upload", platform = platform.label().to_string(), version = (*version).to_string())), - SyncDirection::Download => Some(crate::t!("settings-cloud-sync-success-download", platform = platform.label().to_string(), version = (*version).to_string())), - } - } - SyncState::AlreadyUpToDate { version } => { - Some(crate::t!("settings-cloud-sync-already-up-to-date", version = (*version).to_string())) - } - SyncState::Failed { message } => { - Some(crate::t!("settings-cloud-sync-failed", error = message.clone())) - } + } => match direction { + SyncDirection::Upload => Some(crate::t!( + "settings-cloud-sync-success-upload", + platform = platform.label().to_string(), + version = (*version).to_string() + )), + SyncDirection::Download => Some(crate::t!( + "settings-cloud-sync-success-download", + platform = platform.label().to_string(), + version = (*version).to_string() + )), + }, + SyncState::AlreadyUpToDate { version } => Some(crate::t!( + "settings-cloud-sync-already-up-to-date", + version = (*version).to_string() + )), + SyncState::Failed { message } => Some(crate::t!( + "settings-cloud-sync-failed", + error = message.clone() + )), SyncState::Conflict { local_version, remote_version, @@ -1406,9 +1458,17 @@ impl SettingsWidget for CloudSyncPageWidget { let local = *local_version; let remote = *remote_version; if local == remote { - Some(crate::t!("settings-cloud-sync-conflict-status-equal", local = local.to_string(), remote = remote.to_string())) + Some(crate::t!( + "settings-cloud-sync-conflict-status-equal", + local = local.to_string(), + remote = remote.to_string() + )) } else { - Some(crate::t!("settings-cloud-sync-conflict-status", local = local.to_string(), remote = remote.to_string())) + Some(crate::t!( + "settings-cloud-sync-conflict-status", + local = local.to_string(), + remote = remote.to_string() + )) } } }; diff --git a/app/src/settings_view/code_page.rs b/app/src/settings_view/code_page.rs index 09e178c30ff..a11c2da3b9a 100644 --- a/app/src/settings_view/code_page.rs +++ b/app/src/settings_view/code_page.rs @@ -61,8 +61,7 @@ impl CodeSettingsPageView { fn build_page( ctx: &mut ViewContext, ) -> (PageType, Option>) { - let (widgets, external_editor_view) = if FeatureFlag::ZapNewSettingsModes.is_enabled() - { + let (widgets, external_editor_view) = if FeatureFlag::ZapNewSettingsModes.is_enabled() { let editor_view = ctx.add_typed_action_view(ExternalEditorView::new); let widgets: Vec>> = vec![ Box::new(ExternalEditorCodeWidget), diff --git a/app/src/settings_view/execution_profile_view.rs b/app/src/settings_view/execution_profile_view.rs index 11b7f660185..58bcbc48c97 100644 --- a/app/src/settings_view/execution_profile_view.rs +++ b/app/src/settings_view/execution_profile_view.rs @@ -177,14 +177,18 @@ impl View for ExecutionProfileView { .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( - Text::new(profile.display_name(), appearance.ui_font_family(), appearance.ui_font_subheading()) - .with_style(Properties::default().weight(Weight::Medium)) - .with_color(if is_any_ai_enabled { - appearance.theme().active_ui_text_color().into() - } else { - appearance.theme().disabled_ui_text_color().into() - }) - .finish(), + Text::new( + profile.display_name(), + appearance.ui_font_family(), + appearance.ui_font_subheading(), + ) + .with_style(Properties::default().weight(Weight::Medium)) + .with_color(if is_any_ai_enabled { + appearance.theme().active_ui_text_color().into() + } else { + appearance.theme().disabled_ui_text_color().into() + }) + .finish(), ) .with_child(self.edit_button.as_ref(app).render(app)) .finish(), @@ -499,13 +503,17 @@ where .map(|item| { Container::new( Container::new( - Text::new(item, appearance.ui_font_family(), appearance.ui_font_footnote()) - .with_color(if is_ai_enabled { - appearance.theme().active_ui_text_color().into() - } else { - appearance.theme().disabled_ui_text_color().into() - }) - .finish(), + Text::new( + item, + appearance.ui_font_family(), + appearance.ui_font_footnote(), + ) + .with_color(if is_ai_enabled { + appearance.theme().active_ui_text_color().into() + } else { + appearance.theme().disabled_ui_text_color().into() + }) + .finish(), ) .with_background(appearance.theme().surface_2()) .with_border( @@ -558,16 +566,20 @@ fn render_allowlist_denylist_row( ) .with_child( Container::new( - Text::new(label, appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(if is_ai_enabled { - appearance - .theme() - .sub_text_color(appearance.theme().surface_1()) - .into() - } else { - appearance.theme().disabled_ui_text_color().into() - }) - .finish(), + Text::new( + label, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(if is_ai_enabled { + appearance + .theme() + .sub_text_color(appearance.theme().surface_1()) + .into() + } else { + appearance.theme().disabled_ui_text_color().into() + }) + .finish(), ) .with_margin_right(8.) .finish(), @@ -661,28 +673,36 @@ fn render_model_line_with_icon( ) .with_child( Container::new( - Text::new(label, appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(if is_ai_enabled { - appearance - .theme() - .sub_text_color(appearance.theme().surface_1()) - .into() - } else { - appearance.theme().disabled_ui_text_color().into() - }) - .finish(), - ) - .with_margin_right(8.) - .finish(), - ) - .with_child( - Text::new(model_name, appearance.ui_font_family(), appearance.ui_font_body()) + Text::new( + label, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) .with_color(if is_ai_enabled { - appearance.theme().active_ui_text_color().into() + appearance + .theme() + .sub_text_color(appearance.theme().surface_1()) + .into() } else { appearance.theme().disabled_ui_text_color().into() }) .finish(), + ) + .with_margin_right(8.) + .finish(), + ) + .with_child( + Text::new( + model_name, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(if is_ai_enabled { + appearance.theme().active_ui_text_color().into() + } else { + appearance.theme().disabled_ui_text_color().into() + }) + .finish(), ) .finish(), ) @@ -722,28 +742,36 @@ fn render_permission_line_with_icon( ) .with_child( Container::new( - Text::new(label, appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(if is_ai_enabled { - appearance - .theme() - .sub_text_color(appearance.theme().surface_1()) - .into() - } else { - appearance.theme().disabled_ui_text_color().into() - }) - .finish(), - ) - .with_margin_right(8.) - .finish(), - ) - .with_child( - Text::new(permission_text, appearance.ui_font_family(), appearance.ui_font_body()) + Text::new( + label, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) .with_color(if is_ai_enabled { - appearance.theme().active_ui_text_color().into() + appearance + .theme() + .sub_text_color(appearance.theme().surface_1()) + .into() } else { appearance.theme().disabled_ui_text_color().into() }) .finish(), + ) + .with_margin_right(8.) + .finish(), + ) + .with_child( + Text::new( + permission_text, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(if is_ai_enabled { + appearance.theme().active_ui_text_color().into() + } else { + appearance.theme().disabled_ui_text_color().into() + }) + .finish(), ) .finish() } diff --git a/app/src/settings_view/features/external_editor.rs b/app/src/settings_view/features/external_editor.rs index 96ef5f8b2a0..1ea09403325 100644 --- a/app/src/settings_view/features/external_editor.rs +++ b/app/src/settings_view/features/external_editor.rs @@ -362,9 +362,7 @@ impl View for ExternalEditorView { crate::t!("settings-external-editor-prefer-markdown"), Some(AdditionalInfo { mouse_state: self.markdown_viewer_mouse_state.clone(), - on_click_action: Some(ExternalEditorAction::OpenUrl( - "".to_string(), - )), + on_click_action: Some(ExternalEditorAction::OpenUrl("".to_string())), secondary_text: None, tooltip_override_text: None, }), diff --git a/app/src/settings_view/features_page.rs b/app/src/settings_view/features_page.rs index 84895346917..4121bf859ae 100644 --- a/app/src/settings_view/features_page.rs +++ b/app/src/settings_view/features_page.rs @@ -45,14 +45,14 @@ use crate::settings::{ AliasExpansionEnabled, AliasExpansionSettings, AppEditorSettings, AtContextMenuInTerminalMode, AutocompleteSymbols, AutosuggestionKeybindingHint, CodeSettings, CommandCorrections, CompletionsOpenWhileTyping, CopyOnSelect, CtrlTabBehavior, DefaultSessionMode, - EnableSshAutoDiscovery, EnableSlashCommandsInTerminal, EnableSshWrapper, - ErrorUnderliningEnabled, ExtraMetaKeys, - GPUSettings, GlobalHotkeyMode, InputSettings, InputSettingsChangedEvent, - LinuxSelectionClipboard, MiddleClickPasteEnabled, MouseScrollMultiplier, PreferLowPowerGPU, - PreferencesSettings, PreferredGraphicsBackend, QuakeModeSettings, ScrollSettings, - SelectionSettings, ShowAutosuggestionIgnoreButton, ShowTerminalInputMessageBar, SshSettings, - SyntaxHighlighting, TabBehavior, VimModeEnabled, VimStatusBar, VimUnnamedSystemClipboard, - DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES, QUAKE_WINDOW_AUTOHIDE_SUPPORTED, + EnableSlashCommandsInTerminal, EnableSshAutoDiscovery, EnableSshWrapper, + ErrorUnderliningEnabled, ExtraMetaKeys, GPUSettings, GlobalHotkeyMode, InputSettings, + InputSettingsChangedEvent, LinuxSelectionClipboard, MiddleClickPasteEnabled, + MouseScrollMultiplier, PreferLowPowerGPU, PreferencesSettings, PreferredGraphicsBackend, + QuakeModeSettings, ScrollSettings, SelectionSettings, ShowAutosuggestionIgnoreButton, + ShowTerminalInputMessageBar, SshSettings, SyntaxHighlighting, TabBehavior, VimModeEnabled, + VimStatusBar, VimUnnamedSystemClipboard, DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES, + QUAKE_WINDOW_AUTOHIDE_SUPPORTED, }; use crate::terminal::alt_screen_reporting::{ AltScreenReporting, FocusReportingEnabled, MouseReportingEnabled, ScrollReportingEnabled, @@ -4313,9 +4313,7 @@ impl SettingsWidget for SessionRestorationWidget { crate::t!("settings-features-restore-session"), Some(AdditionalInfo { mouse_state: self.additional_info_link.clone(), - on_click_action: Some(FeaturesPageAction::OpenUrl( - "".into(), - )), + on_click_action: Some(FeaturesPageAction::OpenUrl("".into())), secondary_text: None, tooltip_override_text: None, }), @@ -4447,9 +4445,7 @@ impl SettingsWidget for SnackbarHeaderWidget { crate::t!("settings-features-show-sticky-command-header"), Some(AdditionalInfo { mouse_state: self.additional_info_link.clone(), - on_click_action: Some(FeaturesPageAction::OpenUrl( - "".into(), - )), + on_click_action: Some(FeaturesPageAction::OpenUrl("".into())), secondary_text: None, tooltip_override_text: None, }), @@ -4904,9 +4900,7 @@ impl SettingsWidget for SSHWrapperWidget { crate::t!("settings-features-ssh-wrapper"), Some(AdditionalInfo { mouse_state: self.additional_info_link.clone(), - on_click_action: Some(FeaturesPageAction::OpenUrl( - "".into(), - )), + on_click_action: Some(FeaturesPageAction::OpenUrl("".into())), secondary_text: if view.ssh_wrapper_toggled { Some(crate::t!("settings-features-takes-effect-new-sessions")) } else { @@ -5382,10 +5376,7 @@ impl SettingsWidget for GlobalHotkeyWidget { ui_builder .link( crate::t!("settings-features-see-docs"), - Some( - "" - .to_owned(), - ), + Some("".to_owned()), None, view.button_mouse_states.global_hotkey_link.clone(), ) @@ -6407,10 +6398,7 @@ impl SettingsWidget for MouseReportingWidget { crate::t!("settings-features-enable-mouse-reporting"), Some(AdditionalInfo { mouse_state: self.additional_info_link.clone(), - on_click_action: Some(FeaturesPageAction::OpenUrl( - "" - .into(), - )), + on_click_action: Some(FeaturesPageAction::OpenUrl("".into())), secondary_text: None, tooltip_override_text: None, }), @@ -6672,9 +6660,7 @@ impl SettingsWidget for SmartSelectWidget { crate::t!("settings-features-double-click-smart-selection"), Some(AdditionalInfo { mouse_state: self.additional_info_link.clone(), - on_click_action: Some(FeaturesPageAction::OpenUrl( - "".into(), - )), + on_click_action: Some(FeaturesPageAction::OpenUrl("".into())), secondary_text: None, tooltip_override_text: None, }), @@ -6923,9 +6909,7 @@ impl SettingsWidget for WorkflowsInCommandSearch { crate::t!("settings-features-show-global-workflows-in-command-search"), Some(AdditionalInfo { mouse_state: self.additional_info_link.clone(), - on_click_action: Some(FeaturesPageAction::OpenUrl( - "".into(), - )), + on_click_action: Some(FeaturesPageAction::OpenUrl("".into())), secondary_text: None, tooltip_override_text: None, }), diff --git a/app/src/settings_view/mcp_servers/installation_modal.rs b/app/src/settings_view/mcp_servers/installation_modal.rs index e0b9081996f..a2bcceac855 100644 --- a/app/src/settings_view/mcp_servers/installation_modal.rs +++ b/app/src/settings_view/mcp_servers/installation_modal.rs @@ -337,7 +337,9 @@ impl InstallationModalBody { theme.active_ui_text_color().into(), HighlightedHyperlink::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(appearance.theme().accent().into_solid()) .register_default_click_handlers(|url, _, ctx| { ctx.open_url(&url.url); diff --git a/app/src/settings_view/mcp_servers/list_page.rs b/app/src/settings_view/mcp_servers/list_page.rs index f6b3269ed06..8c56dd54329 100644 --- a/app/src/settings_view/mcp_servers/list_page.rs +++ b/app/src/settings_view/mcp_servers/list_page.rs @@ -1113,10 +1113,7 @@ impl MCPServersListPageView { "{} ", crate::t!("settings-mcp-list-description") )), - FormattedTextFragment::hyperlink( - crate::t!("settings-mcp-list-learn-more"), - "", - ), + FormattedTextFragment::hyperlink(crate::t!("settings-mcp-list-learn-more"), ""), ]; let description = FormattedTextElement::new( diff --git a/app/src/settings_view/mcp_servers/server_card.rs b/app/src/settings_view/mcp_servers/server_card.rs index 035e9f22090..b3ac9b60f00 100644 --- a/app/src/settings_view/mcp_servers/server_card.rs +++ b/app/src/settings_view/mcp_servers/server_card.rs @@ -696,7 +696,9 @@ impl ServerCardView { ), HighlightedHyperlink::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .finish(), ); } @@ -713,7 +715,9 @@ impl ServerCardView { blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()), HighlightedHyperlink::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .finish(), ); } @@ -730,7 +734,9 @@ impl ServerCardView { appearance.theme().ui_error_color(), HighlightedHyperlink::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .finish(), ); } diff --git a/app/src/settings_view/settings_page.rs b/app/src/settings_view/settings_page.rs index 31aa1ad1066..23a706408e3 100644 --- a/app/src/settings_view/settings_page.rs +++ b/app/src/settings_view/settings_page.rs @@ -9,8 +9,8 @@ use super::{ about_page::AboutPageView, ai_page::{AISettingsPageAction, AISettingsPageView}, appearance_page::AppearanceSettingsPageView, - code_page::CodeSettingsPageView, cloud_sync_page::CloudSyncPageView, + code_page::CodeSettingsPageView, features_page::FeaturesPageView, keybindings::KeybindingsView, mcp_servers_page::MCPServersSettingsPageView, @@ -278,10 +278,14 @@ pub fn build_sub_header( let color = color_override.unwrap_or(appearance.theme().active_ui_text_color()); Container::new( Align::new( - Text::new_inline(text_name, appearance.ui_font_family(), appearance.ui_font_heading_3()) - .with_style(Properties::default().weight(Weight::Bold)) - .with_color(color.into()) - .finish(), + Text::new_inline( + text_name, + appearance.ui_font_family(), + appearance.ui_font_heading_3(), + ) + .with_style(Properties::default().weight(Weight::Bold)) + .with_color(color.into()) + .finish(), ) .left() .finish(), @@ -299,9 +303,13 @@ pub fn render_sub_header_with_description( .with_child(build_sub_header(appearance, text_name, None).finish()) .with_child( Align::new( - Text::new(description, appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(appearance.theme().nonactive_ui_text_color().into()) - .finish(), + Text::new( + description, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .finish(), ) .left() .finish(), @@ -321,10 +329,14 @@ pub fn render_sub_sub_header( let mut sub_sub_header = Flex::row().with_child( Container::new( Align::new( - Text::new_inline(text_name, appearance.ui_font_family(), appearance.ui_font_body()) - .with_style(Properties::default().weight(Weight::Semibold)) - .with_color(appearance.theme().active_ui_text_color().into()) - .finish(), + Text::new_inline( + text_name, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_style(Properties::default().weight(Weight::Semibold)) + .with_color(appearance.theme().active_ui_text_color().into()) + .finish(), ) .left() .finish(), @@ -645,8 +657,12 @@ pub fn render_body_item_label_internal( ToggleState::Disabled => appearance.theme().disabled_ui_text_color(), }, }; - let label_text = Text::new_inline(label_text, appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(label_color.into()); + let label_text = Text::new_inline( + label_text, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(label_color.into()); if let Some(icon) = label_icon { label.add_child( Container::new( @@ -836,13 +852,17 @@ pub fn render_dropdown_item_label( color_override: Option, appearance: &Appearance, ) -> Box { - let label = Text::new(label_text, appearance.ui_font_family(), appearance.ui_font_body()) - .with_color( - color_override - .unwrap_or(appearance.theme().active_ui_text_color()) - .into(), - ) - .finish(); + let label = Text::new( + label_text, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color( + color_override + .unwrap_or(appearance.theme().active_ui_text_color()) + .into(), + ) + .finish(); let label = if let Some(secondary_text) = secondary_text { let warp_theme = appearance.theme(); let secondary_text_child = appearance diff --git a/app/src/settings_view/warpify_page.rs b/app/src/settings_view/warpify_page.rs index 680a04b9a36..c57a6e5a7b9 100644 --- a/app/src/settings_view/warpify_page.rs +++ b/app/src/settings_view/warpify_page.rs @@ -537,10 +537,7 @@ impl TitleWidget { fn render_top_of_page(&self, appearance: &Appearance, _app: &AppContext) -> Box { let warpify_description = vec![ FormattedTextFragment::plain_text(crate::t!("settings-warpify-description-prefix")), - FormattedTextFragment::hyperlink( - crate::t!("settings-warpify-learn-more"), - "", - ), + FormattedTextFragment::hyperlink(crate::t!("settings-warpify-learn-more"), ""), ]; let warpify_description = FormattedTextElement::new( @@ -754,9 +751,7 @@ impl SettingsWidget for SSHWidget { crate::t!("settings-warpify-use-tmux"), Some(AdditionalInfo { mouse_state: self.additional_info_mouse_state.clone(), - on_click_action: Some(WarpifyPageAction::OpenUrl( - "".into(), - )), + on_click_action: Some(WarpifyPageAction::OpenUrl("".into())), secondary_text: None, tooltip_override_text: None, }), diff --git a/app/src/sftp_manager/breadcrumb.rs b/app/src/sftp_manager/breadcrumb.rs index 458637f7727..a1006629beb 100644 --- a/app/src/sftp_manager/breadcrumb.rs +++ b/app/src/sftp_manager/breadcrumb.rs @@ -47,14 +47,11 @@ pub fn render_breadcrumb(current_path: &PathBuf, appearance: &Appearance) -> Vec // 分隔符(第一段之后添加) if i > 0 { - let sep_icon = ConstrainedBox::new( - Icon::ChevronRight - .to_warpui_icon(sub_color.into()) - .finish(), - ) - .with_width(12.0) - .with_height(12.0) - .finish(); + let sep_icon = + ConstrainedBox::new(Icon::ChevronRight.to_warpui_icon(sub_color.into()).finish()) + .with_width(12.0) + .with_height(12.0) + .finish(); elements.push( Container::new(sep_icon) .with_padding_left(2.0) diff --git a/app/src/sftp_manager/browser.rs b/app/src/sftp_manager/browser.rs index ab6f90fdc9c..9cd5490b8df 100644 --- a/app/src/sftp_manager/browser.rs +++ b/app/src/sftp_manager/browser.rs @@ -22,11 +22,11 @@ use warpui::elements::{ ScrollbarWidth, Shrinkable, Stack, Text, }; use warpui::platform::{Cursor, FilePickerConfiguration, SaveFilePickerConfiguration}; +use warpui::r#async::SpawnedFutureHandle; use warpui::{ AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; -use warpui::r#async::SpawnedFutureHandle; use crate::editor::{ EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions, @@ -93,10 +93,7 @@ pub enum SftpBrowserAction { /// 确认覆盖 ConfirmOverwrite, /// 弹出右键菜单 - ContextMenu { - index: usize, - position: Vector2F, - }, + ContextMenu { index: usize, position: Vector2F }, /// 关闭右键菜单 CloseContextMenu, /// 关闭对话框 @@ -481,9 +478,12 @@ impl SftpBrowserView { Ok(Ok(session)) => { match session.sftp() { Ok(sftp) => { - let backend = Arc::new(LiveSftpBackend::new(sftp)) as Arc; + let backend = Arc::new(LiveSftpBackend::new(sftp)) + as Arc; // 解析用户 home 目录 - if let Ok(home) = backend.realpath(std::path::Path::new(".")) { + if let Ok(home) = + backend.realpath(std::path::Path::new(".")) + { me.current_path = normalize_remote_path(&home); } else { me.current_path = PathBuf::from("/"); @@ -496,9 +496,13 @@ impl SftpBrowserView { me.refresh_dir(ctx); } Err(e) => { - me.connection = - ConnectionState::Failed(format!("创建 SFTP 通道失败: {e}")); - me.show_error_toast(format!("创建 SFTP 通道失败: {e}"), ctx); + me.connection = ConnectionState::Failed(format!( + "创建 SFTP 通道失败: {e}" + )); + me.show_error_toast( + format!("创建 SFTP 通道失败: {e}"), + ctx, + ); } } } @@ -536,7 +540,8 @@ impl SftpBrowserView { &mut self, ctx: &mut ViewContext, op: impl FnOnce() -> T + Send + 'static, - callback: impl FnOnce(&mut Self, Result, &mut ViewContext) + 'static, + callback: impl FnOnce(&mut Self, Result, &mut ViewContext) + + 'static, ) -> Option { #[cfg(any(test, feature = "integration_tests"))] { @@ -626,8 +631,7 @@ impl SftpBrowserView { fn show_error_toast(&self, message: String, ctx: &mut ViewContext) { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { - let toast = DismissibleToast::error(message) - .with_object_id("sftp_error".to_string()); + let toast = DismissibleToast::error(message).with_object_id("sftp_error".to_string()); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); } @@ -697,7 +701,10 @@ impl SftpBrowserView { .iter() .filter_map(|&i| { self.entries.get(i).map(|e| { - (e.path.clone(), matches!(e.file_type, FileEntryType::Directory)) + ( + e.path.clone(), + matches!(e.file_type, FileEntryType::Directory), + ) }) }) .unzip() @@ -1066,9 +1073,10 @@ impl SftpBrowserView { }; // 检查远程目录中是否已存在同名文件 - let existing = self.entries.iter().find(|e| { - e.name == file_name && matches!(e.file_type, FileEntryType::File) - }); + let existing = self + .entries + .iter() + .find(|e| e.name == file_name && matches!(e.file_type, FileEntryType::File)); if existing.is_some() { let local_size = std::fs::metadata(local_path).map(|m| m.len()).unwrap_or(0); @@ -1135,17 +1143,21 @@ impl SftpBrowserView { let transferred = Arc::new(AtomicU64::new(0)); let transferred_clone = transferred.clone(); - let progress_cb: sftp_ops::ProgressCallback = - Box::new(move |bytes, _total| { - transferred_clone.store(bytes, Ordering::SeqCst); - }); + let progress_cb: sftp_ops::ProgressCallback = Box::new(move |bytes, _total| { + transferred_clone.store(bytes, Ordering::SeqCst); + }); let local_path = local_path.to_path_buf(); let remote_path = remote_path.to_path_buf(); if let Some(handle) = self.run_blocking( ctx, move || { - sftp.upload_file(&local_path, &remote_path, Some(&progress_cb), Some(&cancel_flag)) + sftp.upload_file( + &local_path, + &remote_path, + Some(&progress_cb), + Some(&cancel_flag), + ) }, move |me, result, ctx| { if let Some(t) = me.transfers.iter_mut().find(|t| t.id == task_id) { @@ -1231,17 +1243,21 @@ impl SftpBrowserView { let transferred = Arc::new(AtomicU64::new(0)); let transferred_clone = transferred.clone(); - let progress_cb: sftp_ops::ProgressCallback = - Box::new(move |bytes, _total| { - transferred_clone.store(bytes, Ordering::SeqCst); - }); + let progress_cb: sftp_ops::ProgressCallback = Box::new(move |bytes, _total| { + transferred_clone.store(bytes, Ordering::SeqCst); + }); let remote_path = remote_path.to_path_buf(); let local_path = local_path.to_path_buf(); if let Some(handle) = self.run_blocking( ctx, move || { - sftp.download_file(&remote_path, &local_path, Some(&progress_cb), Some(&cancel_flag)) + sftp.download_file( + &remote_path, + &local_path, + Some(&progress_cb), + Some(&cancel_flag), + ) }, move |me, result, ctx| { if let Some(t) = me.transfers.iter_mut().find(|t| t.id == task_id) { @@ -1475,7 +1491,10 @@ impl TypedActionView for SftpBrowserView { let new_path = match build_rename_path(original_path, &new_name) { Some(p) => p, None => { - self.show_error_toast("名称不合法:不能包含路径分隔符".to_string(), ctx); + self.show_error_toast( + "名称不合法:不能包含路径分隔符".to_string(), + ctx, + ); return; } }; @@ -1518,7 +1537,10 @@ impl TypedActionView for SftpBrowserView { let folder_path = match build_new_folder_path(parent_path, &folder_name) { Some(p) => p, None => { - self.show_error_toast("名称不合法:不能包含路径分隔符".to_string(), ctx); + self.show_error_toast( + "名称不合法:不能包含路径分隔符".to_string(), + ctx, + ); return; } }; @@ -1552,9 +1574,12 @@ impl TypedActionView for SftpBrowserView { SftpBrowserAction::ConfirmOverwrite => { // 从对话框中提取路径和传输方向 let (source, target, file_size, direction) = match &self.dialog { - Some(Dialog::OverwriteConfirm { source, target, file_size, direction }) => { - (source.clone(), target.clone(), *file_size, *direction) - } + Some(Dialog::OverwriteConfirm { + source, + target, + file_size, + direction, + }) => (source.clone(), target.clone(), *file_size, *direction), Some(Dialog::DeleteConfirm { .. }) | Some(Dialog::Rename { .. }) | Some(Dialog::CreateFolder { .. }) @@ -1597,7 +1622,10 @@ impl TypedActionView for SftpBrowserView { // 用户取消覆盖确认时,清空剩余的批量上传队列 let was_upload_overwrite = matches!( self.dialog, - Some(Dialog::OverwriteConfirm { direction: TransferDirection::Upload, .. }) + Some(Dialog::OverwriteConfirm { + direction: TransferDirection::Upload, + .. + }) ); self.dialog = None; if was_upload_overwrite { @@ -1684,9 +1712,10 @@ impl TypedActionView for SftpBrowserView { ctx.notify(); } SftpBrowserAction::ToggleTransferPanel => { - let has_active = self.transfers.iter().any(|t| { - matches!(t.state, TransferState::Pending | TransferState::InProgress) - }); + let has_active = self + .transfers + .iter() + .any(|t| matches!(t.state, TransferState::Pending | TransferState::InProgress)); if has_active { self.dialog = Some(Dialog::CloseTransferPanelConfirm); } else { @@ -1893,12 +1922,11 @@ impl View for SftpBrowserView { } // 11. 保存面板位置(用于右键菜单位置计算) - let positioned_content = - SavePosition::new(main_content, SFTP_PANEL_POSITION_ID).finish(); + let positioned_content = SavePosition::new(main_content, SFTP_PANEL_POSITION_ID).finish(); // 12. 键盘事件拦截 - let key_handler = EventHandler::new(positioned_content).on_keydown( - move |ctx, _app, keystroke| { + let key_handler = + EventHandler::new(positioned_content).on_keydown(move |ctx, _app, keystroke| { match keystroke.key.as_str() { "delete" => { ctx.dispatch_typed_action(SftpBrowserAction::DeleteSelected); @@ -1914,8 +1942,7 @@ impl View for SftpBrowserView { } _ => DispatchEventResult::PropagateToParent, } - }, - ); + }); // 13. 拖拽事件拦截 super::drop_target::SftpDropTargetElement::new(key_handler.finish()).finish() diff --git a/app/src/sftp_manager/browser_integration_tests.rs b/app/src/sftp_manager/browser_integration_tests.rs index 1ee5a1a9f7e..d96d36d9356 100644 --- a/app/src/sftp_manager/browser_integration_tests.rs +++ b/app/src/sftp_manager/browser_integration_tests.rs @@ -35,9 +35,7 @@ fn initialize_app(app: &mut warpui::App) { } /// 创建 SftpBrowserView 并放入窗口(Disconnected 状态) -fn create_view( - app: &mut warpui::App, -) -> (warpui::WindowId, warpui::ViewHandle) { +fn create_view(app: &mut warpui::App) -> (warpui::WindowId, warpui::ViewHandle) { app.add_window(WindowStyle::NotStealFocus, |ctx| { SftpBrowserView::new("test-node".to_string(), ctx) }) @@ -70,8 +68,8 @@ fn create_connected_view( tempfile::TempDir, ) { let temp_dir = create_temp_dir_with_files(files); - let backend = Arc::new(InMemorySftpBackend::new(temp_dir.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp_dir.path().to_path_buf())) as Arc; let (win_id, view) = create_view(app); view.update(app, |v, ctx| { @@ -91,12 +89,15 @@ fn create_standard_view( warpui::ViewHandle, tempfile::TempDir, ) { - create_connected_view(app, &[ - ("docs/report.txt", b"report content"), - ("readme.txt", b"hello world"), - ("config.yaml", b"key: value"), - ("data/sub/deep.txt", b"deep file"), - ]) + create_connected_view( + app, + &[ + ("docs/report.txt", b"report content"), + ("readme.txt", b"hello world"), + ("config.yaml", b"key: value"), + ("data/sub/deep.txt", b"deep file"), + ], + ) } // ============================================================ @@ -108,10 +109,10 @@ fn create_standard_view( fn test_connected_state_with_mock_backend() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file1.txt", b"content1"), - ("file2.txt", b"content2"), - ]); + let (_, view, _temp) = create_connected_view( + &mut app, + &[("file1.txt", b"content1"), ("file2.txt", b"content2")], + ); view.read(&app, |v, _| { assert!( @@ -147,9 +148,7 @@ fn test_connection_failure_shows_error_state() { fn test_reconnect_after_failure() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("reconnect.txt", b"data"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("reconnect.txt", b"data")]); // 先设置为 Failed 状态 view.update(&mut app, |v, ctx| { @@ -166,8 +165,8 @@ fn test_reconnect_after_failure() { // 重新注入后端恢复连接 let temp2 = create_temp_dir_with_files(&[("new.txt", b"new content")]); - let backend = Arc::new(InMemorySftpBackend::new(temp2.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp2.path().to_path_buf())) as Arc; view.update(&mut app, |v, ctx| { v.set_backend_for_test(backend, PathBuf::from("/"), ctx); }); @@ -187,9 +186,7 @@ fn test_reconnect_after_failure() { fn test_disconnect_clears_entries_and_path() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file.txt", b"content"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("file.txt", b"content")]); // 验证已连接 view.read(&app, |v, _| { @@ -253,20 +250,31 @@ fn test_render_failed_state() { fn test_list_dir_populates_entries() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("banana.txt", b"b"), - ("apple.txt", b"a"), - ("cherry.txt", b"c"), - ("folder_a/.keep", b""), - ("folder_b/.keep", b""), - ]); + let (_, view, _temp) = create_connected_view( + &mut app, + &[ + ("banana.txt", b"b"), + ("apple.txt", b"a"), + ("cherry.txt", b"c"), + ("folder_a/.keep", b""), + ("folder_b/.keep", b""), + ], + ); view.read(&app, |v, _| { assert_eq!(v.entries.len(), 5, "应有 5 个条目"); // 目录应排在文件前面 - let dirs: Vec<_> = v.entries.iter().take_while(|e| e.file_type == FileEntryType::Directory).collect(); - let files: Vec<_> = v.entries.iter().skip_while(|e| e.file_type == FileEntryType::Directory).collect(); + let dirs: Vec<_> = v + .entries + .iter() + .take_while(|e| e.file_type == FileEntryType::Directory) + .collect(); + let files: Vec<_> = v + .entries + .iter() + .skip_while(|e| e.file_type == FileEntryType::Directory) + .collect(); assert_eq!(dirs.len(), 2, "应有 2 个目录"); assert_eq!(files.len(), 3, "应有 3 个文件"); }); @@ -278,10 +286,10 @@ fn test_list_dir_populates_entries() { fn test_open_directory_navigates_and_updates_history() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("docs/readme.txt", b"readme"), - ("file.txt", b"file"), - ]); + let (_, view, _temp) = create_connected_view( + &mut app, + &[("docs/readme.txt", b"readme"), ("file.txt", b"file")], + ); // 找到 docs 目录的索引 let docs_idx = view.read(&app, |v, _| { @@ -295,7 +303,8 @@ fn test_open_directory_navigates_and_updates_history() { view.read(&app, |v, _| { assert!( - v.current_path.ends_with("docs") || v.current_path.to_string_lossy().contains("docs"), + v.current_path.ends_with("docs") + || v.current_path.to_string_lossy().contains("docs"), "当前路径应包含 docs" ); assert!(v.path_history.len() >= 2, "历史记录应增加"); @@ -308,9 +317,7 @@ fn test_open_directory_navigates_and_updates_history() { fn test_go_up_from_subdirectory() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("subdir/file.txt", b"content"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("subdir/file.txt", b"content")]); // 进入子目录 let sub_idx = view.read(&app, |v, _| { @@ -327,7 +334,8 @@ fn test_go_up_from_subdirectory() { view.read(&app, |v, _| { assert!( - v.current_path == PathBuf::from("/") || v.entries.iter().any(|e| e.name == "subdir"), + v.current_path == PathBuf::from("/") + || v.entries.iter().any(|e| e.name == "subdir"), "GoUp 应返回上级目录" ); }); @@ -339,10 +347,10 @@ fn test_go_up_from_subdirectory() { fn test_go_back_forward_restores_path() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("alpha/file.txt", b"a"), - ("beta/file.txt", b"b"), - ]); + let (_, view, _temp) = create_connected_view( + &mut app, + &[("alpha/file.txt", b"a"), ("beta/file.txt", b"b")], + ); // 记录根路径 let root_path = view.read(&app, |v, _| v.current_path.clone()); @@ -379,9 +387,8 @@ fn test_go_back_forward_restores_path() { fn test_breadcrumb_click_navigates_to_segment() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("level1/level2/file.txt", b"deep"), - ]); + let (_, view, _temp) = + create_connected_view(&mut app, &[("level1/level2/file.txt", b"deep")]); // 进入 level1/level2 let l1_idx = view.read(&app, |v, _| { @@ -407,7 +414,9 @@ fn test_breadcrumb_click_navigates_to_segment() { // 导航回根(通过 NavigateTo) view.update(&mut app, |v, ctx| { // 找到 level1 对应的面包屑路径 - let l1_path = v.current_path.parent() + let l1_path = v + .current_path + .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from("/")); v.handle_action(&SftpBrowserAction::NavigateTo(l1_path), ctx); @@ -427,11 +436,14 @@ fn test_breadcrumb_click_navigates_to_segment() { fn test_search_filter_narrows_visible_entries() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("readme.txt", b"r"), - ("config.yaml", b"c"), - ("data.csv", b"d"), - ]); + let (_, view, _temp) = create_connected_view( + &mut app, + &[ + ("readme.txt", b"r"), + ("config.yaml", b"c"), + ("data.csv", b"d"), + ], + ); view.update(&mut app, |v, ctx| { v.handle_action(&SftpBrowserAction::SetSearchFilter(".txt".to_string()), ctx); @@ -439,7 +451,9 @@ fn test_search_filter_narrows_visible_entries() { view.read(&app, |v, _| { assert!(v.search_filter.is_some()); - let visible: Vec<_> = v.entries.iter() + let visible: Vec<_> = v + .entries + .iter() .filter(|e| e.name.contains(".txt")) .collect(); assert_eq!(visible.len(), 1, "只有 readme.txt 匹配"); @@ -452,10 +466,8 @@ fn test_search_filter_narrows_visible_entries() { fn test_clear_search_restores_all_entries() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("a.txt", b"a"), - ("b.yaml", b"b"), - ]); + let (_, view, _temp) = + create_connected_view(&mut app, &[("a.txt", b"a"), ("b.yaml", b"b")]); let total = view.read(&app, |v, _| v.entries.len()); @@ -480,12 +492,10 @@ fn test_clear_search_restores_all_entries() { #[test] fn test_refresh_dir_reloads_entries() { warpui::App::test((), |mut app| async move { - let temp = create_temp_dir_with_files(&[ - ("original.txt", b"original"), - ]); + let temp = create_temp_dir_with_files(&[("original.txt", b"original")]); initialize_app(&mut app); - let backend = Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) as Arc; let (_, view) = create_view(&mut app); view.update(&mut app, |v, ctx| { v.set_backend_for_test(backend, PathBuf::from("/"), ctx); @@ -514,9 +524,7 @@ fn test_refresh_dir_reloads_entries() { fn test_navigate_to_same_path_is_noop() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file.txt", b"f"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("file.txt", b"f")]); let history_len = view.read(&app, |v, _| v.path_history.len()); let current = view.read(&app, |v, _| v.current_path.clone()); @@ -527,7 +535,8 @@ fn test_navigate_to_same_path_is_noop() { view.read(&app, |v, _| { assert_eq!( - v.path_history.len(), history_len, + v.path_history.len(), + history_len, "导航到当前路径不应增加历史" ); }); @@ -539,9 +548,7 @@ fn test_navigate_to_same_path_is_noop() { fn test_navigate_normalizes_backslashes() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("target/file.txt", b"t"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("target/file.txt", b"t")]); // 使用反斜杠路径导航 let target_idx = view.read(&app, |v, _| { @@ -554,10 +561,7 @@ fn test_navigate_normalizes_backslashes() { view.read(&app, |v, _| { // 路径不应包含反斜杠 let path_str = v.current_path.to_string_lossy(); - assert!( - path_str.contains("target"), - "导航后路径应包含 target" - ); + assert!(path_str.contains("target"), "导航后路径应包含 target"); }); }); } @@ -567,11 +571,14 @@ fn test_navigate_normalizes_backslashes() { fn test_select_entry_highlights_item() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file_a.txt", b"a"), - ("file_b.txt", b"b"), - ("file_c.txt", b"c"), - ]); + let (_, view, _temp) = create_connected_view( + &mut app, + &[ + ("file_a.txt", b"a"), + ("file_b.txt", b"b"), + ("file_c.txt", b"c"), + ], + ); // 选中第二个条目 view.update(&mut app, |v, ctx| { @@ -579,14 +586,8 @@ fn test_select_entry_highlights_item() { }); view.read(&app, |v, _| { - assert!( - v.selected.contains(&1), - "SelectEntry(1) 应选中第二个条目" - ); - assert_eq!( - v.selected.len(), 1, - "应只有 1 个选中" - ); + assert!(v.selected.contains(&1), "SelectEntry(1) 应选中第二个条目"); + assert_eq!(v.selected.len(), 1, "应只有 1 个选中"); }); // 切换选中到第三个条目 @@ -595,10 +596,7 @@ fn test_select_entry_highlights_item() { }); view.read(&app, |v, _| { - assert!( - v.selected.contains(&2), - "SelectEntry(2) 应选中第三个条目" - ); + assert!(v.selected.contains(&2), "SelectEntry(2) 应选中第三个条目"); }); }); } @@ -608,9 +606,7 @@ fn test_select_entry_highlights_item() { fn test_select_entry_out_of_bounds_safe() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("only_file.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("only_file.txt", b"x")]); // 越界选中不应 panic(当前实现直接插入索引) view.update(&mut app, |v, ctx| { @@ -659,10 +655,7 @@ fn test_download_entry_action_without_connection_safe() { }); view.read(&app, |v, _| { - assert!( - v.transfers.is_empty(), - "未连接时下载不应创建传输任务" - ); + assert!(v.transfers.is_empty(), "未连接时下载不应创建传输任务"); }); }); } @@ -672,13 +665,14 @@ fn test_download_entry_action_without_connection_safe() { fn test_open_entry_on_file_triggers_download() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("readme.txt", b"hello"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("readme.txt", b"hello")]); // 双击文件条目应触发下载(文件选择器在 mock 中不触发) let file_idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "readme.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "readme.txt") + .unwrap() }); view.update(&mut app, |v, ctx| { @@ -701,13 +695,16 @@ fn test_open_entry_on_file_triggers_download() { fn test_delete_file_confirmed_removes_entry() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("to_delete.txt", b"delete me"), - ("keep.txt", b"keep me"), - ]); + let (_, view, _temp) = create_connected_view( + &mut app, + &[("to_delete.txt", b"delete me"), ("keep.txt", b"keep me")], + ); let file_idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "to_delete.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "to_delete.txt") + .unwrap() }); // 发起删除 @@ -738,10 +735,10 @@ fn test_delete_file_confirmed_removes_entry() { fn test_delete_directory_confirmed_removes_recursively() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("mydir/inner.txt", b"inner file"), - ("outer.txt", b"outer"), - ]); + let (_, view, _temp) = create_connected_view( + &mut app, + &[("mydir/inner.txt", b"inner file"), ("outer.txt", b"outer")], + ); let dir_idx = view.read(&app, |v, _| { v.entries.iter().position(|e| e.name == "mydir").unwrap() @@ -766,12 +763,13 @@ fn test_delete_directory_confirmed_removes_recursively() { fn test_rename_entry_updates_name() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("old_name.txt", b"content"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("old_name.txt", b"content")]); let idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "old_name.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "old_name.txt") + .unwrap() }); // 发起重命名 @@ -806,9 +804,7 @@ fn test_rename_entry_updates_name() { fn test_rename_empty_name_shows_error() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file.txt", b"content"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("file.txt", b"content")]); let idx = view.read(&app, |v, _| { v.entries.iter().position(|e| e.name == "file.txt").unwrap() @@ -830,10 +826,7 @@ fn test_rename_empty_name_shows_error() { }); view.read(&app, |v, _| { - assert!( - v.dialog.is_some(), - "空名称时对话框应保持打开" - ); + assert!(v.dialog.is_some(), "空名称时对话框应保持打开"); }); }); } @@ -844,8 +837,8 @@ fn test_new_folder_creates_entry() { warpui::App::test((), |mut app| async move { let temp = create_temp_dir_with_files(&[]); initialize_app(&mut app); - let backend = Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) as Arc; let (_, view) = create_view(&mut app); view.update(&mut app, |v, ctx| { v.set_backend_for_test(backend, PathBuf::from("/"), ctx); @@ -875,7 +868,9 @@ fn test_new_folder_creates_entry() { view.read(&app, |v, _| { assert!(v.dialog.is_none(), "对话框应关闭"); assert!( - v.entries.iter().any(|e| e.name == "test_folder" && e.file_type == FileEntryType::Directory), + v.entries + .iter() + .any(|e| e.name == "test_folder" && e.file_type == FileEntryType::Directory), "应出现新建的文件夹" ); }); @@ -910,10 +905,7 @@ fn test_new_folder_empty_name_shows_error() { }); view.read(&app, |v, _| { - assert!( - v.dialog.is_some(), - "空名称时对话框应保持打开" - ); + assert!(v.dialog.is_some(), "空名称时对话框应保持打开"); }); }); } @@ -923,26 +915,26 @@ fn test_new_folder_empty_name_shows_error() { fn test_file_details_dialog_shows_metadata() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("details.txt", b"file content here"), - ]); + let (_, view, _temp) = + create_connected_view(&mut app, &[("details.txt", b"file content here")]); let idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "details.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "details.txt") + .unwrap() }); view.update(&mut app, |v, ctx| { v.handle_action(&SftpBrowserAction::DetailsEntry(idx), ctx); }); - view.read(&app, |v, _| { - match &v.dialog { - Some(Dialog::FileDetails { entry }) => { - assert_eq!(entry.name, "details.txt"); - assert_eq!(entry.file_type, FileEntryType::File); - } - _ => panic!("应打开 FileDetails 对话框"), + view.read(&app, |v, _| match &v.dialog { + Some(Dialog::FileDetails { entry }) => { + assert_eq!(entry.name, "details.txt"); + assert_eq!(entry.file_type, FileEntryType::File); } + _ => panic!("应打开 FileDetails 对话框"), }); }); } @@ -952,12 +944,13 @@ fn test_file_details_dialog_shows_metadata() { fn test_delete_cancel_preserves_entry() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("keep_me.txt", b"keep"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("keep_me.txt", b"keep")]); let idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "keep_me.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "keep_me.txt") + .unwrap() }); view.update(&mut app, |v, ctx| { @@ -985,15 +978,16 @@ fn test_delete_cancel_preserves_entry() { fn test_right_click_opens_menu_and_selects_entry() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("menu_file.txt", b"content"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("menu_file.txt", b"content")]); view.update(&mut app, |v, ctx| { - v.handle_action(&SftpBrowserAction::ContextMenu { - index: 0, - position: Vector2F::new(100.0, 100.0), - }, ctx); + v.handle_action( + &SftpBrowserAction::ContextMenu { + index: 0, + position: Vector2F::new(100.0, 100.0), + }, + ctx, + ); }); view.read(&app, |v, _| { @@ -1008,16 +1002,17 @@ fn test_right_click_opens_menu_and_selects_entry() { fn test_context_menu_delete_item_triggers_delete() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("ctx_delete.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("ctx_delete.txt", b"x")]); // 打开右键菜单 view.update(&mut app, |v, ctx| { - v.handle_action(&SftpBrowserAction::ContextMenu { - index: 0, - position: Vector2F::new(50.0, 50.0), - }, ctx); + v.handle_action( + &SftpBrowserAction::ContextMenu { + index: 0, + position: Vector2F::new(50.0, 50.0), + }, + ctx, + ); }); // 从菜单选择删除 @@ -1039,15 +1034,16 @@ fn test_context_menu_delete_item_triggers_delete() { fn test_context_menu_rename_item_triggers_rename() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("ctx_rename.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("ctx_rename.txt", b"x")]); view.update(&mut app, |v, ctx| { - v.handle_action(&SftpBrowserAction::ContextMenu { - index: 0, - position: Vector2F::new(50.0, 50.0), - }, ctx); + v.handle_action( + &SftpBrowserAction::ContextMenu { + index: 0, + position: Vector2F::new(50.0, 50.0), + }, + ctx, + ); }); view.update(&mut app, |v, ctx| { @@ -1068,15 +1064,16 @@ fn test_context_menu_rename_item_triggers_rename() { fn test_context_menu_details_item_triggers_details() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("ctx_details.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("ctx_details.txt", b"x")]); view.update(&mut app, |v, ctx| { - v.handle_action(&SftpBrowserAction::ContextMenu { - index: 0, - position: Vector2F::new(50.0, 50.0), - }, ctx); + v.handle_action( + &SftpBrowserAction::ContextMenu { + index: 0, + position: Vector2F::new(50.0, 50.0), + }, + ctx, + ); }); view.update(&mut app, |v, ctx| { @@ -1097,15 +1094,16 @@ fn test_context_menu_details_item_triggers_details() { fn test_dismiss_click_closes_menu() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("menu_close.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("menu_close.txt", b"x")]); view.update(&mut app, |v, ctx| { - v.handle_action(&SftpBrowserAction::ContextMenu { - index: 0, - position: Vector2F::new(50.0, 50.0), - }, ctx); + v.handle_action( + &SftpBrowserAction::ContextMenu { + index: 0, + position: Vector2F::new(50.0, 50.0), + }, + ctx, + ); }); view.read(&app, |v, _| { @@ -1131,10 +1129,8 @@ fn test_dismiss_click_closes_menu() { fn test_delete_confirm_dialog_multiple_paths() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file_a.txt", b"a"), - ("file_b.txt", b"b"), - ]); + let (_, view, _temp) = + create_connected_view(&mut app, &[("file_a.txt", b"a"), ("file_b.txt", b"b")]); // 选中两个条目 view.update(&mut app, |v, ctx| { @@ -1148,13 +1144,11 @@ fn test_delete_confirm_dialog_multiple_paths() { v.handle_action(&SftpBrowserAction::DeleteSelected, ctx); }); - view.read(&app, |v, _| { - match &v.dialog { - Some(Dialog::DeleteConfirm { paths, .. }) => { - assert_eq!(paths.len(), 2, "应显示 2 个待删除路径"); - } - _ => panic!("应打开删除确认对话框"), + view.read(&app, |v, _| match &v.dialog { + Some(Dialog::DeleteConfirm { paths, .. }) => { + assert_eq!(paths.len(), 2, "应显示 2 个待删除路径"); } + _ => panic!("应打开删除确认对话框"), }); }); } @@ -1164,12 +1158,13 @@ fn test_delete_confirm_dialog_multiple_paths() { fn test_rename_editor_enter_confirms() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("rename_enter.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("rename_enter.txt", b"x")]); let idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "rename_enter.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "rename_enter.txt") + .unwrap() }); view.update(&mut app, |v, ctx| { @@ -1198,12 +1193,13 @@ fn test_rename_editor_enter_confirms() { fn test_rename_editor_escape_cancels() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("rename_esc.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("rename_esc.txt", b"x")]); let idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "rename_esc.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "rename_esc.txt") + .unwrap() }); view.update(&mut app, |v, ctx| { @@ -1266,9 +1262,7 @@ fn test_new_folder_editor_enter_confirms() { fn test_overwrite_confirm_dialog() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("file.txt", b"x")]); // 手动设置覆盖确认对话框 view.update(&mut app, |v, ctx| { @@ -1295,13 +1289,11 @@ fn test_overwrite_confirm_dialog() { #[test] fn test_move_confirm_dialog() { warpui::App::test((), |mut app| async move { - let temp = create_temp_dir_with_files(&[ - ("move_src.txt", b"move me"), - ("dest_dir/.keep", b""), - ]); + let temp = + create_temp_dir_with_files(&[("move_src.txt", b"move me"), ("dest_dir/.keep", b"")]); initialize_app(&mut app); - let backend = Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) as Arc; let (_, view) = create_view(&mut app); view.update(&mut app, |v, ctx| { v.set_backend_for_test(backend, PathBuf::from("/"), ctx); @@ -1341,8 +1333,8 @@ fn test_upload_creates_transfer_task() { std::fs::write(&local_file, b"upload content").unwrap(); initialize_app(&mut app); - let backend = Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) as Arc; let (_, view) = create_view(&mut app); view.update(&mut app, |v, ctx| { v.set_backend_for_test(backend, PathBuf::from("/"), ctx); @@ -1360,7 +1352,10 @@ fn test_upload_creates_transfer_task() { let task = &v.transfers[0]; assert_eq!(task.direction, TransferDirection::Upload); assert!( - matches!(task.state, TransferState::Completed | TransferState::InProgress | TransferState::Failed(_)), + matches!( + task.state, + TransferState::Completed | TransferState::InProgress | TransferState::Failed(_) + ), "传输任务应有明确状态" ); }); @@ -1395,21 +1390,22 @@ fn test_upload_nonexistent_file_fails() { #[test] fn test_download_creates_transfer_task() { warpui::App::test((), |mut app| async move { - let temp = create_temp_dir_with_files(&[ - ("download_me.txt", b"download content"), - ]); + let temp = create_temp_dir_with_files(&[("download_me.txt", b"download content")]); let local_save = temp.path().join("saved_file.txt"); initialize_app(&mut app); - let backend = Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) as Arc; let (_, view) = create_view(&mut app); view.update(&mut app, |v, ctx| { v.set_backend_for_test(backend, PathBuf::from("/"), ctx); }); let idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "download_me.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "download_me.txt") + .unwrap() }); view.update(&mut app, |v, ctx| { @@ -1497,9 +1493,7 @@ fn test_transfer_panel_renders_with_tasks() { fn test_drag_enter_shows_overlay() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("file.txt", b"x")]); view.update(&mut app, |v, ctx| { v.handle_action(&SftpBrowserAction::DragFilesEnter, ctx); @@ -1516,9 +1510,7 @@ fn test_drag_enter_shows_overlay() { fn test_drag_leave_hides_overlay() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("file.txt", b"x")]); view.update(&mut app, |v, ctx| { v.handle_action(&SftpBrowserAction::DragFilesEnter, ctx); @@ -1544,8 +1536,8 @@ fn test_drop_files_creates_upload_tasks() { std::fs::write(&drop_file, b"dropped content").unwrap(); initialize_app(&mut app); - let backend = Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) as Arc; let (_, view) = create_view(&mut app); view.update(&mut app, |v, ctx| { v.set_backend_for_test(backend, PathBuf::from("/"), ctx); @@ -1591,9 +1583,7 @@ fn test_drop_empty_paths_ignored() { fn test_keyboard_navigate_up() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("subdir/file.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("subdir/file.txt", b"x")]); // 进入子目录 let sub_idx = view.read(&app, |v, _| { @@ -1622,9 +1612,7 @@ fn test_keyboard_navigate_up() { fn test_keyboard_delete_selected() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("del_target.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("del_target.txt", b"x")]); // 选中第一个条目 view.update(&mut app, |v, ctx| { @@ -1671,9 +1659,7 @@ fn test_keyboard_create_folder() { fn test_keyboard_shortcuts_without_selection() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("file.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("file.txt", b"x")]); // 无选中 view.update(&mut app, |v, ctx| { @@ -1686,10 +1672,7 @@ fn test_keyboard_shortcuts_without_selection() { }); view.read(&app, |v, _| { - assert!( - v.dialog.is_none(), - "无选中时 DeleteSelected 不应打开对话框" - ); + assert!(v.dialog.is_none(), "无选中时 DeleteSelected 不应打开对话框"); assert_eq!(v.entries.len(), 1, "条目不应被删除"); }); }); @@ -1700,12 +1683,13 @@ fn test_keyboard_shortcuts_without_selection() { fn test_keyboard_escape_closes_dialog() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("esc_file.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("esc_file.txt", b"x")]); let idx = view.read(&app, |v, _| { - v.entries.iter().position(|e| e.name == "esc_file.txt").unwrap() + v.entries + .iter() + .position(|e| e.name == "esc_file.txt") + .unwrap() }); // 打开重命名对话框 @@ -1737,13 +1721,14 @@ fn test_keyboard_escape_closes_dialog() { fn test_render_with_all_overlays_connected() { warpui::App::test((), |mut app| async move { initialize_app(&mut app); - let (_, view, _temp) = create_connected_view(&mut app, &[ - ("overlay.txt", b"x"), - ]); + let (_, view, _temp) = create_connected_view(&mut app, &[("overlay.txt", b"x")]); // 打开右键菜单 view.update(&mut app, |v, ctx| { - v.context_menu = Some(super::context_menu::ContextMenuState::new(0, Vector2F::new(50.0, 50.0))); + v.context_menu = Some(super::context_menu::ContextMenuState::new( + 0, + Vector2F::new(50.0, 50.0), + )); // 打开对话框 v.dialog = Some(Dialog::DeleteConfirm { paths: vec![PathBuf::from("/overlay.txt")], @@ -1752,8 +1737,11 @@ fn test_render_with_all_overlays_connected() { // 添加传输任务 use super::types::TransferTask; v.transfers.push(TransferTask::new( - 1, PathBuf::from("/file.txt"), PathBuf::from("/local.txt"), - TransferDirection::Upload, 1024, + 1, + PathBuf::from("/file.txt"), + PathBuf::from("/local.txt"), + TransferDirection::Upload, + 1024, )); // 启用拖拽悬停 v.is_drag_hovering = true; @@ -1823,8 +1811,8 @@ fn test_render_after_multiple_operations() { ("root_file.txt", b"root"), ]); initialize_app(&mut app); - let backend = Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) - as Arc; + let backend = + Arc::new(InMemorySftpBackend::new(temp.path().to_path_buf())) as Arc; let (_, view) = create_view(&mut app); view.update(&mut app, |v, ctx| { v.set_backend_for_test(backend, PathBuf::from("/"), ctx); @@ -1840,7 +1828,10 @@ fn test_render_after_multiple_operations() { // 搜索 view.update(&mut app, |v, ctx| { - v.handle_action(&SftpBrowserAction::SetSearchFilter("file1".to_string()), ctx); + v.handle_action( + &SftpBrowserAction::SetSearchFilter("file1".to_string()), + ctx, + ); }); // 清除搜索 diff --git a/app/src/sftp_manager/browser_tests.rs b/app/src/sftp_manager/browser_tests.rs index 4b422bfac1f..5a42d520c46 100644 --- a/app/src/sftp_manager/browser_tests.rs +++ b/app/src/sftp_manager/browser_tests.rs @@ -278,13 +278,7 @@ fn test_context_menu_sets_state() { let position = Vector2F::new(100.0, 200.0); view.update(&mut app, |view, ctx| { - view.handle_action( - &SftpBrowserAction::ContextMenu { - index: 3, - position, - }, - ctx, - ); + view.handle_action(&SftpBrowserAction::ContextMenu { index: 3, position }, ctx); }); view.read(&app, |view, _| { @@ -295,10 +289,7 @@ fn test_context_menu_sets_state() { let cm = view.context_menu.as_ref().unwrap(); assert_eq!(cm.entry_index, 3, "entry_index 应为 3"); assert_eq!(cm.position, position, "position 应与传入值一致"); - assert!( - view.selected.contains(&3), - "ContextMenu 后应选中索引 3" - ); + assert!(view.selected.contains(&3), "ContextMenu 后应选中索引 3"); }); }); } @@ -375,18 +366,9 @@ fn test_context_menu_replaces_previous() { view.read(&app, |view, _| { let cm = view.context_menu.as_ref().unwrap(); assert_eq!(cm.entry_index, 5, "应更新为新的 entry_index"); - assert_eq!( - cm.position, new_position, - "应更新为新的 position" - ); - assert!( - view.selected.contains(&5), - "应选中新索引 5" - ); - assert!( - !view.selected.contains(&0), - "应取消选中旧索引 0" - ); + assert_eq!(cm.position, new_position, "应更新为新的 position"); + assert!(view.selected.contains(&5), "应选中新索引 5"); + assert!(!view.selected.contains(&0), "应取消选中旧索引 0"); }); }); } @@ -406,13 +388,7 @@ fn test_context_menu_zero_index() { let position = Vector2F::new(0.0, 0.0); view.update(&mut app, |view, ctx| { - view.handle_action( - &SftpBrowserAction::ContextMenu { - index: 0, - position, - }, - ctx, - ); + view.handle_action(&SftpBrowserAction::ContextMenu { index: 0, position }, ctx); }); view.read(&app, |view, _| { @@ -463,13 +439,7 @@ fn test_context_menu_negative_position() { let position = Vector2F::new(-50.0, -100.0); view.update(&mut app, |view, ctx| { - view.handle_action( - &SftpBrowserAction::ContextMenu { - index: 1, - position, - }, - ctx, - ); + view.handle_action(&SftpBrowserAction::ContextMenu { index: 1, position }, ctx); }); view.read(&app, |view, _| { @@ -497,10 +467,7 @@ fn test_close_context_menu_when_none() { }); view.read(&app, |view, _| { - assert!( - view.context_menu.is_none(), - "关闭后仍应为 None" - ); + assert!(view.context_menu.is_none(), "关闭后仍应为 None"); }); }); } @@ -574,10 +541,7 @@ fn test_context_menu_multiple_open_close_cycles() { view.handle_action(&SftpBrowserAction::CloseContextMenu, ctx); }); view.read(&app, |view, _| { - assert!( - view.context_menu.is_none(), - "第 {i} 次关闭后应为 None" - ); + assert!(view.context_menu.is_none(), "第 {i} 次关闭后应为 None"); }); } }); @@ -632,10 +596,7 @@ fn test_action_context_menu_variant() { }; assert!(matches!( action, - SftpBrowserAction::ContextMenu { - index: 3, - .. - } + SftpBrowserAction::ContextMenu { index: 3, .. } )); } @@ -790,9 +751,10 @@ fn test_confirm_new_folder_no_connection_with_dialog() { parent_path: PathBuf::from("/home"), }); // 先输入非空名称以跳过空名称检查 - view.new_folder_editor.update(ctx, |e: &mut EditorView, ctx| { - e.set_buffer_text("new_folder", ctx); - }); + view.new_folder_editor + .update(ctx, |e: &mut EditorView, ctx| { + e.set_buffer_text("new_folder", ctx); + }); view.handle_action(&SftpBrowserAction::ConfirmNewFolder, ctx); }); @@ -1197,10 +1159,7 @@ fn test_execute_upload_nonexistent_file() { view.read(&app, |view, _| { assert_eq!(view.transfers.len(), 1); - assert!(matches!( - view.transfers[0].state, - TransferState::Failed(_) - )); + assert!(matches!(view.transfers[0].state, TransferState::Failed(_))); }); }); } diff --git a/app/src/sftp_manager/context_menu.rs b/app/src/sftp_manager/context_menu.rs index dfb8a44c0fb..5e29e5e0ace 100644 --- a/app/src/sftp_manager/context_menu.rs +++ b/app/src/sftp_manager/context_menu.rs @@ -30,7 +30,10 @@ pub struct ContextMenuState { impl ContextMenuState { /// 创建新的右键菜单状态 pub fn new(entry_index: usize, position: Vector2F) -> Self { - Self { entry_index, position } + Self { + entry_index, + position, + } } } @@ -251,10 +254,22 @@ mod tests { let items = build_file_menu_items(index); assert!(matches!(&items[0].action, SftpBrowserAction::OpenEntry(7))); - assert!(matches!(&items[1].action, SftpBrowserAction::DownloadEntry(7))); - assert!(matches!(&items[2].action, SftpBrowserAction::RenameEntry(7))); - assert!(matches!(&items[3].action, SftpBrowserAction::DeleteEntry(7))); - assert!(matches!(&items[4].action, SftpBrowserAction::DetailsEntry(7))); + assert!(matches!( + &items[1].action, + SftpBrowserAction::DownloadEntry(7) + )); + assert!(matches!( + &items[2].action, + SftpBrowserAction::RenameEntry(7) + )); + assert!(matches!( + &items[3].action, + SftpBrowserAction::DeleteEntry(7) + )); + assert!(matches!( + &items[4].action, + SftpBrowserAction::DetailsEntry(7) + )); } /// 测试 index=0 时菜单项动作正确 @@ -262,8 +277,14 @@ mod tests { fn test_build_file_menu_items_zero_index() { let items = build_file_menu_items(0); assert!(matches!(&items[0].action, SftpBrowserAction::OpenEntry(0))); - assert!(matches!(&items[3].action, SftpBrowserAction::DeleteEntry(0))); - assert!(matches!(&items[4].action, SftpBrowserAction::DetailsEntry(0))); + assert!(matches!( + &items[3].action, + SftpBrowserAction::DeleteEntry(0) + )); + assert!(matches!( + &items[4].action, + SftpBrowserAction::DetailsEntry(0) + )); } // ============================================================ @@ -286,10 +307,9 @@ mod tests { let temp_db = std::env::temp_dir().join("warp_sftp_ctx_test.sqlite"); let _ = warp_ssh_manager::set_database_path(temp_db); - let (_, view) = app.add_window( - warpui::platform::WindowStyle::NotStealFocus, - |ctx| crate::sftp_manager::browser::SftpBrowserView::new("test-node".to_string(), ctx), - ); + let (_, view) = app.add_window(warpui::platform::WindowStyle::NotStealFocus, |ctx| { + crate::sftp_manager::browser::SftpBrowserView::new("test-node".to_string(), ctx) + }); // 触发右键菜单 view.update(&mut app, |view, ctx| { diff --git a/app/src/sftp_manager/dialogs.rs b/app/src/sftp_manager/dialogs.rs index a640e3bb0c2..c9d542567a8 100644 --- a/app/src/sftp_manager/dialogs.rs +++ b/app/src/sftp_manager/dialogs.rs @@ -9,8 +9,9 @@ use std::path::PathBuf; use warp_core::ui::appearance::Appearance; use warp_core::ui::icons::Icon; use warpui::elements::{ - Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Flex, - Hoverable, MainAxisSize, MainAxisAlignment, MouseStateHandle, ParentElement, Radius, SavePosition, Shrinkable, Text, + Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, + Dismiss, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, + Radius, SavePosition, Shrinkable, Text, }; use warpui::platform::Cursor; use warpui::Element; @@ -47,7 +48,11 @@ fn dialog_shell(content: Box, appearance: &Appearance) -> Box Box { +fn render_title_bar( + title: &str, + appearance: &Appearance, + close_btn_state: MouseStateHandle, +) -> Box { let theme = appearance.theme(); let text_color = theme.active_ui_text_color(); let ui_font = appearance.ui_font_family(); @@ -73,7 +78,10 @@ fn render_title_bar(title: &str, appearance: &Appearance, close_btn_state: Mouse } /// 渲染 X 图标关闭按钮 -fn render_icon_close_button(appearance: &Appearance, mouse_state: MouseStateHandle) -> Box { +fn render_icon_close_button( + appearance: &Appearance, + mouse_state: MouseStateHandle, +) -> Box { let theme = appearance.theme(); let icon_color = theme.sub_text_color(theme.background()); let icon_el = ConstrainedBox::new(Icon::X.to_warpui_icon(icon_color).finish()) @@ -154,8 +162,18 @@ fn render_button( } /// 渲染取消按钮 -fn render_cancel_button(appearance: &Appearance, mouse_state: MouseStateHandle) -> Box { - render_button("取消", false, appearance, SftpBrowserAction::CloseDialog, mouse_state, Some("sftp_btn:dialog_cancel")) +fn render_cancel_button( + appearance: &Appearance, + mouse_state: MouseStateHandle, +) -> Box { + render_button( + "取消", + false, + appearance, + SftpBrowserAction::CloseDialog, + mouse_state, + Some("sftp_btn:dialog_cancel"), + ) } /// 渲染描述性确认对话框(标题 + 描述 + 确认/取消按钮) @@ -186,7 +204,14 @@ fn render_confirm_dialog( ) .finish(); - let confirm_btn = render_button(confirm_label, true, appearance, confirm_action, confirm_btn_state, Some("sftp_btn:dialog_confirm")); + let confirm_btn = render_button( + confirm_label, + true, + appearance, + confirm_action, + confirm_btn_state, + Some("sftp_btn:dialog_confirm"), + ); let cancel_btn = render_cancel_button(appearance, cancel_btn_state); let buttons = Flex::row() @@ -283,11 +308,7 @@ fn render_rename( // 编辑器 — Shrinkable + Clipped 防止长文件名溢出 let editor_el = Container::new( - Shrinkable::new( - 1.0, - Clipped::new(ChildView::new(editor).finish()).finish(), - ) - .finish(), + Shrinkable::new(1.0, Clipped::new(ChildView::new(editor).finish()).finish()).finish(), ) .with_padding_left(8.0) .with_padding_right(8.0) @@ -348,11 +369,7 @@ fn render_create_folder( // 编辑器 let editor_el = Container::new( - Shrinkable::new( - 1.0, - Clipped::new(ChildView::new(editor).finish()).finish(), - ) - .finish(), + Shrinkable::new(1.0, Clipped::new(ChildView::new(editor).finish()).finish()).finish(), ) .with_padding_left(8.0) .with_padding_right(8.0) @@ -458,7 +475,11 @@ fn render_file_details( rows.add_child(detail_row("修改时间", modified, appearance)); let permissions = entry.permissions.as_deref().unwrap_or("--"); rows.add_child(detail_row("权限", permissions, appearance)); - rows.add_child(detail_row("路径", &entry.path.display().to_string(), appearance)); + rows.add_child(detail_row( + "路径", + &entry.path.display().to_string(), + appearance, + )); // 关闭按钮 let close_btn = render_cancel_button(appearance, cancel_btn_state); @@ -467,7 +488,11 @@ fn render_file_details( .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(12.0) .with_child(title_bar) - .with_child(ConstrainedBox::new(rows.finish()).with_max_height(250.0).finish()) + .with_child( + ConstrainedBox::new(rows.finish()) + .with_max_height(250.0) + .finish(), + ) .with_child(close_btn) .finish(); @@ -493,9 +518,7 @@ fn render_move_dialog( .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); let target_display = target_dir.display(); - let desc = format!( - "将 \"{source_name}\" 移动到 {target_display}" - ); + let desc = format!("将 \"{source_name}\" 移动到 {target_display}"); render_confirm_dialog( "移动文件", @@ -554,36 +577,63 @@ pub fn render_dialog( close_btn_state: MouseStateHandle, ) -> Box { match dialog { - Dialog::DeleteConfirm { paths, .. } => { - render_delete_confirm(paths, appearance, confirm_btn_state, cancel_btn_state, close_btn_state) - } - Dialog::Rename { + Dialog::DeleteConfirm { paths, .. } => render_delete_confirm( + paths, + appearance, + confirm_btn_state, + cancel_btn_state, + close_btn_state, + ), + Dialog::Rename { original_name, .. } => render_rename( original_name, - .. - } => render_rename(original_name, rename_editor, appearance, confirm_btn_state, cancel_btn_state, close_btn_state), - Dialog::CreateFolder { .. } => { - render_create_folder(new_folder_editor, appearance, confirm_btn_state, cancel_btn_state, close_btn_state) - } + rename_editor, + appearance, + confirm_btn_state, + cancel_btn_state, + close_btn_state, + ), + Dialog::CreateFolder { .. } => render_create_folder( + new_folder_editor, + appearance, + confirm_btn_state, + cancel_btn_state, + close_btn_state, + ), Dialog::FileDetails { entry } => { render_file_details(entry, appearance, cancel_btn_state, close_btn_state) } - Dialog::Move { source, target_dir } => { - render_move_dialog(source, target_dir, appearance, confirm_btn_state, cancel_btn_state, close_btn_state) - } - Dialog::OverwriteConfirm { source, target, file_size, direction } => { - render_overwrite_confirm(source, target, *file_size, *direction, appearance, confirm_btn_state, cancel_btn_state, close_btn_state) - } - Dialog::CloseTransferPanelConfirm => { - render_confirm_dialog( - "关闭传输面板", - "有正在进行的传输任务,关闭将中断所有传输并清空记录。确定要关闭吗?", - "关闭", - SftpBrowserAction::ConfirmCloseTransferPanel, - appearance, - confirm_btn_state, - cancel_btn_state, - close_btn_state, - ) - } + Dialog::Move { source, target_dir } => render_move_dialog( + source, + target_dir, + appearance, + confirm_btn_state, + cancel_btn_state, + close_btn_state, + ), + Dialog::OverwriteConfirm { + source, + target, + file_size, + direction, + } => render_overwrite_confirm( + source, + target, + *file_size, + *direction, + appearance, + confirm_btn_state, + cancel_btn_state, + close_btn_state, + ), + Dialog::CloseTransferPanelConfirm => render_confirm_dialog( + "关闭传输面板", + "有正在进行的传输任务,关闭将中断所有传输并清空记录。确定要关闭吗?", + "关闭", + SftpBrowserAction::ConfirmCloseTransferPanel, + appearance, + confirm_btn_state, + cancel_btn_state, + close_btn_state, + ), } } diff --git a/app/src/sftp_manager/drop_target.rs b/app/src/sftp_manager/drop_target.rs index d09b7de3fbc..b5db2be5cd7 100644 --- a/app/src/sftp_manager/drop_target.rs +++ b/app/src/sftp_manager/drop_target.rs @@ -10,11 +10,8 @@ use std::any::Any; use std::path::PathBuf; use warpui::{ - elements::Point, - event::DispatchedEvent, - geometry::vector::Vector2F, - AfterLayoutContext, AppContext, Element, Event, EventContext, LayoutContext, PaintContext, - SizeConstraint, + elements::Point, event::DispatchedEvent, geometry::vector::Vector2F, AfterLayoutContext, + AppContext, Element, Event, EventContext, LayoutContext, PaintContext, SizeConstraint, }; use super::browser::SftpBrowserAction; @@ -93,8 +90,7 @@ impl Element for SftpDropTargetElement { } Event::DragAndDropFiles { paths, location } => { if self.mouse_position_is_in_bounds(*location) && !paths.is_empty() { - let paths: Vec = - paths.iter().map(PathBuf::from).collect(); + let paths: Vec = paths.iter().map(PathBuf::from).collect(); ctx.dispatch_typed_action(SftpBrowserAction::DragAndDropFiles(paths)); } return true; diff --git a/app/src/sftp_manager/file_list.rs b/app/src/sftp_manager/file_list.rs index 211da3ad46d..744a00ed74c 100644 --- a/app/src/sftp_manager/file_list.rs +++ b/app/src/sftp_manager/file_list.rs @@ -9,8 +9,8 @@ use std::collections::HashSet; use warp_core::ui::appearance::Appearance; use warp_core::ui::theme::color::internal_colors; use warpui::elements::{ - ConstrainedBox, Container, CrossAxisAlignment, Fill, Flex, Hoverable, - MouseStateHandle, ParentElement, SavePosition, Shrinkable, Text, + ConstrainedBox, Container, CrossAxisAlignment, Fill, Flex, Hoverable, MouseStateHandle, + ParentElement, SavePosition, Shrinkable, Text, }; use warpui::platform::Cursor; use warpui::Element; @@ -46,7 +46,10 @@ pub fn render_file_row( } else { theme.background().into_solid() }; - let icon_color = if matches!(entry.file_type, FileEntryType::Directory | FileEntryType::Symlink) { + let icon_color = if matches!( + entry.file_type, + FileEntryType::Directory | FileEntryType::Symlink + ) { theme.accent().into_solid() } else { theme.sub_text_color(theme.background()).into_solid() @@ -65,7 +68,9 @@ pub fn render_file_row( Hoverable::new(mouse_handle, move |_| { // 图标 let icon_el = ConstrainedBox::new( - file_icon(&file_type).to_warpui_icon(icon_color.into()).finish(), + file_icon(&file_type) + .to_warpui_icon(icon_color.into()) + .finish(), ) .with_width(16.0) .with_height(16.0) @@ -208,8 +213,7 @@ pub fn render_file_rows( mouse_handles: &[MouseStateHandle], appearance: &Appearance, ) -> Box { - let mut col = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch); + let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); for &index in filtered_indices { let entry = &entries[index]; diff --git a/app/src/sftp_manager/sftp_backend.rs b/app/src/sftp_manager/sftp_backend.rs index e77ac1cbe5b..e3f3c9fab32 100644 --- a/app/src/sftp_manager/sftp_backend.rs +++ b/app/src/sftp_manager/sftp_backend.rs @@ -101,7 +101,9 @@ impl SftpBackend for LiveSftpBackend { } fn realpath(&self, path: &Path) -> Result { - self.sftp.realpath(path).map_err(|e| SftpOpsError::Operation(e.to_string())) + self.sftp + .realpath(path) + .map_err(|e| SftpOpsError::Operation(e.to_string())) } fn stat(&self, path: &Path) -> Result { @@ -160,7 +162,6 @@ impl SftpBackend for LiveSftpBackend { } } - // ============================================================ // InMemorySftpBackend — 基于本地文件系统的测试实现 // ============================================================ @@ -237,23 +238,20 @@ impl SftpBackend for InMemorySftpBackend { fn list_dir(&self, path: &Path) -> Result, SftpOpsError> { let local = self.to_local(path); let p = path.display(); - let entries = fs::read_dir(&local).map_err(|e| { - SftpOpsError::Operation(format!("列出目录失败 {p}: {e}")) - })?; + let entries = fs::read_dir(&local) + .map_err(|e| SftpOpsError::Operation(format!("列出目录失败 {p}: {e}")))?; let mut result = Vec::new(); for entry in entries { - let entry = entry.map_err(|e| { - SftpOpsError::Operation(format!("读取目录条目失败: {e}")) - })?; + let entry = + entry.map_err(|e| SftpOpsError::Operation(format!("读取目录条目失败: {e}")))?; let name = entry.file_name().to_string_lossy().to_string(); // 过滤 . 和 .. if name == "." || name == ".." { continue; } - let meta = fs::symlink_metadata(entry.path()).map_err(|e| { - SftpOpsError::Operation(format!("读取元数据失败: {e}")) - })?; + let meta = fs::symlink_metadata(entry.path()) + .map_err(|e| SftpOpsError::Operation(format!("读取元数据失败: {e}")))?; result.push(self.metadata_to_entry(name, &entry.path(), &meta)); } @@ -263,25 +261,22 @@ impl SftpBackend for InMemorySftpBackend { fn delete_file(&self, path: &Path) -> Result<(), SftpOpsError> { let local = self.to_local(path); let p = path.display(); - fs::remove_file(&local).map_err(|e| { - SftpOpsError::Operation(format!("删除文件失败 {p}: {e}")) - }) + fs::remove_file(&local) + .map_err(|e| SftpOpsError::Operation(format!("删除文件失败 {p}: {e}"))) } fn delete_dir_recursive(&self, path: &Path) -> Result<(), SftpOpsError> { let local = self.to_local(path); let p = path.display(); - fs::remove_dir_all(&local).map_err(|e| { - SftpOpsError::Operation(format!("递归删除目录失败 {p}: {e}")) - }) + fs::remove_dir_all(&local) + .map_err(|e| SftpOpsError::Operation(format!("递归删除目录失败 {p}: {e}"))) } fn create_dir(&self, path: &Path) -> Result<(), SftpOpsError> { let local = self.to_local(path); let p = path.display(); - fs::create_dir(&local).map_err(|e| { - SftpOpsError::Operation(format!("创建目录失败 {p}: {e}")) - }) + fs::create_dir(&local) + .map_err(|e| SftpOpsError::Operation(format!("创建目录失败 {p}: {e}"))) } fn rename(&self, old_path: &Path, new_path: &Path) -> Result<(), SftpOpsError> { @@ -299,18 +294,16 @@ impl SftpBackend for InMemorySftpBackend { fn realpath(&self, path: &Path) -> Result { let local = self.to_local(path); let p = path.display(); - let canonical = dunce::canonicalize(&local).map_err(|e| { - SftpOpsError::Operation(format!("解析路径失败 {p}: {e}")) - })?; + let canonical = dunce::canonicalize(&local) + .map_err(|e| SftpOpsError::Operation(format!("解析路径失败 {p}: {e}")))?; Ok(self.to_remote(&canonical)) } fn stat(&self, path: &Path) -> Result { let local = self.to_local(path); let p = path.display(); - let meta = fs::symlink_metadata(&local).map_err(|e| { - SftpOpsError::Operation(format!("获取文件信息失败 {p}: {e}")) - })?; + let meta = fs::symlink_metadata(&local) + .map_err(|e| SftpOpsError::Operation(format!("获取文件信息失败 {p}: {e}")))?; let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -328,13 +321,11 @@ impl SftpBackend for InMemorySftpBackend { let dest = self.to_local(remote_path); // 确保父目录存在 if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).map_err(|e| { - SftpOpsError::LocalIo(format!("创建目录失败: {e}")) - })?; + fs::create_dir_all(parent) + .map_err(|e| SftpOpsError::LocalIo(format!("创建目录失败: {e}")))?; } - fs::copy(local_path, &dest).map_err(|e| { - SftpOpsError::LocalIo(format!("上传文件失败: {e}")) - })?; + fs::copy(local_path, &dest) + .map_err(|e| SftpOpsError::LocalIo(format!("上传文件失败: {e}")))?; Ok(()) } @@ -348,34 +339,31 @@ impl SftpBackend for InMemorySftpBackend { let src = self.to_local(remote_path); // 确保本地父目录存在 if let Some(parent) = local_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - SftpOpsError::LocalIo(format!("创建目录失败: {e}")) - })?; + fs::create_dir_all(parent) + .map_err(|e| SftpOpsError::LocalIo(format!("创建目录失败: {e}")))?; } - let mut src_file = fs::File::open(&src).map_err(|e| { - SftpOpsError::LocalIo(format!("打开远程文件失败: {e}")) - })?; - let mut dest_file = fs::File::create(local_path).map_err(|e| { - SftpOpsError::LocalIo(format!("创建本地文件失败: {e}")) - })?; + let mut src_file = fs::File::open(&src) + .map_err(|e| SftpOpsError::LocalIo(format!("打开远程文件失败: {e}")))?; + let mut dest_file = fs::File::create(local_path) + .map_err(|e| SftpOpsError::LocalIo(format!("创建本地文件失败: {e}")))?; // 分块复制以模拟流式传输 const CHUNK_SIZE: usize = 32 * 1024; let mut buf = vec![0u8; CHUNK_SIZE]; loop { - let n = src_file.read(&mut buf).map_err(|e| { - SftpOpsError::LocalIo(format!("读取失败: {e}")) - })?; + let n = src_file + .read(&mut buf) + .map_err(|e| SftpOpsError::LocalIo(format!("读取失败: {e}")))?; if n == 0 { break; } - dest_file.write_all(&buf[..n]).map_err(|e| { - SftpOpsError::LocalIo(format!("写入失败: {e}")) - })?; + dest_file + .write_all(&buf[..n]) + .map_err(|e| SftpOpsError::LocalIo(format!("写入失败: {e}")))?; } - dest_file.flush().map_err(|e| { - SftpOpsError::LocalIo(format!("刷新失败: {e}")) - })?; + dest_file + .flush() + .map_err(|e| SftpOpsError::LocalIo(format!("刷新失败: {e}")))?; Ok(()) } } diff --git a/app/src/sftp_manager/sftp_ops.rs b/app/src/sftp_manager/sftp_ops.rs index aae93bc7ecf..8abb1f1568c 100644 --- a/app/src/sftp_manager/sftp_ops.rs +++ b/app/src/sftp_manager/sftp_ops.rs @@ -10,12 +10,12 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use warp_ssh_manager::SshRepository; use warp_ssh_manager::secrets::SshSecretStore; use warp_ssh_manager::types::{AuthType, ResolvedSshAuth, SshServerInfo}; -use zap_sftp::Sftp; +use warp_ssh_manager::SshRepository; use zap_sftp::session::{AuthMethod, SftpSession}; use zap_sftp::types::OpenOptions; +use zap_sftp::Sftp; use super::types::{FileEntry, FileEntryType}; diff --git a/app/src/sftp_manager/transfer_panel.rs b/app/src/sftp_manager/transfer_panel.rs index a98e5c3fe90..04afd4df9d7 100644 --- a/app/src/sftp_manager/transfer_panel.rs +++ b/app/src/sftp_manager/transfer_panel.rs @@ -7,7 +7,8 @@ use warp_core::ui::appearance::Appearance; use warpui::elements::{ Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable, - MainAxisSize, MainAxisAlignment, MouseStateHandle, ParentElement, Radius, SavePosition, Shrinkable, Text, + MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, SavePosition, + Shrinkable, Text, }; use warpui::platform::Cursor; use warpui::Element; diff --git a/app/src/sftp_manager/types.rs b/app/src/sftp_manager/types.rs index 6e68b921017..72316c1442f 100644 --- a/app/src/sftp_manager/types.rs +++ b/app/src/sftp_manager/types.rs @@ -4,8 +4,8 @@ //! date: 2026-05-26 use std::path::PathBuf; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; /// 文件条目类型(UI 层) #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -137,7 +137,9 @@ pub enum Dialog { file_size: u64, direction: TransferDirection, }, - FileDetails { entry: FileEntry }, + FileDetails { + entry: FileEntry, + }, /// 关闭传输面板确认(有活跃传输时) CloseTransferPanelConfirm, } @@ -400,7 +402,10 @@ mod tests { #[test] fn test_transfer_state_variants() { assert!(matches!(TransferState::Pending, TransferState::Pending)); - assert!(matches!(TransferState::InProgress, TransferState::InProgress)); + assert!(matches!( + TransferState::InProgress, + TransferState::InProgress + )); assert!(matches!(TransferState::Completed, TransferState::Completed)); assert!(matches!(TransferState::Cancelled, TransferState::Cancelled)); let failed = TransferState::Failed("io error".into()); @@ -559,7 +564,10 @@ mod tests { /// 测试 Dialog::DeleteConfirm 空路径列表 #[test] fn test_dialog_delete_confirm_empty_paths() { - let dialog = Dialog::DeleteConfirm { paths: vec![], is_dirs: vec![] }; + let dialog = Dialog::DeleteConfirm { + paths: vec![], + is_dirs: vec![], + }; assert!(matches!(dialog, Dialog::DeleteConfirm { .. })); } diff --git a/app/src/skill_manager/panel.rs b/app/src/skill_manager/panel.rs index 1b413e3dad0..91ac5384be7 100644 --- a/app/src/skill_manager/panel.rs +++ b/app/src/skill_manager/panel.rs @@ -66,7 +66,10 @@ impl SkillManagerPanel { pub fn new(ctx: &mut ViewContext) -> Self { let query_editor = ctx.add_typed_action_view(|ctx| { let options = EditorOptions { - text: TextOptions::ui_text(Some(Appearance::as_ref(ctx).ui_font_subheading()), Appearance::as_ref(ctx)), + text: TextOptions::ui_text( + Some(Appearance::as_ref(ctx).ui_font_subheading()), + Appearance::as_ref(ctx), + ), propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::AtBoundary, propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::Always, diff --git a/app/src/ssh_manager/candidates.rs b/app/src/ssh_manager/candidates.rs index d791598d76d..f854e2cd308 100644 --- a/app/src/ssh_manager/candidates.rs +++ b/app/src/ssh_manager/candidates.rs @@ -22,7 +22,7 @@ use std::collections::HashSet; use settings::Setting; -use warp_ssh_manager::{LoadOutcome, LoadResult, SshConfigCandidate, load_candidates}; +use warp_ssh_manager::{load_candidates, LoadOutcome, LoadResult, SshConfigCandidate}; use warpui::{Entity, ModelContext, SingletonEntity}; use crate::settings::SshSettings; diff --git a/app/src/ssh_manager/candidates_tests.rs b/app/src/ssh_manager/candidates_tests.rs index 3eb31e10ec0..8cae8fb26c6 100644 --- a/app/src/ssh_manager/candidates_tests.rs +++ b/app/src/ssh_manager/candidates_tests.rs @@ -10,8 +10,8 @@ use std::path::PathBuf; use warp_ssh_manager::SshConfigCandidate; use super::{ - CandidateRow, CandidatesViewModel, fake_load_result_error, fake_load_result_loaded, - fake_load_result_not_found, + fake_load_result_error, fake_load_result_loaded, fake_load_result_not_found, CandidateRow, + CandidatesViewModel, }; fn cand(alias: &str) -> SshConfigCandidate { diff --git a/app/src/ssh_manager/su_password_injector_tests.rs b/app/src/ssh_manager/su_password_injector_tests.rs index 69e0ca886bc..4d8a58a5984 100644 --- a/app/src/ssh_manager/su_password_injector_tests.rs +++ b/app/src/ssh_manager/su_password_injector_tests.rs @@ -25,7 +25,9 @@ fn password_prompt_matches_typical_forms() { assert!(pw_matches("输入密码")); assert!(pw_matches("输入密码 ")); // passphrase - assert!(pw_matches("Enter passphrase for key '/home/u/.ssh/id_rsa': ")); + assert!(pw_matches( + "Enter passphrase for key '/home/u/.ssh/id_rsa': " + )); } #[test] @@ -36,7 +38,9 @@ fn password_prompt_rejects_false_positives() { assert!(!pw_matches("password changed successfully")); assert!(!pw_matches("New password for root")); assert!(!pw_matches("Welcome! Please change your password soon.\n")); - assert!(!pw_matches("Last login: Mon Jan 1 password rotated yesterday\n")); + assert!(!pw_matches( + "Last login: Mon Jan 1 password rotated yesterday\n" + )); // 中文同理 assert!(!pw_matches("您的密码已过期")); } diff --git a/app/src/tab.rs b/app/src/tab.rs index b352c382571..2133dd9b480 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -8,6 +8,7 @@ use crate::features::FeatureFlag; use crate::launch_configs::launch_config::LaunchConfig; use crate::menu::{MenuAction, MenuItem, MenuItemFields}; use crate::pane_group::PaneGroup; +use crate::project_organization::domain::RepositoryWorkspaceId; use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; use settings::Setting as _; use std::sync::Arc; @@ -144,6 +145,7 @@ pub struct TabData { pub indicator_hover_state: MouseStateHandle, // Used by a later drag-tab branch to distinguish tabs that have moved into detached windows. pub detached: bool, + pub repository_workspace_id: Option, } const TAB_COLOR_ICON_PATH: &str = "bundled/svg/ellipse.svg"; @@ -161,6 +163,7 @@ impl TabData { selected_color: SelectedTabColor::Unset, indicator_hover_state: Default::default(), detached: false, + repository_workspace_id: None, } } diff --git a/app/src/terminal/input/models/data_source.rs b/app/src/terminal/input/models/data_source.rs index 83253bce80e..94af6cd78c9 100644 --- a/app/src/terminal/input/models/data_source.rs +++ b/app/src/terminal/input/models/data_source.rs @@ -581,7 +581,9 @@ impl SearchItem for ModelSearchItem { theme.disabled_ui_text_color().into_solid(), HighlightedHyperlink::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(theme.accent().into_solid()) .register_default_click_handlers_with_action_support(|hyperlink_lens, event, ctx| { match hyperlink_lens { diff --git a/app/src/terminal/local_tty/windows/mod.rs b/app/src/terminal/local_tty/windows/mod.rs index 8d5ac7303cb..6f289128171 100644 --- a/app/src/terminal/local_tty/windows/mod.rs +++ b/app/src/terminal/local_tty/windows/mod.rs @@ -25,8 +25,8 @@ use windows::Win32::Foundation::{HANDLE, WAIT_OBJECT_0}; use windows::Win32::System::Console::{COORD, HPCON}; use windows::Win32::System::Threading::{ CreateProcessW, WaitForSingleObject, CREATE_BREAKAWAY_FROM_JOB, CREATE_UNICODE_ENVIRONMENT, - EXTENDED_STARTUPINFO_PRESENT, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, - STARTUPINFOEXW, STARTUPINFOW, + EXTENDED_STARTUPINFO_PRESENT, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTUPINFOEXW, + STARTUPINFOW, }; use crate::terminal::local_tty::windows::proc_thread_attribute_list::ProcThreadAttributeList; diff --git a/app/src/terminal/model/block/serialized_block.rs b/app/src/terminal/model/block/serialized_block.rs index dfd4cbf4463..b7355a265b3 100644 --- a/app/src/terminal/model/block/serialized_block.rs +++ b/app/src/terminal/model/block/serialized_block.rs @@ -121,7 +121,10 @@ pub struct SerializedAIMetadata { /// `true` if this block should be hidden from the user (as is the case with AI-requested /// commands, for example). - #[serde(default = "default_as_true", skip_serializing_if = "skip_hide_when_true")] + #[serde( + default = "default_as_true", + skip_serializing_if = "skip_hide_when_true" + )] should_hide_block: bool, } @@ -265,6 +268,13 @@ impl SerializedBlock { .map_err(|e| anyhow::anyhow!("Failed to deserialize block from JSON: {e}")) } + /// 当应用在 shell 报告完成前退出时,将活动 block 标记为已结束。退出码 130 与 UI + /// 现有的 SIGINT 语义保持一致。 + pub(crate) fn finalize_for_shutdown(&mut self, completed_ts: DateTime) { + self.completed_ts = Some(completed_ts); + self.exit_code = ExitCode::from(130); + } + pub fn has_failed(&self) -> bool { let block_state = match self.did_execute { true => BlockState::DoneWithExecution, @@ -274,7 +284,7 @@ impl SerializedBlock { } } -/// We should only be serializing a block that has finished. +/// Serializes a block for persistence. impl From<&Block> for SerializedBlock { fn from(block: &Block) -> Self { let stylized_command = block diff --git a/app/src/terminal/model/block/serialized_block_tests.rs b/app/src/terminal/model/block/serialized_block_tests.rs index ef4eb4e4a46..4abce594f13 100644 --- a/app/src/terminal/model/block/serialized_block_tests.rs +++ b/app/src/terminal/model/block/serialized_block_tests.rs @@ -98,3 +98,15 @@ fn serialized_ai_metadata_preserves_explicit_visible_block() { assert!(!agent_metadata.should_hide_block()); } + +#[test] +fn active_block_can_be_finalized_for_shutdown() { + let completed_ts = chrono::Local::now(); + let mut block = SerializedBlock::new_for_test(b"claude".to_vec(), Vec::new()); + block.completed_ts = None; + + block.finalize_for_shutdown(completed_ts); + + assert_eq!(block.completed_ts, Some(completed_ts)); + assert_eq!(block.exit_code.value(), 130); +} diff --git a/app/src/terminal/model/session/command_executor/in_band_command_executor.rs b/app/src/terminal/model/session/command_executor/in_band_command_executor.rs index 37bb32ac30a..d12a2e8e8a7 100644 --- a/app/src/terminal/model/session/command_executor/in_band_command_executor.rs +++ b/app/src/terminal/model/session/command_executor/in_band_command_executor.rs @@ -329,7 +329,9 @@ impl InBandCommandExecutor { let in_band_command = match shell.shell_type() { ShellType::PowerShell => { - format!("Zap-Run-GeneratorCommand {id} '{escaped_command}' -ErrorAction Ignore") + format!( + "Zap-Run-GeneratorCommand {id} '{escaped_command}' -ErrorAction Ignore" + ) } ShellType::Fish => { // Add a leading space for in-band commands in fish, which omits them from diff --git a/app/src/terminal/ssh/install_tmux.rs b/app/src/terminal/ssh/install_tmux.rs index 9e9ca955e58..28a9b1eae73 100644 --- a/app/src/terminal/ssh/install_tmux.rs +++ b/app/src/terminal/ssh/install_tmux.rs @@ -24,8 +24,7 @@ use warpui::{ }; use warpui::{BlurContext, FocusContext}; -pub const WHY_INSTALL_TMUX_URL: &str = - ""; +pub const WHY_INSTALL_TMUX_URL: &str = ""; #[derive(Debug, Clone)] pub struct TmuxInstallMethod { diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 131b1e78c55..4e824658a24 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -118,7 +118,7 @@ use crate::code_review::git_status_update::{ GitRepoStatusModel, GitStatusMetadata, GitStatusUpdateModel, }; use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; -use crate::projects::ProjectManagementModel; +use crate::project_organization::model::ProjectOrganizationModel; use crate::remote_server::manager::{ RemoteServerInitPhase, RemoteServerManager, RemoteServerManagerEvent, }; @@ -644,16 +644,13 @@ const BOOTSTRAP_FAILED_DURATION: Duration = Duration::from_secs(7); /// a user needing to type in one or many secret manager passwords /// during the bootstrap period. const ENV_VAR_BOOTSTRAP_FAILED_DURATION: Duration = Duration::from_secs(60); -const KNOWN_ISSUES_URL: &str = - ""; +const KNOWN_ISSUES_URL: &str = ""; /// Link to supported custom prompts. -const PROMPT_COMPATIBILITY_URL: &str = - ""; +const PROMPT_COMPATIBILITY_URL: &str = ""; /// Link to troubleshooting steps for ControlMaster errors. -const CONTROLMASTER_ISSUES_URL: &str = - ""; +const CONTROLMASTER_ISSUES_URL: &str = ""; /// Link to instructions on how to update p10k. const P10K_UPDATE_INSTRUCTIONS_URL: &str = @@ -679,10 +676,8 @@ const MIN_DELTA_FOR_TEXT_SELECTION: f32 = 0.5; /// Notifications-specific info /// TODO (suraj): add documentation for notifications in gitbook -const NOTIFICATIONS_LEARN_MORE_URL: &str = - ""; -pub const NOTIFICATIONS_TROUBLESHOOT_URL: &str = - ""; +const NOTIFICATIONS_LEARN_MORE_URL: &str = ""; +pub const NOTIFICATIONS_TROUBLESHOOT_URL: &str = ""; const DEBOUNCE_PERIOD: Duration = Duration::from_millis(40); @@ -1975,7 +1970,9 @@ impl ContextMenuInfo { ContextMenuType::BlockList { .. } => "Block", ContextMenuType::Prompt { .. } => "Prompt", ContextMenuType::Input { .. } => "Input", - ContextMenuType::OneKeyPrompt | ContextMenuType::SuRootPasswordConfirm => "OneKeyPrompt", + ContextMenuType::OneKeyPrompt | ContextMenuType::SuRootPasswordConfirm => { + "OneKeyPrompt" + } ContextMenuType::AltScreen { .. } => "AltScreen", ContextMenuType::AIBlockAttachedContext { .. } => "AIBlockContextList", ContextMenuType::AIBlockOverflowMenu { .. } => "AIBlockOverflowMenu", @@ -1996,7 +1993,9 @@ impl ContextMenuInfo { }, ContextMenuType::Prompt { .. } => "RightClick", ContextMenuType::Input { .. } => "RightClick", - ContextMenuType::OneKeyPrompt | ContextMenuType::SuRootPasswordConfirm => "PasswordPrompt", + ContextMenuType::OneKeyPrompt | ContextMenuType::SuRootPasswordConfirm => { + "PasswordPrompt" + } ContextMenuType::AltScreen { .. } => "AltScreen", ContextMenuType::AIBlockAttachedContext { .. } => "AIBlockAttachedBlockChipLeftClick", ContextMenuType::AIBlockOverflowMenu { .. } => "AIBlockOverflowMenuClick", @@ -5216,12 +5215,7 @@ impl TerminalView { let should_forward_windows_ctrl_c = is_live; ctx.subscribe_to_view(&subagent_view, move |me, view, event, ctx| { - me.handle_cli_subagent_view_event( - view.id(), - event, - should_forward_windows_ctrl_c, - ctx, - ); + me.handle_cli_subagent_view_event(view.id(), event, should_forward_windows_ctrl_c, ctx); }); if is_live { @@ -5403,18 +5397,17 @@ impl TerminalView { if let Some(conversation_id) = conversation_id { let should_restore = { let model = self.model.lock(); - model.block_list().block_with_id(block_id).is_some_and( - |block| { + model + .block_list() + .block_with_id(block_id) + .is_some_and(|block| { block.agent_interaction_metadata().is_some_and(|metadata| { metadata.conversation_id() == conversation_id && metadata.subagent_task_id() == Some(task_id) }) - }, - ) + }) }; - if should_restore - && !self.cli_subagent_views.contains_key(block_id) - { + if should_restore && !self.cli_subagent_views.contains_key(block_id) { self.create_cli_subagent_view( block_id.clone(), *conversation_id, @@ -6401,6 +6394,21 @@ impl TerminalView { }) } + /// 返回应用退出时被中断命令的持久化快照。已完成 block 由正常完成事件保存,因此跳过。 + pub(crate) fn active_block_snapshot_for_shutdown(&self) -> Option { + let mut snapshot = { + let model = self.model.lock(); + SerializedBlock::from(model.block_list().active_block()) + }; + + if snapshot.start_ts.is_none() || snapshot.completed_ts.is_some() { + return None; + } + + snapshot.finalize_for_shutdown(Local::now()); + Some(snapshot) + } + /// Returns the active session's launch shell, if it is specified. /// Returns None if there is no active session or if the current session does not /// have a launch shell. @@ -15456,7 +15464,9 @@ impl TerminalView { ctx.update_view(&self.context_menu, |context_menu, view_ctx| { context_menu.set_origin(menu_state.menu_type.origin()); let width = match menu_state.menu_type { - ContextMenuType::OneKeyPrompt | ContextMenuType::SuRootPasswordConfirm => ONEKEY_CONTEXT_MENU_WIDTH, + ContextMenuType::OneKeyPrompt | ContextMenuType::SuRootPasswordConfirm => { + ONEKEY_CONTEXT_MENU_WIDTH + } ContextMenuType::BlockList { .. } | ContextMenuType::AltScreen { .. } | ContextMenuType::Prompt { .. } @@ -24430,14 +24440,22 @@ impl TypedActionView for TerminalView { } SummarizeConversation => self.summarize_conversation(ctx), AddProjectAtCurrentDirectory => { - // Get the current working directory and add it as a project if let Some(current_dir) = self.pwd() { let path = PathBuf::from(¤t_dir); - - // Access the ProjectManagementModel and add the project - ProjectManagementModel::handle(ctx).update(ctx, |project_model, ctx| { - project_model.upsert_project(path, ctx); - }); + if let Err(error) = ProjectOrganizationModel::handle(ctx) + .update(ctx, |model, ctx| model.touch_repository_path(path, ctx)) + { + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!( + "Failed to add repository: {error}" + )), + window_id, + ctx, + ); + }); + } } } OpenProjectRulesPane => { @@ -24844,27 +24862,31 @@ impl View for TerminalView { } }, ), - Some(ContextMenuType::OneKeyPrompt) | Some(ContextMenuType::SuRootPasswordConfirm) => stack.add_positioned_overlay_child( - ChildView::new(&self.context_menu).finish(), - match input_mode { - InputMode::PinnedToBottom | InputMode::Waterfall => { - OffsetPositioning::offset_from_save_position_element( - self.input.as_ref(app).save_position_id(), - vec2f(0., -8.), - PositionedElementOffsetBounds::WindowByPosition, - PositionedElementAnchor::TopLeft, - ChildAnchor::BottomLeft, - ) - } - InputMode::PinnedToTop => OffsetPositioning::offset_from_save_position_element( - self.input.as_ref(app).save_position_id(), - vec2f(0., 8.), - PositionedElementOffsetBounds::WindowByPosition, - PositionedElementAnchor::BottomLeft, - ChildAnchor::TopLeft, - ), - }, - ), + Some(ContextMenuType::OneKeyPrompt) | Some(ContextMenuType::SuRootPasswordConfirm) => { + stack.add_positioned_overlay_child( + ChildView::new(&self.context_menu).finish(), + match input_mode { + InputMode::PinnedToBottom | InputMode::Waterfall => { + OffsetPositioning::offset_from_save_position_element( + self.input.as_ref(app).save_position_id(), + vec2f(0., -8.), + PositionedElementOffsetBounds::WindowByPosition, + PositionedElementAnchor::TopLeft, + ChildAnchor::BottomLeft, + ) + } + InputMode::PinnedToTop => { + OffsetPositioning::offset_from_save_position_element( + self.input.as_ref(app).save_position_id(), + vec2f(0., 8.), + PositionedElementOffsetBounds::WindowByPosition, + PositionedElementAnchor::BottomLeft, + ChildAnchor::TopLeft, + ) + } + }, + ) + } Some(ContextMenuType::AIBlockAttachedContext { ai_block_view_id }) => stack .add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), diff --git a/app/src/terminal/view/action.rs b/app/src/terminal/view/action.rs index a39a5475aa3..af20437a302 100644 --- a/app/src/terminal/view/action.rs +++ b/app/src/terminal/view/action.rs @@ -7,22 +7,22 @@ use ai::skills::SkillReference; use command_corrections::Correction; use pathfinder_geometry::vector::Vector2F; use warp_util::user_input::UserInput; -use warpui::EntityId; use warpui::elements::HyperlinkUrl; use warpui::event::ModifiersState; use warpui::units::Lines; +use warpui::EntityId; -use crate::ai::agent::AIAgentExchangeId; use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent::AIAgentExchangeId; use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; use crate::server::telemetry::{AgentModeRewindEntrypoint, PaletteSource, ToggleBlockFilterSource}; use crate::terminal::available_shells::AvailableShell; use crate::terminal::model::completions::ShellCompletion; use crate::terminal::shared_session::SharedSessionActionSource; use crate::terminal::ssh::error::SshErrorBlockAction; -use crate::terminal::view::RichContentSecretTooltipInfo; use crate::terminal::view::inline_banner::AgentModeSetupSpeedbumpBannerAction; use crate::terminal::view::passive_suggestions::PromptSuggestionResolution; +use crate::terminal::view::RichContentSecretTooltipInfo; use crate::workflows::workflow::Workflow; use crate::{ server::ids::SyncId, @@ -32,11 +32,11 @@ use crate::{ }, block_list_viewport::OverhangingBlock, model::{ - SecretHandle, index::Point, mouse::MouseState, selection::{SelectAction, SelectionDirection}, terminal_model::{BlockIndex, WithinModel}, + SecretHandle, }, }, }; diff --git a/app/src/terminal/view/ambient_agent/model_selector.rs b/app/src/terminal/view/ambient_agent/model_selector.rs index 73712a57f9b..57223877d79 100644 --- a/app/src/terminal/view/ambient_agent/model_selector.rs +++ b/app/src/terminal/view/ambient_agent/model_selector.rs @@ -26,7 +26,6 @@ use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ActionButton, ButtonSize}; use warp_editor::editor::NavigationKey; - const MENU_HORIZONTAL_PADDING: f32 = 16.; const ITEM_VERTICAL_PADDING: f32 = 4.; diff --git a/app/src/terminal/view/block_onboarding/onboarding_agentic_suggestions_block.rs b/app/src/terminal/view/block_onboarding/onboarding_agentic_suggestions_block.rs index d802ea01f22..18bc3629bc3 100644 --- a/app/src/terminal/view/block_onboarding/onboarding_agentic_suggestions_block.rs +++ b/app/src/terminal/view/block_onboarding/onboarding_agentic_suggestions_block.rs @@ -617,7 +617,9 @@ impl OnboardingAgenticSuggestionsBlock { font_color.into(), Default::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .finish(), ]) .finish() diff --git a/app/src/terminal/view/block_onboarding/onboarding_prompt_block.rs b/app/src/terminal/view/block_onboarding/onboarding_prompt_block.rs index 1bb2da23197..ca90141dfa3 100644 --- a/app/src/terminal/view/block_onboarding/onboarding_prompt_block.rs +++ b/app/src/terminal/view/block_onboarding/onboarding_prompt_block.rs @@ -66,8 +66,7 @@ impl OnboardingPromptBlock { const LINE_ONE: &str = "Next, let’s set up your prompt. Zap has a custom prompt builder or you can select PS1 to honor your pre-existing prompt configuration."; const LINE_TWO: &str = "Zap works with many custom prompts like oh-my-zsh, Starship, Powerlevel10K. "; - const LINK_DESTINATION: &str = - ""; + const LINK_DESTINATION: &str = ""; Flex::column() .with_children([ @@ -93,7 +92,9 @@ impl OnboardingPromptBlock { font_color.into_solid(), self.learn_more_highlight_index.clone(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(current_theme.accent().into_solid()) .register_default_click_handlers(|url, ctx, _| { ctx.dispatch_typed_action(OnboardingPromptBlockAction::HyperlinkClick(url)); diff --git a/app/src/terminal/view/open_in_warp.rs b/app/src/terminal/view/open_in_warp.rs index 658c34d1238..c3f21e13cf9 100644 --- a/app/src/terminal/view/open_in_warp.rs +++ b/app/src/terminal/view/open_in_warp.rs @@ -41,8 +41,7 @@ use super::{Event, InlineBannerItem, InlineBannerType, TerminalView}; #[path = "open_in_warp_tests.rs"] mod tests; -const LEARN_MORE_MARKDOWN_URL: &str = - ""; +const LEARN_MORE_MARKDOWN_URL: &str = ""; const LEARN_MORE_CODE_URL: &str = ""; /// A path to a file that can be opened in Zap, along with its type. diff --git a/app/src/terminal/view/plugin_instructions_block.rs b/app/src/terminal/view/plugin_instructions_block.rs index 6d8b206e044..278d31a3621 100644 --- a/app/src/terminal/view/plugin_instructions_block.rs +++ b/app/src/terminal/view/plugin_instructions_block.rs @@ -114,7 +114,9 @@ impl PluginInstructionsBlock { theme.nonactive_ui_text_color().into_solid(), Default::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(theme.accent().into()) .register_default_click_handlers_with_action_support(|hyperlink, _evt, app| { if let HyperlinkLens::Url(url) = hyperlink { diff --git a/app/src/terminal/view/shared_session/sharer/inactivity_modal.rs b/app/src/terminal/view/shared_session/sharer/inactivity_modal.rs index 1832bc5ae4a..224c19fa57f 100644 --- a/app/src/terminal/view/shared_session/sharer/inactivity_modal.rs +++ b/app/src/terminal/view/shared_session/sharer/inactivity_modal.rs @@ -152,13 +152,17 @@ impl InactivityModalBody { ); Container::new( - Text::new_inline(text, appearance.ui_font_family(), appearance.ui_font_subheading()) - .with_color(blended_colors::text_main( - appearance.theme(), - appearance.theme().background(), - )) - .with_style(Properties::default().weight(Weight::Normal)) - .finish(), + Text::new_inline( + text, + appearance.ui_font_family(), + appearance.ui_font_subheading(), + ) + .with_color(blended_colors::text_main( + appearance.theme(), + appearance.theme().background(), + )) + .with_style(Properties::default().weight(Weight::Normal)) + .finish(), ) .with_padding_bottom(MODAL_PADDING) .finish() diff --git a/app/src/terminal/view/shell_terminated_banner.rs b/app/src/terminal/view/shell_terminated_banner.rs index c7ca385d21a..b1369fba836 100644 --- a/app/src/terminal/view/shell_terminated_banner.rs +++ b/app/src/terminal/view/shell_terminated_banner.rs @@ -225,9 +225,7 @@ impl TerminationType { .with_text_label(crate::t!("terminal-more-info")) .build() .on_click(|ctx, _, _| { - ctx.dispatch_typed_action(Action::OpenUrl( - "".to_string(), - )); + ctx.dispatch_typed_action(Action::OpenUrl("".to_string())); }) .finish(), ] @@ -263,9 +261,7 @@ impl TerminationType { .with_text_label(crate::t!("terminal-more-info")) .build() .on_click(|ctx, _, _| { - ctx.dispatch_typed_action(Action::OpenUrl( - "".to_string(), - )); + ctx.dispatch_typed_action(Action::OpenUrl("".to_string())); }) .finish(), ] diff --git a/app/src/terminal/view/ssh_file_upload.rs b/app/src/terminal/view/ssh_file_upload.rs index 7477aba4729..fbaac4608b4 100644 --- a/app/src/terminal/view/ssh_file_upload.rs +++ b/app/src/terminal/view/ssh_file_upload.rs @@ -476,7 +476,9 @@ impl FileUpload { appearance.theme().active_ui_text_color().into_solid(), HighlightedHyperlink::default(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .finish(), ) .with_uniform_margin(4.) diff --git a/app/src/terminal/view/use_agent_footer/mod.rs b/app/src/terminal/view/use_agent_footer/mod.rs index 35fdf7a7898..7b0b69d3623 100644 --- a/app/src/terminal/view/use_agent_footer/mod.rs +++ b/app/src/terminal/view/use_agent_footer/mod.rs @@ -134,9 +134,7 @@ fn rich_input_submit_strategy(agent: CLIAgent) -> RichInputSubmitStrategy { | CLIAgent::Pi | CLIAgent::Goose | CLIAgent::Omp - | CLIAgent::Unknown => { - RichInputSubmitStrategy::Inline - } + | CLIAgent::Unknown => RichInputSubmitStrategy::Inline, } } diff --git a/app/src/terminal/view_test.rs b/app/src/terminal/view_test.rs index 675477bd154..aef08020d46 100644 --- a/app/src/terminal/view_test.rs +++ b/app/src/terminal/view_test.rs @@ -548,9 +548,7 @@ fn exiting_restored_cli_subagent_agent_view_inserts_entry_card() { }); } -fn assert_exiting_restored_ordinary_agent_view_inserts_entry_card( - origin: AgentViewEntryOrigin, -) { +fn assert_exiting_restored_ordinary_agent_view_inserts_entry_card(origin: AgentViewEntryOrigin) { App::test((), move |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); @@ -604,10 +602,8 @@ fn skips_cli_subagent_view_restore_without_matching_ai_metadata() { let block_id = BlockId::from("cli-block-1".to_string()); let task_id = TaskId::new("cli-task-1".to_string()); - let conversation = build_restored_conversation_with_cli_subagent_for_test( - block_id.clone(), - task_id, - ); + let conversation = + build_restored_conversation_with_cli_subagent_for_test(block_id.clone(), task_id); let mut serialized_blocks = serialized_blocks_for_restored_cli_subagent_for_test(&conversation); clear_ai_metadata_for_serialized_blocks_for_test(&mut serialized_blocks); @@ -668,7 +664,9 @@ fn finished_cli_subagent_keeps_read_only_card_when_metadata_matches() { initialize_app_for_terminal_view(&mut app); // FinishedSubagent 会触发 sidecar 持久化,需要 GlobalResourceHandlesProvider。 let global_resource_handles = crate::GlobalResourceHandles::mock(&mut app); - app.add_singleton_model(|_| crate::GlobalResourceHandlesProvider::new(global_resource_handles)); + app.add_singleton_model(|_| { + crate::GlobalResourceHandlesProvider::new(global_resource_handles) + }); // 先恢复一个带 CLI subagent 的历史会话,建立带匹配 metadata 的 command block // 和一个 RestoredReadOnly 视图,模拟 SSH 会话在 agent view 里展开后的状态。 diff --git a/app/src/test_util/terminal.rs b/app/src/test_util/terminal.rs index 28ea0f67ee1..5ad0dda6b4f 100644 --- a/app/src/test_util/terminal.rs +++ b/app/src/test_util/terminal.rs @@ -19,7 +19,6 @@ use watcher::HomeDirectoryWatcher; use super::settings::initialize_settings_for_tests; use crate::ai::agent_providers::AgentProviderSecrets; -use crate::settings::CloudSyncTokenStore; use crate::ai::blocklist::BlocklistAIPermissions; use crate::ai::blocklist::SerializedBlockListItem; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; @@ -29,6 +28,7 @@ use crate::auth::AuthManager; use crate::auth::AuthStateProvider; use crate::changelog_model::ChangelogModel; use crate::pricing::PricingInfoModel; +use crate::settings::CloudSyncTokenStore; use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::terminal::view::inline_banner::ByoLlmAuthBannerSessionState; use crate::undo_close::UndoCloseStack; diff --git a/app/src/themes/default_themes.rs b/app/src/themes/default_themes.rs index 5073f015d6f..2da4ade7d11 100644 --- a/app/src/themes/default_themes.rs +++ b/app/src/themes/default_themes.rs @@ -1,5 +1,6 @@ use asset_macro::bundled_or_fetched_asset; use pathfinder_color::ColorU; +use warp_core::ui::theme::ui_colors::UiColors; use warp_core::ui::{ color::{blend::Blend, coloru_with_opacity, OPAQUE}, theme::{ @@ -7,7 +8,6 @@ use warp_core::ui::{ TerminalColors, VerticalGradient, WarpTheme, }, }; -use warp_core::ui::theme::ui_colors::UiColors; const DARK_MODE_NORMAL_COLORS: AnsiColors = AnsiColors::new( AnsiColor::from_u32(0x616161FF), @@ -306,7 +306,10 @@ const VSCODE_2026_DARK_BRIGHT_COLORS: AnsiColors = AnsiColors::new( /// 返回 VS Code 2026 Dark 主题的 16 色 ANSI 终端颜色。 pub(super) fn vscode_2026_dark_colors() -> TerminalColors { - TerminalColors::new(VSCODE_2026_DARK_NORMAL_COLORS, VSCODE_2026_DARK_BRIGHT_COLORS) + TerminalColors::new( + VSCODE_2026_DARK_NORMAL_COLORS, + VSCODE_2026_DARK_BRIGHT_COLORS, + ) } /// VS Code 2026 Dark 内置主题;配色源: vscode/extensions/theme-defaults/themes/2026-dark.json。 @@ -322,24 +325,114 @@ pub(super) fn vscode_2026_dark() -> WarpTheme { None, Some("VS Code 2026 Dark".to_string()), Some(UiColors { - surface_1: Some(ColorU { r: 0x20, g: 0x21, b: 0x22, a: 255 }), - surface_2: Some(ColorU { r: 0x24, g: 0x25, b: 0x26, a: 255 }), - surface_3: Some(ColorU { r: 0x2A, g: 0x2B, b: 0x2C, a: 255 }), - border: Some(ColorU { r: 0x33, g: 0x35, b: 0x36, a: 255 }), - focus_border: Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0xB3 }), - split_pane_border: Some(ColorU { r: 0x2A, g: 0x2B, b: 0x2C, a: 255 }), - main_text: Some(ColorU { r: 0xED, g: 0xED, b: 0xED, a: 255 }), - sub_text: Some(ColorU { r: 0x8C, g: 0x8C, b: 0x8C, a: 255 }), - hint_text: Some(ColorU { r: 0x55, g: 0x55, b: 0x55, a: 255 }), - disabled_text: Some(ColorU { r: 0x55, g: 0x55, b: 0x55, a: 255 }), - selection: Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0x33 }), - text_selection: Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0x33 }), - hover: Some(ColorU { r: 0xFF, g: 0xFF, b: 0xFF, a: 0x0D }), - active: Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 255 }), - warning: Some(ColorU { r: 0xE5, g: 0xBA, b: 0x7D, a: 255 }), - error: Some(ColorU { r: 0xF4, g: 0x87, b: 0x71, a: 255 }), - success: Some(ColorU { r: 0x72, g: 0xC8, b: 0x92, a: 255 }), - link: Some(ColorU { r: 0x48, g: 0xA0, b: 0xC7, a: 255 }), + surface_1: Some(ColorU { + r: 0x20, + g: 0x21, + b: 0x22, + a: 255, + }), + surface_2: Some(ColorU { + r: 0x24, + g: 0x25, + b: 0x26, + a: 255, + }), + surface_3: Some(ColorU { + r: 0x2A, + g: 0x2B, + b: 0x2C, + a: 255, + }), + border: Some(ColorU { + r: 0x33, + g: 0x35, + b: 0x36, + a: 255, + }), + focus_border: Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0xB3, + }), + split_pane_border: Some(ColorU { + r: 0x2A, + g: 0x2B, + b: 0x2C, + a: 255, + }), + main_text: Some(ColorU { + r: 0xED, + g: 0xED, + b: 0xED, + a: 255, + }), + sub_text: Some(ColorU { + r: 0x8C, + g: 0x8C, + b: 0x8C, + a: 255, + }), + hint_text: Some(ColorU { + r: 0x55, + g: 0x55, + b: 0x55, + a: 255, + }), + disabled_text: Some(ColorU { + r: 0x55, + g: 0x55, + b: 0x55, + a: 255, + }), + selection: Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0x33, + }), + text_selection: Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0x33, + }), + hover: Some(ColorU { + r: 0xFF, + g: 0xFF, + b: 0xFF, + a: 0x0D, + }), + active: Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 255, + }), + warning: Some(ColorU { + r: 0xE5, + g: 0xBA, + b: 0x7D, + a: 255, + }), + error: Some(ColorU { + r: 0xF4, + g: 0x87, + b: 0x71, + a: 255, + }), + success: Some(ColorU { + r: 0x72, + g: 0xC8, + b: 0x92, + a: 255, + }), + link: Some(ColorU { + r: 0x48, + g: 0xA0, + b: 0xC7, + a: 255, + }), }), ) } diff --git a/app/src/themes/theme_chooser.rs b/app/src/themes/theme_chooser.rs index 06c4dc0b4f1..0e5fa5ce8dc 100644 --- a/app/src/themes/theme_chooser.rs +++ b/app/src/themes/theme_chooser.rs @@ -22,8 +22,8 @@ use warpui::{ }; use crate::resource_center::{mark_feature_used_and_write_to_user_defaults, Tip, TipAction}; -use crate::ui_components::tab_selector::{render_tab_selector, SettingsTab}; use crate::themes::theme::{RespectSystemTheme, ThemeKind, WarpTheme}; +use crate::ui_components::tab_selector::{render_tab_selector, SettingsTab}; use crate::util::traffic_lights::traffic_light_data; use crate::workspace::PANEL_HEADER_HEIGHT; use crate::{ diff --git a/app/src/themes/theme_test.rs b/app/src/themes/theme_test.rs index 7ce67e0b72c..3b7da72510f 100644 --- a/app/src/themes/theme_test.rs +++ b/app/src/themes/theme_test.rs @@ -60,32 +60,176 @@ fn vscode_2026_dark_has_ui_colors_override() { let ui = theme.ui_colors().expect("应有 ui_colors 覆盖"); // surface 层级 - assert_eq!(ui.surface_1, Some(ColorU { r: 0x20, g: 0x21, b: 0x22, a: 255 })); - assert_eq!(ui.surface_2, Some(ColorU { r: 0x24, g: 0x25, b: 0x26, a: 255 })); - assert_eq!(ui.surface_3, Some(ColorU { r: 0x2A, g: 0x2B, b: 0x2C, a: 255 })); + assert_eq!( + ui.surface_1, + Some(ColorU { + r: 0x20, + g: 0x21, + b: 0x22, + a: 255 + }) + ); + assert_eq!( + ui.surface_2, + Some(ColorU { + r: 0x24, + g: 0x25, + b: 0x26, + a: 255 + }) + ); + assert_eq!( + ui.surface_3, + Some(ColorU { + r: 0x2A, + g: 0x2B, + b: 0x2C, + a: 255 + }) + ); // 边框 - assert_eq!(ui.border, Some(ColorU { r: 0x33, g: 0x35, b: 0x36, a: 255 })); - assert_eq!(ui.focus_border, Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0xB3 })); - assert_eq!(ui.split_pane_border, Some(ColorU { r: 0x2A, g: 0x2B, b: 0x2C, a: 255 })); + assert_eq!( + ui.border, + Some(ColorU { + r: 0x33, + g: 0x35, + b: 0x36, + a: 255 + }) + ); + assert_eq!( + ui.focus_border, + Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0xB3 + }) + ); + assert_eq!( + ui.split_pane_border, + Some(ColorU { + r: 0x2A, + g: 0x2B, + b: 0x2C, + a: 255 + }) + ); // 文字颜色 - assert_eq!(ui.main_text, Some(ColorU { r: 0xED, g: 0xED, b: 0xED, a: 255 })); - assert_eq!(ui.sub_text, Some(ColorU { r: 0x8C, g: 0x8C, b: 0x8C, a: 255 })); - assert_eq!(ui.hint_text, Some(ColorU { r: 0x55, g: 0x55, b: 0x55, a: 255 })); - assert_eq!(ui.disabled_text, Some(ColorU { r: 0x55, g: 0x55, b: 0x55, a: 255 })); + assert_eq!( + ui.main_text, + Some(ColorU { + r: 0xED, + g: 0xED, + b: 0xED, + a: 255 + }) + ); + assert_eq!( + ui.sub_text, + Some(ColorU { + r: 0x8C, + g: 0x8C, + b: 0x8C, + a: 255 + }) + ); + assert_eq!( + ui.hint_text, + Some(ColorU { + r: 0x55, + g: 0x55, + b: 0x55, + a: 255 + }) + ); + assert_eq!( + ui.disabled_text, + Some(ColorU { + r: 0x55, + g: 0x55, + b: 0x55, + a: 255 + }) + ); // 交互状态 - assert_eq!(ui.selection, Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0x33 })); - assert_eq!(ui.text_selection, Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0x33 })); - assert_eq!(ui.hover, Some(ColorU { r: 0xFF, g: 0xFF, b: 0xFF, a: 0x0D })); - assert_eq!(ui.active, Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 255 })); + assert_eq!( + ui.selection, + Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0x33 + }) + ); + assert_eq!( + ui.text_selection, + Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0x33 + }) + ); + assert_eq!( + ui.hover, + Some(ColorU { + r: 0xFF, + g: 0xFF, + b: 0xFF, + a: 0x0D + }) + ); + assert_eq!( + ui.active, + Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 255 + }) + ); // 功能色 - assert_eq!(ui.warning, Some(ColorU { r: 0xE5, g: 0xBA, b: 0x7D, a: 255 })); - assert_eq!(ui.error, Some(ColorU { r: 0xF4, g: 0x87, b: 0x71, a: 255 })); - assert_eq!(ui.success, Some(ColorU { r: 0x72, g: 0xC8, b: 0x92, a: 255 })); - assert_eq!(ui.link, Some(ColorU { r: 0x48, g: 0xA0, b: 0xC7, a: 255 })); + assert_eq!( + ui.warning, + Some(ColorU { + r: 0xE5, + g: 0xBA, + b: 0x7D, + a: 255 + }) + ); + assert_eq!( + ui.error, + Some(ColorU { + r: 0xF4, + g: 0x87, + b: 0x71, + a: 255 + }) + ); + assert_eq!( + ui.success, + Some(ColorU { + r: 0x72, + g: 0xC8, + b: 0x92, + a: 255 + }) + ); + assert_eq!( + ui.link, + Some(ColorU { + r: 0x48, + g: 0xA0, + b: 0xC7, + a: 255 + }) + ); } /// 验证 UiColors 覆盖实际生效(surface_1 返回覆盖值而非派生值)。 @@ -95,15 +239,39 @@ fn vscode_2026_dark_ui_colors_override_works() { // surface_1 应返回 UiColors 中定义的 #1E1F20 而非派生值 let s1 = theme.surface_1().into_solid(); - assert_eq!(s1, ColorU { r: 0x20, g: 0x21, b: 0x22, a: 255 }); + assert_eq!( + s1, + ColorU { + r: 0x20, + g: 0x21, + b: 0x22, + a: 255 + } + ); // outline 应返回 UiColors 中定义的 border #333536 let ol = theme.outline().into_solid(); - assert_eq!(ol, ColorU { r: 0x33, g: 0x35, b: 0x36, a: 255 }); + assert_eq!( + ol, + ColorU { + r: 0x33, + g: 0x35, + b: 0x36, + a: 255 + } + ); // text_selection_color 应返回 UiColors 中定义的 selection let sel = theme.text_selection_color().into_solid(); - assert_eq!(sel, ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0x33 }); + assert_eq!( + sel, + ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0x33 + } + ); } /// 验证 ThemeKind::VsCode2026Dark 已注册到默认配置中。 @@ -118,7 +286,10 @@ fn vscode_2026_dark_registered_in_default_config() { /// 验证 ThemeKind::VsCode2026Dark 的 Display 输出。 #[test] fn vscode_2026_dark_display_name() { - assert_eq!(format!("{}", ThemeKind::VsCode2026Dark), "VS Code 2026 Dark"); + assert_eq!( + format!("{}", ThemeKind::VsCode2026Dark), + "VS Code 2026 Dark" + ); } #[test] diff --git a/app/src/ui_components/icon_with_status.rs b/app/src/ui_components/icon_with_status.rs index fb4747e639c..327ac21221b 100644 --- a/app/src/ui_components/icon_with_status.rs +++ b/app/src/ui_components/icon_with_status.rs @@ -46,11 +46,7 @@ pub(crate) fn render_cli_agent_logo( _ => None, }; if let Some(path) = multi_color_logo_path { - Image::new( - AssetSource::Bundled { path }, - CacheOption::BySize, - ) - .finish() + Image::new(AssetSource::Bundled { path }, CacheOption::BySize).finish() } else { agent .icon() @@ -159,12 +155,14 @@ pub(crate) fn render_icon_with_status( .with_width(sizing.icon_size) .with_height(sizing.icon_size) .finish(); - let background: ElementFill = - if matches!(agent, CLIAgent::DeepSeek | CLIAgent::Antigravity | CLIAgent::Omp) { - theme.background().into() - } else { - ThemeFill::Solid(brand_color).into() - }; + let background: ElementFill = if matches!( + agent, + CLIAgent::DeepSeek | CLIAgent::Antigravity | CLIAgent::Omp + ) { + theme.background().into() + } else { + ThemeFill::Solid(brand_color).into() + }; let circle = Container::new(inner) .with_uniform_padding(sizing.padding) .with_background(background) diff --git a/app/src/uri/mod.rs b/app/src/uri/mod.rs index b4394d2f68b..6b5cbc45fec 100644 --- a/app/src/uri/mod.rs +++ b/app/src/uri/mod.rs @@ -281,9 +281,7 @@ impl UriHost { // `SettingsSection::OzCloudAPIKeys`(云端 API key 管理页), // 随 UI 一同物理删。保留 arm 以记录原意图,物理处理为 no-op。 "platform" => { - log::warn!( - "warp://settings/platform 路由在 Zap 中已下线,忽略该请求" - ); + log::warn!("warp://settings/platform 路由在 Zap 中已下线,忽略该请求"); } "appearance" => { dispatch_action_in_new_or_existing_window( diff --git a/app/src/util/file/external_editor/mac.rs b/app/src/util/file/external_editor/mac.rs index 1957ac98932..5b96330ccaf 100644 --- a/app/src/util/file/external_editor/mac.rs +++ b/app/src/util/file/external_editor/mac.rs @@ -366,9 +366,7 @@ pub fn open_file_path_with_line_and_col( fn is_zap_bundle(bundle_id: &str) -> bool { AppId::parse(bundle_id) - .map(|id| { - id.qualifier() == "dev" && matches!(id.organization(), "warp" | "zap") - }) + .map(|id| id.qualifier() == "dev" && matches!(id.organization(), "warp" | "zap")) .unwrap_or(false) } diff --git a/app/src/util/openable_file_type.rs b/app/src/util/openable_file_type.rs index e9eb4037a25..c1e2af0ef41 100644 --- a/app/src/util/openable_file_type.rs +++ b/app/src/util/openable_file_type.rs @@ -259,10 +259,7 @@ mod tests { fn test_open_code_panels_file_editor_default_is_warp() { use crate::util::file::external_editor::settings::OpenCodePanelsFileEditor; - assert_eq!( - OpenCodePanelsFileEditor::default_value(), - EditorChoice::Zap - ); + assert_eq!(OpenCodePanelsFileEditor::default_value(), EditorChoice::Zap); } #[test] diff --git a/app/src/view_components/dropdown.rs b/app/src/view_components/dropdown.rs index ff7ccb4092f..70f84fe6c07 100644 --- a/app/src/view_components/dropdown.rs +++ b/app/src/view_components/dropdown.rs @@ -475,9 +475,9 @@ where ctx.dispatch_typed_action(DropdownAction::::ToggleExpanded); }); - let effective_height = self.top_bar_height.unwrap_or_else(|| { - appearance.dropdown_top_bar_height() - }); + let effective_height = self + .top_bar_height + .unwrap_or_else(|| appearance.dropdown_top_bar_height()); SavePosition::new( Container::new( diff --git a/app/src/view_components/filterable_dropdown.rs b/app/src/view_components/filterable_dropdown.rs index 2f71b9732b4..5b061dfc2ae 100644 --- a/app/src/view_components/filterable_dropdown.rs +++ b/app/src/view_components/filterable_dropdown.rs @@ -1,6 +1,5 @@ use super::dropdown::{ - DropdownAction, DropdownItem, MenuHeaderTextFormatter, DROPDOWN_PADDING, - TOP_MENU_BAR_MAX_WIDTH, + DropdownAction, DropdownItem, MenuHeaderTextFormatter, DROPDOWN_PADDING, TOP_MENU_BAR_MAX_WIDTH, }; use crate::{ appearance::Appearance, diff --git a/app/src/view_components/find.rs b/app/src/view_components/find.rs index e6545916a29..d1ed83b6de6 100644 --- a/app/src/view_components/find.rs +++ b/app/src/view_components/find.rs @@ -40,7 +40,6 @@ const FIND_EDITOR_PADDING: f32 = 6.; pub const FIND_EDITOR_BORDER_RADIUS: f32 = 6.; pub(crate) const FIND_EDITOR_BORDER_WIDTH: f32 = 1.; - pub const REGEX_TOGGLE_LABEL: &str = ". *"; pub const REGEX_TOGGLE_TOOLTIP: &str = "Regex toggle"; @@ -324,12 +323,16 @@ impl + 'static> Find { Some(idx) => idx + 1, }; let label = format!("{}/{}", index, self.model.as_ref(app).match_count()); - Text::new_inline(label, appearance.ui_font_family(), appearance.ui_font_body()) - .with_color(blended_colors::text_sub( - appearance.theme(), - appearance.theme().surface_1(), - )) - .finish() + Text::new_inline( + label, + appearance.ui_font_family(), + appearance.ui_font_body(), + ) + .with_color(blended_colors::text_sub( + appearance.theme(), + appearance.theme().surface_1(), + )) + .finish() } #[allow(clippy::too_many_arguments)] diff --git a/app/src/workflows/categories.rs b/app/src/workflows/categories.rs index aae64ac51f6..de99919d8bc 100644 --- a/app/src/workflows/categories.rs +++ b/app/src/workflows/categories.rs @@ -739,10 +739,7 @@ impl CategoriesView { .ui_builder() .link( "creating your own workflow".into(), - Some( - "" - .into(), - ), + Some("".into()), None, self.link_mouse_state_handles .documentation_link_handle diff --git a/app/src/workflows/workflow_view.rs b/app/src/workflows/workflow_view.rs index 6171235a98a..92a24fe3fc0 100644 --- a/app/src/workflows/workflow_view.rs +++ b/app/src/workflows/workflow_view.rs @@ -160,7 +160,6 @@ const COMMAND_MARGIN_TOP: f32 = 20.; const VERTICAL_TEXT_INPUT_PADDING: f32 = 5.; const HORIZONTAL_TEXT_INPUT_PADDING: f32 = 10.; - pub(super) const EDITOR_FONT_SIZE: f32 = 14.; const BUTTON_PADDING: f32 = 12.; const BUTTON_BORDER_RADIUS: f32 = 4.; diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index b24cb94c5e3..472d94575ec 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -21,8 +21,8 @@ use crate::settings_view::{SettingsAction as SettingsTabAction, SettingsSection} use crate::tab::{NewSessionMenuItem, SelectedTabColor}; use crate::tab_configs::TabConfig; use crate::terminal::available_shells::AvailableShell; -use crate::terminal::CLIAgent; use crate::terminal::view::inline_banner::ZeroStatePromptSuggestionType; +use crate::terminal::CLIAgent; use crate::themes::theme::AnsiColorIdentifier; use crate::themes::theme_chooser::ThemeChooserMode; use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType}; diff --git a/app/src/workspace/hoa_onboarding/hoa_onboarding_flow.rs b/app/src/workspace/hoa_onboarding/hoa_onboarding_flow.rs index 9a5a8b9fe7e..39be5672231 100644 --- a/app/src/workspace/hoa_onboarding/hoa_onboarding_flow.rs +++ b/app/src/workspace/hoa_onboarding/hoa_onboarding_flow.rs @@ -471,9 +471,7 @@ impl HoaOnboardingFlow { text: crate::t!("common-learn-more"), styles: FormattedTextStyles { underline: true, - hyperlink: Some(Hyperlink::Url( - "".into(), - )), + hyperlink: Some(Hyperlink::Url("".into())), ..Default::default() }, }; diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index 35fd4bb2732..2c4ad6fa446 100644 --- a/app/src/workspace/mod.rs +++ b/app/src/workspace/mod.rs @@ -15,6 +15,7 @@ mod lightbox_view; mod native_modal; mod one_time_modal_model; mod registry; +mod repository_workspace_tabs; pub mod rewind_confirmation_dialog; pub mod sync_inputs; pub mod tab_settings; @@ -98,6 +99,10 @@ pub use one_time_modal_model::OneTimeModalModel; pub use registry::WorkspaceRegistry; pub use toast_stack::ToastStack; +#[cfg(test)] +#[path = "repository_workspace_tabs_tests.rs"] +mod repository_workspace_tabs_tests; + pub fn init(app: &mut AppContext) { app.add_singleton_model(|_| WorkspaceRegistry::new()); app.add_singleton_model(|_| cross_window_tab_drag::CrossWindowTabDrag::new()); diff --git a/app/src/workspace/one_time_modal_model.rs b/app/src/workspace/one_time_modal_model.rs index f818b298435..8636ae47bca 100644 --- a/app/src/workspace/one_time_modal_model.rs +++ b/app/src/workspace/one_time_modal_model.rs @@ -98,11 +98,7 @@ impl OneTimeModalModel { } } - fn set_zap_launch_modal_open( - &mut self, - is_open: bool, - ctx: &mut ModelContext, - ) -> bool { + fn set_zap_launch_modal_open(&mut self, is_open: bool, ctx: &mut ModelContext) -> bool { if self.is_zap_launch_modal_open != is_open { self.is_zap_launch_modal_open = is_open; ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open }); diff --git a/app/src/workspace/repository_workspace_tabs.rs b/app/src/workspace/repository_workspace_tabs.rs new file mode 100644 index 00000000000..aba6a474c20 --- /dev/null +++ b/app/src/workspace/repository_workspace_tabs.rs @@ -0,0 +1,147 @@ +use std::collections::{HashMap, HashSet}; + +use crate::project_organization::domain::RepositoryWorkspaceId; + +#[derive(Debug)] +pub(crate) struct RepositoryWorkspaceTabState { + pub(crate) tabs: Vec, + pub(crate) active_tab_index: usize, +} + +impl RepositoryWorkspaceTabState { + pub(crate) fn new(tabs: Vec, active_tab_index: usize) -> Self { + Self { + active_tab_index: clamped_tab_index(active_tab_index, tabs.len()), + tabs, + } + } +} + +#[derive(Debug)] +pub(crate) struct RepositoryWorkspaceTabSets { + active_workspace_id: Option, + inactive: HashMap, RepositoryWorkspaceTabState>, +} + +impl RepositoryWorkspaceTabSets { + pub(crate) fn new(active_workspace_id: Option) -> Self { + Self { + active_workspace_id, + inactive: HashMap::new(), + } + } + + pub(crate) fn active_workspace_id(&self) -> Option { + self.active_workspace_id + } + + pub(crate) fn insert_inactive( + &mut self, + workspace_id: Option, + state: RepositoryWorkspaceTabState, + ) { + debug_assert_ne!(workspace_id, self.active_workspace_id); + self.inactive.insert(workspace_id, state); + } + + pub(crate) fn take_inactive( + &mut self, + workspace_id: Option, + ) -> Option> { + self.inactive.remove(&workspace_id) + } + + pub(crate) fn find_inactive_workspace( + &self, + mut contains: impl FnMut(&T) -> bool, + ) -> Option> { + self.inactive.iter().find_map(|(workspace_id, state)| { + state + .tabs + .iter() + .any(&mut contains) + .then_some(*workspace_id) + }) + } + + pub(crate) fn workspace_ids_matching( + &self, + active_tabs: &[T], + mut matches_tab: impl FnMut(&T) -> bool, + ) -> HashSet { + let mut workspace_ids = HashSet::new(); + + if let Some(workspace_id) = self.active_workspace_id { + if active_tabs.iter().any(&mut matches_tab) { + workspace_ids.insert(workspace_id); + } + } + + for (workspace_id, state) in &self.inactive { + let Some(workspace_id) = workspace_id else { + continue; + }; + if state.tabs.iter().any(&mut matches_tab) { + workspace_ids.insert(*workspace_id); + } + } + + workspace_ids + } + + pub(crate) fn switch_to( + &mut self, + workspace_id: Option, + active_tabs: &mut Vec, + active_tab_index: &mut usize, + ) { + if workspace_id == self.active_workspace_id { + *active_tab_index = clamped_tab_index(*active_tab_index, active_tabs.len()); + return; + } + + let current = RepositoryWorkspaceTabState::new( + std::mem::take(active_tabs), + std::mem::take(active_tab_index), + ); + self.inactive.insert(self.active_workspace_id, current); + + let next = self + .inactive + .remove(&workspace_id) + .unwrap_or_else(|| RepositoryWorkspaceTabState::new(Vec::new(), 0)); + *active_tabs = next.tabs; + *active_tab_index = next.active_tab_index; + self.active_workspace_id = workspace_id; + } + + pub(crate) fn inactive_states( + &self, + ) -> impl Iterator< + Item = ( + &Option, + &RepositoryWorkspaceTabState, + ), + > { + self.inactive.iter() + } + + pub(crate) fn tab_counts(&self, active_tabs: &[T]) -> HashMap { + let mut counts = HashMap::new(); + if let Some(workspace_id) = self.active_workspace_id.filter(|_| !active_tabs.is_empty()) { + counts.insert(workspace_id, active_tabs.len()); + } + counts.extend(self.inactive.iter().filter_map(|(workspace_id, state)| { + workspace_id + .filter(|_| !state.tabs.is_empty()) + .map(|workspace_id| (workspace_id, state.tabs.len())) + })); + counts + } +} + +fn clamped_tab_index(index: usize, tab_count: usize) -> usize { + tab_count + .checked_sub(1) + .map_or(0, |last_index| index.min(last_index)) +} diff --git a/app/src/workspace/repository_workspace_tabs_tests.rs b/app/src/workspace/repository_workspace_tabs_tests.rs new file mode 100644 index 00000000000..bb5c17cfef4 --- /dev/null +++ b/app/src/workspace/repository_workspace_tabs_tests.rs @@ -0,0 +1,133 @@ +use crate::project_organization::domain::RepositoryWorkspaceId; + +use super::repository_workspace_tabs::{RepositoryWorkspaceTabSets, RepositoryWorkspaceTabState}; + +#[test] +fn switching_workspaces_swaps_tabs_without_dropping_inactive_state() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut active_tabs = vec![10_u64]; + let mut active_tab_index = 0; + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive( + Some(workspace_b), + RepositoryWorkspaceTabState::new(vec![20_u64], 0), + ); + + sets.switch_to(Some(workspace_b), &mut active_tabs, &mut active_tab_index); + assert_eq!(active_tabs, vec![20]); + + sets.switch_to(Some(workspace_a), &mut active_tabs, &mut active_tab_index); + assert_eq!(active_tabs, vec![10]); +} + +#[test] +fn switching_workspaces_restores_each_workspace_active_tab_index() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut active_tabs = vec![10_u64, 11, 12]; + let mut active_tab_index = 2; + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive( + Some(workspace_b), + RepositoryWorkspaceTabState::new(vec![20_u64], 0), + ); + + sets.switch_to(Some(workspace_b), &mut active_tabs, &mut active_tab_index); + sets.switch_to(Some(workspace_a), &mut active_tabs, &mut active_tab_index); + + assert_eq!(active_tab_index, 2); +} + +#[test] +fn tab_counts_include_active_and_inactive_workspaces() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut active_tabs = vec![10_u64, 11]; + let sets = { + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive( + Some(workspace_b), + RepositoryWorkspaceTabState::new(vec![20_u64, 21, 22], 1), + ); + sets.insert_inactive(None, RepositoryWorkspaceTabState::new(vec![30_u64], 0)); + sets + }; + + assert_eq!(sets.tab_counts(&active_tabs).get(&workspace_a), Some(&2)); + assert_eq!(sets.tab_counts(&active_tabs).get(&workspace_b), Some(&3)); + + active_tabs.clear(); + assert_eq!(sets.tab_counts(&active_tabs).get(&workspace_a), None); +} + +#[test] +fn workspace_ids_matching_includes_active_and_inactive_workspaces() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let workspace_c = RepositoryWorkspaceId(uuid::Uuid::from_u128(3)); + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive( + Some(workspace_b), + RepositoryWorkspaceTabState::new(vec![20_u64, 21], 0), + ); + sets.insert_inactive( + Some(workspace_c), + RepositoryWorkspaceTabState::new(vec![30_u64], 0), + ); + + let active_tabs = vec![10_u64, 11]; + let matches = sets.workspace_ids_matching(&active_tabs, |tab| *tab == 11 || *tab == 20); + + assert!(matches.contains(&workspace_a)); + assert!(matches.contains(&workspace_b)); + assert!(!matches.contains(&workspace_c)); +} + +#[test] +fn workspace_ids_matching_ignores_unclassified_tabs() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive(None, RepositoryWorkspaceTabState::new(vec![20_u64], 0)); + + let active_tabs = vec![10_u64]; + let matches = sets.workspace_ids_matching(&active_tabs, |tab| *tab == 20); + + assert!(matches.is_empty()); +} + +#[test] +fn taking_an_inactive_workspace_removes_only_its_tab_state() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive( + Some(workspace_b), + RepositoryWorkspaceTabState::new(vec![20_u64], 0), + ); + + let removed = sets + .take_inactive(Some(workspace_b)) + .expect("workspace state should be removed"); + + assert_eq!(removed.tabs, vec![20]); + assert!(sets.inactive_states().next().is_none()); + assert_eq!(sets.active_workspace_id(), Some(workspace_a)); +} + +#[test] +fn finds_inactive_workspace_containing_target_tab() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive( + Some(workspace_b), + RepositoryWorkspaceTabState::new(vec![20_u64], 0), + ); + + assert_eq!( + sets.find_inactive_workspace(|tab| *tab == 20), + Some(Some(workspace_b)) + ); + assert_eq!(sets.find_inactive_workspace(|tab| *tab == 10), None); +} diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 9a0fb455aaf..f6b3b303db8 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -6,7 +6,6 @@ pub mod global_search; pub(crate) mod launch_modal; pub(crate) mod left_panel; pub(crate) mod onboarding; -pub(crate) mod zap_launch_modal; pub(crate) mod right_panel; pub(crate) mod server_file_browser; mod startup_directory; @@ -16,6 +15,7 @@ mod tests; mod vertical_tabs; #[cfg(target_family = "wasm")] mod wasm_view; +pub(crate) mod zap_launch_modal; use self::vertical_tabs::telemetry::{VerticalTabsDisplayOption, VerticalTabsTelemetryEvent}; use self::vertical_tabs::{ @@ -53,8 +53,8 @@ use crate::ai::{ use crate::ai_assistant::execution_context::WarpAiExecutionContext; use crate::app_state::{ LeafContents, LeafSnapshot, LeftPanelDisplayedTab, LeftPanelSnapshot, NotebookPaneSnapshot, - PaneNodeSnapshot, PaneUuid, RightPanelSnapshot, SettingsPaneSnapshot, TabSnapshot, - TerminalPaneSnapshot, WindowSnapshot, WorkflowPaneSnapshot, + PaneNodeSnapshot, PaneUuid, RepositoryWorkspaceWindowStateSnapshot, RightPanelSnapshot, + SettingsPaneSnapshot, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot, WorkflowPaneSnapshot, }; use crate::code_review::diff_state::DiffStateModel; #[cfg(feature = "local_fs")] @@ -70,7 +70,24 @@ use crate::notifications::{ NotificationMailboxViewEvent, }; use crate::pane_group::pane::ActionOrigin; -use crate::projects::ProjectManagementModel; +use crate::project_organization::domain::{ + ProjectOrganizationError, RepositoryWorkspace, RepositoryWorkspaceId, +}; +use crate::project_organization::git::{ + create_from_local_async, create_from_remote_async, deletion_preflight_async, + fetch_and_list_refs_async, list_branch_refs_async, list_worktrees_async, + remove_workspace_async, validate_existing_worktree_async, validate_repository_async, +}; +use crate::project_organization::model::ProjectOrganizationModel; +use crate::project_organization::view::create_workspace_modal::{ + CreateWorkspaceModal, CreateWorkspaceModalEvent, CreateWorkspaceRequest, CreateWorkspaceSource, +}; +use crate::project_organization::view::delete_workspace_dialog::{ + DeleteWorkspaceDialog, DeleteWorkspaceDialogEvent, +}; +use crate::project_organization::view::project_tree::{ + resolved_project_organization_tab_layout, ProjectTreeEvent, TabLayout, +}; use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; use crate::terminal::session_settings::SessionSettings; @@ -99,18 +116,19 @@ use crate::util::openable_file_type::{resolve_file_target_with_editor_choice, Ed use crate::ai::blocklist::history_model::LoadedConversationData; use crate::ai::blocklist::FORK_PREFIX; +use crate::terminal::cli_agent::{CLIAgentInstallEvent, CLIAgentInstallModel}; #[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; use crate::terminal::cli_agent_sessions::{CLIAgentSessionsModel, CLIAgentSessionsModelEvent}; -use crate::terminal::cli_agent::{CLIAgentInstallEvent, CLIAgentInstallModel}; use crate::terminal::CLIAgent; use crate::workspace::header_toolbar_editor::{HeaderToolbarEditorEvent, HeaderToolbarEditorModal}; use crate::workspace::header_toolbar_item::HeaderToolbarItemKind; +use crate::workspace::repository_workspace_tabs::{ + RepositoryWorkspaceTabSets, RepositoryWorkspaceTabState, +}; use crate::workspace::tab_settings::TabCloseButtonPosition; use crate::workspace::view::codex_modal::{CodexModal, CodexModalEvent}; -use crate::workspace::view::zap_launch_modal::{ - ZapLaunchModal, ZapLaunchModalEvent, -}; +use crate::workspace::view::zap_launch_modal::{ZapLaunchModal, ZapLaunchModalEvent}; use crate::workspace::{ForkFromExchange, ForkedConversationDestination}; use crate::BlocklistAIHistoryModel; @@ -254,7 +272,7 @@ use crate::server::telemetry::{ OpenedWarpAISource, WarpDriveSource, }; use crate::server_time::ServerTime; -use crate::session_management::{SessionNavigationData, SessionSource}; +use crate::session_management::{RunningSessionSummary, SessionNavigationData, SessionSource}; use crate::settings::{ active_theme_kind, respect_system_theme, AccessibilitySettings, AliasExpansionSettings, AppEditorSettings, BlockVisibilitySettings, CursorBlink, DebugSettings, FontSettings, @@ -352,9 +370,10 @@ use std::convert::TryFrom; use std::time::Duration; #[cfg(target_os = "macos")] use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; use warp_core::context_flag::ContextFlag; -use warp_core::HostId; use warp_core::semantic_selection::SemanticSelection; +use warp_core::HostId; use warp_util::path::{user_friendly_path, LineAndColumnArg}; use warpui::fonts::Weight; use warpui::modals::{AlertDialogWithCallbacks, AppModalCallback}; @@ -843,6 +862,7 @@ enum PendingSessionConfigTabConfigChipTutorial { /// animation continues seamlessly after a handoff. pub struct TransferredTab { pub pane_group: ViewHandle, + pub repository_workspace_id: Option, pub color: Option, pub custom_title: Option, pub left_panel_open: bool, @@ -856,6 +876,7 @@ pub struct Workspace { window_id: WindowId, pub(crate) tabs: Vec, active_tab_index: usize, + repository_workspace_tabs: RepositoryWorkspaceTabSets, pub(crate) hovered_tab_index: Option, tab_bar_hover_state: MouseStateHandle, tab_fixed_width: Option, @@ -914,6 +935,8 @@ pub struct Workspace { pending_session_config_tab_config_chip_tutorial: Option, new_worktree_modal: ModalViewState>, + create_workspace_modal: ModalViewState>, + delete_workspace_dialog: ModalViewState>, close_session_confirmation_dialog: ViewHandle, rewind_confirmation_dialog: ViewHandle, delete_conversation_confirmation_dialog: ViewHandle, @@ -1009,6 +1032,10 @@ pub struct Workspace { remove_tab_config_confirmation_dialog: ViewHandle, } +fn source_creates_worktree(source: &CreateWorkspaceSource) -> bool { + !matches!(source, CreateWorkspaceSource::ExistingWorktree { .. }) +} + impl Workspace { pub fn is_tab_drag_preview(&self) -> bool { self.is_tab_drag_preview @@ -1021,8 +1048,20 @@ impl Workspace { pub(crate) fn set_suppress_detach_panes_on_window_close(&mut self, value: bool) { self.suppress_detach_panes_on_window_close = value; } + + fn vertical_tabs_active(ctx: &AppContext) -> bool { + matches!( + resolved_project_organization_tab_layout( + FeatureFlag::RepositoryWorkspaces.is_enabled(), + FeatureFlag::VerticalTabs.is_enabled() + && *TabSettings::as_ref(ctx).use_vertical_tabs, + ), + TabLayout::Vertical + ) + } + fn tab_rename_editor_font_size(ctx: &AppContext, appearance: &Appearance) -> f32 { - if FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs { + if Self::vertical_tabs_active(ctx) { match *TabSettings::as_ref(ctx) .vertical_tabs_display_granularity .value() @@ -1366,11 +1405,7 @@ impl Workspace { id_to_force_expand = Some(workflow.id); } if let Some(id) = id_to_force_expand { - self.open_workflow_with_existing( - id, - &ZapDriveObjectSettings::default(), - ctx, - ); + self.open_workflow_with_existing(id, &ZapDriveObjectSettings::default(), ctx); ObjectStoreModel::handle(ctx).update(ctx, |object_store_model, ctx| { object_store_model.force_expand_object_and_ancestors(id, ctx); }); @@ -1830,6 +1865,48 @@ impl Workspace { ModalViewState::new(modal) } + fn build_create_workspace_modal( + ctx: &mut ViewContext, + ) -> ModalViewState> { + let body = ctx.add_typed_action_view(CreateWorkspaceModal::new); + ctx.subscribe_to_view(&body, |workspace, _, event, ctx| { + workspace.handle_create_workspace_modal_body_event(event, ctx); + }); + let modal = ctx.add_typed_action_view(|ctx| { + Modal::new(None, body, ctx).with_modal_style(UiComponentStyles { + width: Some(520.), + ..Default::default() + }) + }); + ctx.subscribe_to_view(&modal, |workspace, _, event, ctx| { + if matches!(event, ModalEvent::Close) { + workspace.close_create_workspace_modal(ctx); + } + }); + ModalViewState::new(modal) + } + + fn build_delete_workspace_dialog( + ctx: &mut ViewContext, + ) -> ModalViewState> { + let body = ctx.add_typed_action_view(DeleteWorkspaceDialog::new); + ctx.subscribe_to_view(&body, |workspace, _, event, ctx| { + workspace.handle_delete_workspace_dialog_event(event, ctx); + }); + let modal = ctx.add_typed_action_view(|ctx| { + Modal::new(None, body, ctx).with_modal_style(UiComponentStyles { + width: Some(480.), + ..Default::default() + }) + }); + ctx.subscribe_to_view(&modal, |workspace, _, event, ctx| { + if matches!(event, ModalEvent::Close) { + workspace.close_delete_workspace_dialog(ctx); + } + }); + ModalViewState::new(modal) + } + fn build_remove_tab_config_confirmation_dialog( ctx: &mut ViewContext, ) -> ViewHandle { @@ -2045,7 +2122,7 @@ impl Workspace { /// Opens the vertical tabs panel if the setting was enabled. /// Called from the onboarding flow before the session config modal is shown. pub(crate) fn open_vertical_tabs_panel_if_enabled(&mut self, ctx: &mut ViewContext) { - if FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs { + if Self::vertical_tabs_active(ctx) { self.vertical_tabs_panel_open = true; self.sync_window_button_visibility(ctx); ctx.notify(); @@ -2537,6 +2614,8 @@ impl Workspace { let tab_config_params_modal = Self::build_tab_config_params_modal(ctx); let new_worktree_modal = Self::build_new_worktree_modal(ctx); + let create_workspace_modal = Self::build_create_workspace_modal(ctx); + let delete_workspace_dialog = Self::build_delete_workspace_dialog(ctx); let session_config_modal = Self::build_session_config_modal(ctx); @@ -2857,9 +2936,19 @@ impl Workspace { }, ); + let active_repository_workspace_id = match &workspace_setting { + NewWorkspaceSource::Restored { + window_snapshot, .. + } => window_snapshot.active_repository_workspace_id, + _ => None, + }; + let mut ws = Self { tabs: Vec::new(), active_tab_index: 0, + repository_workspace_tabs: RepositoryWorkspaceTabSets::new( + active_repository_workspace_id, + ), hovered_tab_index: None, tab_bar_hover_state: Default::default(), traffic_light_mouse_states: Default::default(), @@ -2899,6 +2988,8 @@ impl Workspace { show_session_config_tab_config_chip: false, pending_session_config_tab_config_chip_tutorial: None, new_worktree_modal, + create_workspace_modal, + delete_workspace_dialog, close_session_confirmation_dialog, rewind_confirmation_dialog, delete_conversation_confirmation_dialog, @@ -2978,6 +3069,7 @@ impl Workspace { }; ws.configure_new_workspace(workspace_setting, ctx); + ws.sync_project_tree(ctx); ws.sync_panel_positions_from_config(ctx); ws.sync_window_button_visibility(ctx); ws.update_titlebar_height(ctx); @@ -3157,7 +3249,7 @@ impl Workspace { ctx.notify(); } TabSettingsChangedEvent::UseVerticalTabs { .. } => { - let vertical_tabs_enabled = *TabSettings::as_ref(ctx).use_vertical_tabs; + let vertical_tabs_enabled = Self::vertical_tabs_active(ctx); // During HOA onboarding, keep the vertical tabs panel open // regardless of the setting so the callout stays anchored. if self.hoa_onboarding_flow.is_none() { @@ -3183,8 +3275,7 @@ impl Workspace { ctx.notify(); } TabSettingsChangedEvent::ShowVerticalTabPanelInRestoredWindows { .. } => { - if FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(ctx).use_vertical_tabs + if Self::vertical_tabs_active(ctx) && *TabSettings::as_ref(ctx).show_vertical_tab_panel_in_restored_windows { self.vertical_tabs_panel_open = true; @@ -3282,6 +3373,49 @@ impl Workspace { } } + fn restore_repository_workspace_tab_sets( + &mut self, + active_tab_index: usize, + workspace_states: &[RepositoryWorkspaceWindowStateSnapshot], + ) { + if !FeatureFlag::RepositoryWorkspaces.is_enabled() { + return; + } + + let active_workspace_id = self.active_repository_workspace_id(); + let active_indices = workspace_states + .iter() + .map(|state| (state.repository_workspace_id, state.active_tab_index)) + .collect::>(); + let mut tabs_by_workspace = HashMap::, Vec>::new(); + for tab in std::mem::take(&mut self.tabs) { + tabs_by_workspace + .entry(tab.repository_workspace_id) + .or_default() + .push(tab); + } + + let active_state = RepositoryWorkspaceTabState::new( + tabs_by_workspace + .remove(&active_workspace_id) + .unwrap_or_default(), + active_tab_index, + ); + self.tabs = active_state.tabs; + self.active_tab_index = active_state.active_tab_index; + self.repository_workspace_tabs = RepositoryWorkspaceTabSets::new(active_workspace_id); + + for (workspace_id, tabs) in tabs_by_workspace { + let saved_active_tab_index = workspace_id + .and_then(|id| active_indices.get(&id).copied()) + .unwrap_or(0); + self.repository_workspace_tabs.insert_inactive( + workspace_id, + RepositoryWorkspaceTabState::new(tabs, saved_active_tab_index), + ); + } + } + fn configure_new_workspace( &mut self, workspace_setting: NewWorkspaceSource, @@ -3303,6 +3437,8 @@ impl Workspace { } => { let active_tab_index = window_snapshot.active_tab_index; let restored_left_panel_open = window_snapshot.left_panel_open; + let repository_workspace_states = + window_snapshot.repository_workspace_states.clone(); // Re-apply this window's per-window theme override, if it had one, // and record it so subsequent snapshots keep it. @@ -3329,6 +3465,8 @@ impl Workspace { self.tabs[tab_index].default_directory_color = saved_tab.default_directory_color; self.tabs[tab_index].selected_color = saved_tab.selected_color; + self.tabs[tab_index].repository_workspace_id = + saved_tab.repository_workspace_id; let pane_group = self.tabs[tab_index].pane_group.clone(); @@ -3345,7 +3483,16 @@ impl Workspace { } }); - if self.tab_count() == 0 { + let has_restored_tabs = !self.tabs.is_empty(); + self.restore_repository_workspace_tab_sets( + active_tab_index, + &repository_workspace_states, + ); + + let should_create_default_tab = !has_restored_tabs + && (!FeatureFlag::RepositoryWorkspaces.is_enabled() + || self.active_repository_workspace_id().is_none()); + if should_create_default_tab { if self.should_trigger_get_started_onboarding(ctx) { self.trigger_get_started_onboarding(ctx); return; @@ -3364,9 +3511,11 @@ impl Workspace { self.left_panel_open = restored_left_panel_open; } - self.activate_tab_internal(active_tab_index, ctx); - self.check_and_trigger_onboarding(ctx); - self.maybe_auto_open_conversation_list(ctx); + if !self.tabs.is_empty() { + self.activate_tab_internal(self.active_tab_index, ctx); + self.check_and_trigger_onboarding(ctx); + self.maybe_auto_open_conversation_list(ctx); + } } NewWorkspaceSource::FromTemplate { window_template } => { self.open_launch_config_window(window_template, ctx); @@ -3479,14 +3628,20 @@ impl Workspace { self.left_panel_view.update(ctx, |left_panel, ctx| { left_panel.set_active_pane_group(active_pane_group, &working_directories_model, ctx); }); + + if FeatureFlag::RepositoryWorkspaces.is_enabled() { + self.open_left_panel(ctx); + self.left_panel_view.update(ctx, |left_panel, ctx| { + left_panel.restore_active_view_from_snapshot(ToolPanelView::ProjectTree, ctx); + }); + } } fn initial_vertical_tabs_panel_open( workspace_setting: &NewWorkspaceSource, ctx: &AppContext, ) -> bool { - let should_default_open = - FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs; + let should_default_open = Self::vertical_tabs_active(ctx); match workspace_setting { NewWorkspaceSource::Restored { @@ -3544,6 +3699,7 @@ impl Workspace { self.left_panel_view.update(ctx, |lp, ctx| { // Restore which panel tab was active let active_view = match left_panel_snapshot.left_panel_displayed_tab { + LeftPanelDisplayedTab::ProjectTree => ToolPanelView::ProjectTree, LeftPanelDisplayedTab::FileTree => ToolPanelView::ProjectExplorer, LeftPanelDisplayedTab::GlobalSearch => ToolPanelView::GlobalSearch { entry_focus: GlobalSearchEntryFocus::Results, @@ -4301,6 +4457,106 @@ impl Workspace { self.tabs.iter().map(|s| &s.pane_group) } + pub(crate) fn active_repository_workspace_id(&self) -> Option { + self.repository_workspace_tabs.active_workspace_id() + } + + pub(crate) fn switch_repository_workspace( + &mut self, + workspace_id: Option, + ctx: &mut ViewContext, + ) { + self.repository_workspace_tabs.switch_to( + workspace_id, + &mut self.tabs, + &mut self.active_tab_index, + ); + self.hovered_tab_index = None; + self.sync_project_tree(ctx); + ctx.notify(); + } + + fn activate_repository_workspace( + &mut self, + workspace_id: RepositoryWorkspaceId, + initial_directory: PathBuf, + ctx: &mut ViewContext, + ) { + self.switch_repository_workspace(Some(workspace_id), ctx); + self.add_tab_with_pane_layout( + PanesLayout::SingleTerminal(Box::new( + NewTerminalOptions::default().with_initial_directory(initial_directory), + )), + Arc::new(HashMap::new()), + None, + ctx, + ); + } + + fn sync_project_tree(&mut self, ctx: &mut ViewContext) { + if !FeatureFlag::RepositoryWorkspaces.is_enabled() { + return; + } + + let tab_counts = self.repository_workspace_tabs.tab_counts(&self.tabs); + let active_workspace_id = self.active_repository_workspace_id(); + let running_workspace_ids = self.repository_workspace_ids_with_long_running_terminal(ctx); + self.left_panel_view.update(ctx, |left_panel, ctx| { + left_panel.set_project_tree_tab_counts(tab_counts, ctx); + left_panel.set_project_tree_active_workspace(active_workspace_id, ctx); + left_panel.set_project_tree_running_workspaces(running_workspace_ids, ctx); + }); + } + + fn all_repository_workspace_tabs(&self) -> impl Iterator { + self.tabs.iter().chain( + self.repository_workspace_tabs + .inactive_states() + .flat_map(|(_, state)| state.tabs.iter()), + ) + } + + fn tab_has_long_running_terminal(&self, tab: &TabData, ctx: &AppContext) -> bool { + let pane_group = tab.pane_group.as_ref(ctx); + let sessions = pane_group + .pane_sessions(tab.pane_group.id(), tab.pane_group.window_id(ctx), ctx) + .collect_vec(); + !RunningSessionSummary::new(&sessions) + .long_running_cmds + .is_empty() + } + + fn repository_workspace_ids_with_long_running_terminal( + &self, + ctx: &AppContext, + ) -> HashSet { + self.repository_workspace_tabs + .workspace_ids_matching(&self.tabs, |tab| self.tab_has_long_running_terminal(tab, ctx)) + } + + /// 在持久化线程终止前保存所有 tab 中的活动 terminal block。 + pub(crate) fn persist_active_terminal_blocks_for_shutdown(&self, ctx: &mut ViewContext) { + let pane_groups = self + .all_repository_workspace_tabs() + .map(|tab| tab.pane_group.clone()) + .collect::>(); + + for pane_group in pane_groups { + pane_group.update(ctx, |pane_group, ctx| { + pane_group.persist_active_blocks_for_shutdown(ctx); + }); + } + } + + fn tab_data_for_active_repository_workspace( + &self, + pane_group: ViewHandle, + ) -> TabData { + let mut tab_data = TabData::new(pane_group); + tab_data.repository_workspace_id = self.active_repository_workspace_id(); + tab_data + } + /// Get the tab color for a given tab index. pub fn get_tab_color(&self, index: usize) -> Option { self.tabs.get(index).and_then(|tab| tab.color()) @@ -4432,10 +4688,7 @@ impl Workspace { self.active_tab_index = index; - if self.vertical_tabs_panel_open - && FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(ctx).use_vertical_tabs - { + if self.vertical_tabs_panel_open && Self::vertical_tabs_active(ctx) { self.vertical_tabs_panel.scroll_to_tab(index); } @@ -4790,6 +5043,21 @@ impl Workspace { /// Focuses the given pane within the pane group. pub fn focus_pane(&mut self, pane_view_locator: PaneViewLocator, ctx: &mut ViewContext) { + if !self + .tabs + .iter() + .any(|tab_data| tab_data.pane_group.id() == pane_view_locator.pane_group_id) + { + if let Some(workspace_id) = + self.repository_workspace_tabs + .find_inactive_workspace(|tab_data| { + tab_data.pane_group.id() == pane_view_locator.pane_group_id + }) + { + self.switch_repository_workspace(workspace_id, ctx); + } + } + if let Some((index, tab)) = self .tabs .iter() @@ -5217,131 +5485,759 @@ impl Workspace { } } - crate::util::file::open_file_path_in_external_editor(line_col, path.clone(), ctx); - } - FileTarget::CodeEditor(layout) => { - let open_as_preview = false; - self.open_code(code_source, layout, line_col, open_as_preview, &[], ctx); - } - FileTarget::ImageViewer(_layout) => { - // 图片始终在新标签页打开,见 `insert_image_pane`。target 携带的 - // layout 在此被故意忽略。 - self.open_image(path.clone(), self.get_active_session(ctx), ctx); - } - FileTarget::ExternalEditor(editor) => { - crate::util::file::open_file_path_with_editor( - line_col, - path.clone(), - Some(editor), - ctx, - ); - } - FileTarget::SystemDefault => { - crate::util::file::open_file_path_with_editor(line_col, path.clone(), None, ctx); - } - FileTarget::SystemGeneric => { - ctx.open_file_path(&path); - } + crate::util::file::open_file_path_in_external_editor(line_col, path.clone(), ctx); + } + FileTarget::CodeEditor(layout) => { + let open_as_preview = false; + self.open_code(code_source, layout, line_col, open_as_preview, &[], ctx); + } + FileTarget::ImageViewer(_layout) => { + // 图片始终在新标签页打开,见 `insert_image_pane`。target 携带的 + // layout 在此被故意忽略。 + self.open_image(path.clone(), self.get_active_session(ctx), ctx); + } + FileTarget::ExternalEditor(editor) => { + crate::util::file::open_file_path_with_editor( + line_col, + path.clone(), + Some(editor), + ctx, + ); + } + FileTarget::SystemDefault => { + crate::util::file::open_file_path_with_editor(line_col, path.clone(), None, ctx); + } + FileTarget::SystemGeneric => { + ctx.open_file_path(&path); + } + } + } + + fn handle_left_panel_event(&mut self, event: &LeftPanelEvent, ctx: &mut ViewContext) { + match event { + LeftPanelEvent::ProjectTree(event) => self.handle_project_tree_event(event, ctx), + LeftPanelEvent::FileTree(pane_group_event) => { + let pane_group = self.active_tab_pane_group().clone(); + self.handle_file_tree_event(pane_group, pane_group_event, ctx); + } + LeftPanelEvent::ZapDrive(drive_event) => { + self.handle_warp_drive_event(drive_event, ctx); + } + LeftPanelEvent::ServerFileBrowser(event) => match event { + crate::workspace::view::server_file_browser::ServerFileBrowserEvent::OpenRemoteFile { + remote_path, + } => { + #[cfg(feature = "local_tty")] + self.open_remote_file(remote_path.clone(), ctx); + #[cfg(not(feature = "local_tty"))] + let _ = remote_path; + } + crate::workspace::view::server_file_browser::ServerFileBrowserEvent::CdToDirectory { + path, + } => { + self.cd_to_remote_directory(path, ctx); + } + }, + LeftPanelEvent::OpenFileWithTarget { + path, + target, + line_col, + } => { + self.open_file_with_target( + path.clone(), + target.clone(), + *line_col, + CodeSource::FileTree { path: path.clone() }, + ctx, + ); + } + #[cfg(feature = "local_tty")] + LeftPanelEvent::OpenRemoteFile { remote_path } => { + self.open_remote_file(remote_path.clone(), ctx); + } + #[cfg(not(feature = "local_tty"))] + LeftPanelEvent::OpenRemoteFile { .. } => {} + #[cfg(feature = "local_tty")] + LeftPanelEvent::OpenRemoteImage { remote_path } => { + self.open_remote_image(remote_path.clone(), ctx); + } + #[cfg(not(feature = "local_tty"))] + LeftPanelEvent::OpenRemoteImage { .. } => {} + LeftPanelEvent::OpenSkillFile { source } => { + #[cfg(feature = "local_fs")] + { + let layout = *EditorSettings::as_ref(ctx).open_file_layout.value(); + if let Some(path) = source.path() { + self.open_file_with_target( + path, + FileTarget::CodeEditor(layout), + None, + source.clone(), + ctx, + ); + } else { + log::error!("failed to open skill file: missing source path"); + } + } + #[cfg(not(feature = "local_fs"))] + { + let _ = source; + } + } + LeftPanelEvent::NewConversationInNewTab => { + self.add_terminal_tab_with_new_agent_view(ctx); + } + LeftPanelEvent::ShowDeleteConfirmationDialog { + conversation_id, + conversation_title, + terminal_view_id, + } => { + self.show_delete_conversation_confirmation_dialog( + DeleteConversationDialogSource { + conversation_id: *conversation_id, + conversation_title: conversation_title.clone(), + terminal_view_id: *terminal_view_id, + }, + ctx, + ); + } + LeftPanelEvent::OpenSshServerEditor { node_id } => { + self.open_ssh_server(node_id.clone(), ctx); + } + LeftPanelEvent::OpenSshTerminal { node_id, server } => { + self.open_ssh_terminal(node_id.clone(), server.clone(), ctx); + } + LeftPanelEvent::OpenSftpPane { node_id, server: _ } => { + self.open_sftp_pane(node_id.clone(), ctx); + } + } + } + + fn handle_project_tree_event(&mut self, event: &ProjectTreeEvent, ctx: &mut ViewContext) { + match event { + ProjectTreeEvent::AddRepositoryRequested => { + self.open_add_repository_picker(ctx); + } + ProjectTreeEvent::CreateWorkspaceRequested { repository_id } => { + self.open_create_workspace_modal(*repository_id, ctx); + } + ProjectTreeEvent::DeleteWorkspaceRequested { workspace_id } => { + self.open_delete_workspace_dialog(*workspace_id, ctx); + } + ProjectTreeEvent::WorkspaceSelected { workspace_id } => { + self.switch_repository_workspace(*workspace_id, ctx); + } + } + } + + fn open_add_repository_picker(&mut self, ctx: &mut ViewContext) { + ctx.open_file_picker( + |result, ctx| match result { + Ok(paths) => { + let Some(path) = paths.into_iter().next() else { + return; + }; + let Some(handle) = ctx.handle().upgrade(ctx) else { + return; + }; + handle.update(ctx, |workspace, ctx| { + workspace.validate_and_add_repository(PathBuf::from(path), ctx); + }); + } + Err(error) => { + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!( + "Failed to select repository directory: {error}" + )), + window_id, + ctx, + ); + }); + } + }, + FilePickerConfiguration::new().folders_only(), + ); + } + + fn validate_and_add_repository(&mut self, path: PathBuf, ctx: &mut ViewContext) { + ctx.spawn( + validate_repository_async(path), + |workspace, result, ctx| match result { + Ok(repository) => { + let repository_root = repository.root.clone(); + let result = ProjectOrganizationModel::handle(ctx).update(ctx, |model, ctx| { + model.add_local_repository_with_initial_workspace( + &repository.root, + Some(repository.remote_url), + repository.primary_branch, + ctx, + ) + }); + match result { + Ok((_, workspace_id)) => { + workspace.activate_repository_workspace( + workspace_id, + repository_root, + ctx, + ); + } + Err(error) => { + workspace.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!( + "Failed to add repository: {error}" + )), + ctx, + ); + }); + } + } + } + Err(error) => { + workspace.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!( + "Failed to validate repository: {error}" + )), + ctx, + ); + }); + } + }, + ); + } + + fn open_create_workspace_modal( + &mut self, + repository_id: crate::project_organization::domain::RepositoryId, + ctx: &mut ViewContext, + ) { + let Some(repository) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .repository(repository_id) + .cloned() + else { + self.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error("Repository no longer exists.".to_string()), + ctx, + ); + }); + return; + }; + + let workspace_id = RepositoryWorkspaceId::from(Uuid::new_v4()); + let home = dirs::home_dir().expect("home directory should be available"); + self.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + body.configure( + repository_id, + workspace_id, + repository.path.clone(), + home, + repository.display_name.clone(), + ctx, + ); + }); + }); + self.create_workspace_modal.open(); + self.fetch_create_workspace_branch_refs( + repository_id, + workspace_id, + repository.path.clone(), + ctx, + ); + self.fetch_existing_worktrees(repository_id, workspace_id, repository.path, ctx); + ctx.notify(); + } + + fn fetch_create_workspace_branch_refs( + &mut self, + repository_id: crate::project_organization::domain::RepositoryId, + workspace_id: RepositoryWorkspaceId, + repository_path: PathBuf, + ctx: &mut ViewContext, + ) { + let mut should_fetch = false; + self.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if body.matches_target(repository_id, workspace_id) { + should_fetch = true; + body.begin_branch_fetch(ctx); + } + }); + }); + if !should_fetch { + return; + } + + ctx.spawn( + fetch_and_list_refs_async(repository_path.clone()), + move |workspace, result, ctx| match result { + Ok(refs) => { + workspace + .create_workspace_modal + .view + .update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if body.matches_target(repository_id, workspace_id) { + body.set_branch_refs(refs, ctx); + } + }); + }); + } + Err(fetch_error) => { + let error_message = format!("Failed to fetch repository refs: {fetch_error}"); + ctx.spawn( + list_branch_refs_async(repository_path), + move |workspace, local_result, ctx| { + workspace + .create_workspace_modal + .view + .update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if !body.matches_target(repository_id, workspace_id) { + return; + } + if let Ok(refs) = local_result { + body.set_local_branch_refs(refs, ctx); + } + body.set_branch_fetch_error(error_message, ctx); + }); + }); + }, + ); + } + }, + ); + } + + fn fetch_existing_worktrees( + &mut self, + repository_id: crate::project_organization::domain::RepositoryId, + workspace_id: RepositoryWorkspaceId, + repository_path: PathBuf, + ctx: &mut ViewContext, + ) { + let mut should_fetch = false; + self.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if body.matches_target(repository_id, workspace_id) { + should_fetch = true; + body.begin_existing_worktree_fetch(ctx); + } + }); + }); + if !should_fetch { + return; + } + + ctx.spawn( + list_worktrees_async(repository_path), + move |workspace, result, ctx| { + workspace + .create_workspace_modal + .view + .update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if !body.matches_target(repository_id, workspace_id) { + return; + } + match result { + Ok(worktrees) => body.set_existing_worktrees(worktrees, ctx), + Err(error) => body.set_existing_worktree_fetch_error( + format!("Failed to list existing worktrees: {error}"), + ctx, + ), + } + }); + }); + }, + ); + } + + fn handle_create_workspace_modal_body_event( + &mut self, + event: &CreateWorkspaceModalEvent, + ctx: &mut ViewContext, + ) { + match event { + CreateWorkspaceModalEvent::Close => self.close_create_workspace_modal(ctx), + CreateWorkspaceModalEvent::RetryBranchRefs { + repository_id, + workspace_id, + } => { + let Some(repository) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .repository(*repository_id) + .cloned() + else { + return; + }; + self.fetch_create_workspace_branch_refs( + *repository_id, + *workspace_id, + repository.path, + ctx, + ); + } + CreateWorkspaceModalEvent::RetryExistingWorktrees { + repository_id, + workspace_id, + } => { + let Some(repository) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .repository(*repository_id) + .cloned() + else { + return; + }; + self.fetch_existing_worktrees(*repository_id, *workspace_id, repository.path, ctx); + } + CreateWorkspaceModalEvent::Submit(request) => { + self.close_create_workspace_modal(ctx); + self.create_repository_workspace(request.clone(), ctx); + } + } + } + + fn close_create_workspace_modal(&mut self, ctx: &mut ViewContext) { + self.create_workspace_modal.close(); + self.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + body.on_close(ctx); + }); + }); + ctx.notify(); + } + + fn open_delete_workspace_dialog( + &mut self, + workspace_id: RepositoryWorkspaceId, + ctx: &mut ViewContext, + ) { + let Some(workspace) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .workspace(workspace_id) + .cloned() + else { + self.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error("Workspace no longer exists.".to_string()), + ctx, + ); + }); + return; + }; + self.delete_workspace_dialog.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + body.configure(workspace_id, workspace.display_name, workspace.branch, ctx); + }); + }); + self.delete_workspace_dialog.open(); + ctx.notify(); + } + + fn close_delete_workspace_dialog(&mut self, ctx: &mut ViewContext) { + self.delete_workspace_dialog.close(); + self.delete_workspace_dialog.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| body.reset(ctx)); + }); + ctx.notify(); + } + + fn handle_delete_workspace_dialog_event( + &mut self, + event: &DeleteWorkspaceDialogEvent, + ctx: &mut ViewContext, + ) { + match event { + DeleteWorkspaceDialogEvent::Close => self.close_delete_workspace_dialog(ctx), + DeleteWorkspaceDialogEvent::Confirm { + workspace_id, + delete_branch, + force_branch, + } => { + if *force_branch { + self.remove_repository_workspace(*workspace_id, *delete_branch, true, ctx); + } else { + self.preflight_repository_workspace_removal(*workspace_id, *delete_branch, ctx); + } + } + } + } + + fn preflight_repository_workspace_removal( + &mut self, + workspace_id: RepositoryWorkspaceId, + delete_branch: bool, + ctx: &mut ViewContext, + ) { + let Some(workspace) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .workspace(workspace_id) + .cloned() + else { + self.close_delete_workspace_dialog(ctx); + return; + }; + let Some(repository) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .repository(workspace.repository_id) + .cloned() + else { + self.close_delete_workspace_dialog(ctx); + return; + }; + + ctx.spawn( + deletion_preflight_async(repository.path, workspace.worktree_path, delete_branch), + move |workspace_view, result, ctx| match result { + Ok(preflight) + if delete_branch + && preflight + .merge_target + .as_ref() + .is_some_and(|target| !target.is_merged) => + { + workspace_view + .delete_workspace_dialog + .view + .update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + body.require_force_confirmation(ctx); + }); + }); + } + Ok(_) => workspace_view.remove_repository_workspace( + workspace_id, + delete_branch, + false, + ctx, + ), + Err(error) => { + workspace_view.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!("Cannot remove workspace: {error}")), + ctx, + ); + }); + } + }, + ); + } + + fn remove_repository_workspace( + &mut self, + workspace_id: RepositoryWorkspaceId, + delete_branch: bool, + force_branch: bool, + ctx: &mut ViewContext, + ) { + let Some(workspace) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .workspace(workspace_id) + .cloned() + else { + self.close_delete_workspace_dialog(ctx); + return; + }; + let Some(repository) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .repository(workspace.repository_id) + .cloned() + else { + self.close_delete_workspace_dialog(ctx); + return; + }; + let branch = workspace.branch.clone(); + ctx.spawn( + remove_workspace_async( + repository.path, + workspace.worktree_path, + branch, + delete_branch, + force_branch, + ), + move |workspace_view, result, ctx| match result { + Ok(()) => { + let removed = ProjectOrganizationModel::handle(ctx) + .update(ctx, |model, ctx| model.remove_workspace(workspace_id, ctx)); + match removed { + Ok(_) => { + workspace_view.close_repository_workspace_tabs(workspace_id, ctx); + workspace_view.close_delete_workspace_dialog(ctx); + } + Err(error) => { + workspace_view.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!( + "Worktree was removed but workspace state could not be saved: {error}" + )), + ctx, + ); + }); + } + } + } + Err(error) => { + workspace_view.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!("Failed to remove workspace: {error}")), + ctx, + ); + }); + } + }, + ); + } + + fn close_repository_workspace_tabs( + &mut self, + workspace_id: RepositoryWorkspaceId, + ctx: &mut ViewContext, + ) { + let tabs = if self.active_repository_workspace_id() == Some(workspace_id) { + let tabs = std::mem::take(&mut self.tabs); + self.switch_repository_workspace(None, ctx); + self.repository_workspace_tabs + .take_inactive(Some(workspace_id)); + tabs + } else { + self.repository_workspace_tabs + .take_inactive(Some(workspace_id)) + .map_or_else(Vec::new, |state| state.tabs) + }; + let working_directories_model = self.working_directories_model.clone(); + for tab in tabs { + tab.pane_group.update(ctx, |pane_group, ctx| { + pane_group.detach_panes_for_close(&working_directories_model, ctx); + }); } + self.sync_project_tree(ctx); } - fn handle_left_panel_event(&mut self, event: &LeftPanelEvent, ctx: &mut ViewContext) { - match event { - LeftPanelEvent::FileTree(pane_group_event) => { - let pane_group = self.active_tab_pane_group().clone(); - self.handle_file_tree_event(pane_group, pane_group_event, ctx); - } - LeftPanelEvent::ZapDrive(drive_event) => { - self.handle_warp_drive_event(drive_event, ctx); - } - LeftPanelEvent::ServerFileBrowser(event) => match event { - crate::workspace::view::server_file_browser::ServerFileBrowserEvent::OpenRemoteFile { - remote_path, + fn create_repository_workspace( + &mut self, + request: CreateWorkspaceRequest, + ctx: &mut ViewContext, + ) { + let Some(repository) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .repository(request.repository_id) + .cloned() + else { + self.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error("Repository no longer exists.".to_string()), + ctx, + ); + }); + return; + }; + + let repository_path = repository.path.clone(); + let worktree_path = request.worktree_path.clone(); + let branch = match &request.source { + CreateWorkspaceSource::RemoteBranch { new_branch, .. } => new_branch.clone(), + CreateWorkspaceSource::ExistingLocalBranch { local_branch } => local_branch.clone(), + CreateWorkspaceSource::ExistingWorktree { local_branch } => local_branch.clone(), + }; + let delete_branch_on_cleanup = + matches!(&request.source, CreateWorkspaceSource::RemoteBranch { .. }); + let should_cleanup_on_persistence_failure = source_creates_worktree(&request.source); + let source = request.source.clone(); + let git_operation = async move { + match source { + CreateWorkspaceSource::RemoteBranch { + remote_ref, + new_branch, } => { - #[cfg(feature = "local_tty")] - self.open_remote_file(remote_path.clone(), ctx); - #[cfg(not(feature = "local_tty"))] - let _ = remote_path; + create_from_remote_async(repository_path, remote_ref, new_branch, worktree_path) + .await } - crate::workspace::view::server_file_browser::ServerFileBrowserEvent::CdToDirectory { - path, - } => { - self.cd_to_remote_directory(path, ctx); + CreateWorkspaceSource::ExistingLocalBranch { local_branch } => { + create_from_local_async(repository_path, local_branch, worktree_path).await + } + CreateWorkspaceSource::ExistingWorktree { local_branch } => { + validate_existing_worktree_async(repository_path, worktree_path, local_branch) + .await + .map(|_| ()) } - }, - LeftPanelEvent::OpenFileWithTarget { - path, - target, - line_col, - } => { - self.open_file_with_target( - path.clone(), - target.clone(), - *line_col, - CodeSource::FileTree { path: path.clone() }, - ctx, - ); - } - #[cfg(feature = "local_tty")] - LeftPanelEvent::OpenRemoteFile { remote_path } => { - self.open_remote_file(remote_path.clone(), ctx); - } - #[cfg(not(feature = "local_tty"))] - LeftPanelEvent::OpenRemoteFile { .. } => {} - #[cfg(feature = "local_tty")] - LeftPanelEvent::OpenRemoteImage { remote_path } => { - self.open_remote_image(remote_path.clone(), ctx); } - #[cfg(not(feature = "local_tty"))] - LeftPanelEvent::OpenRemoteImage { .. } => {} - LeftPanelEvent::OpenSkillFile { source } => { - #[cfg(feature = "local_fs")] - { - let layout = *EditorSettings::as_ref(ctx).open_file_layout.value(); - if let Some(path) = source.path() { - self.open_file_with_target( - path, - FileTarget::CodeEditor(layout), - None, - source.clone(), - ctx, - ); - } else { - log::error!("failed to open skill file: missing source path"); + }; + let persistence_repository_path = repository.path.clone(); + let persistence_worktree_path = request.worktree_path.clone(); + ctx.spawn(git_operation, move |workspace, result, ctx| match result { + Ok(()) => { + let now = chrono::Utc::now().naive_utc(); + let record = RepositoryWorkspace { + id: request.workspace_id, + repository_id: request.repository_id, + display_name: request.display_name.clone(), + branch: branch.clone(), + worktree_path: request.worktree_path.clone(), + created_at: now, + last_opened_at: now, + }; + let persisted = ProjectOrganizationModel::handle(ctx) + .update(ctx, |model, ctx| model.insert_workspace(record, ctx)); + if let Err(error) = persisted { + if !should_cleanup_on_persistence_failure { + workspace.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!( + "Failed to save workspace: {error}" + )), + ctx, + ); + }); + return; } + let cleanup_branch = branch.clone(); + ctx.spawn( + remove_workspace_async( + persistence_repository_path.clone(), + persistence_worktree_path.clone(), + cleanup_branch, + delete_branch_on_cleanup, + true, + ), + move |workspace, cleanup, ctx| { + let cleanup_detail = cleanup + .err() + .map(|cleanup_error| format!(" Cleanup failed: {cleanup_error}")) + .unwrap_or_default(); + workspace.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!( + "Failed to save workspace: {error}.{cleanup_detail}" + )), + ctx, + ); + }); + }, + ); + return; } - #[cfg(not(feature = "local_fs"))] - { - let _ = source; - } - } - LeftPanelEvent::NewConversationInNewTab => { - self.add_terminal_tab_with_new_agent_view(ctx); - } - LeftPanelEvent::ShowDeleteConfirmationDialog { - conversation_id, - conversation_title, - terminal_view_id, - } => { - self.show_delete_conversation_confirmation_dialog( - DeleteConversationDialogSource { - conversation_id: *conversation_id, - conversation_title: conversation_title.clone(), - terminal_view_id: *terminal_view_id, - }, + + workspace.activate_repository_workspace( + request.workspace_id, + request.worktree_path.clone(), ctx, ); } - LeftPanelEvent::OpenSshServerEditor { node_id } => { - self.open_ssh_server(node_id.clone(), ctx); - } - LeftPanelEvent::OpenSshTerminal { node_id, server } => { - self.open_ssh_terminal(node_id.clone(), server.clone(), ctx); - } - LeftPanelEvent::OpenSftpPane { node_id, server: _ } => { - self.open_sftp_pane(node_id.clone(), ctx); + Err(error) => { + workspace.toast_stack.update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!("Failed to create workspace: {error}")), + ctx, + ); + }); } - } + }); } /// 在中央区域打开给定 SSH 节点的编辑 pane。MVP 实现:**每次都开新 @@ -6138,8 +7034,7 @@ impl Workspace { } fn toggle_tab_configs_menu(&mut self, ctx: &mut ViewContext) { - let use_vertical_tabs = - FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs; + let use_vertical_tabs = Self::vertical_tabs_active(ctx); if self.show_new_session_dropdown_menu.is_some() { self.close_new_session_dropdown_menu(ctx); return; @@ -6204,9 +7099,7 @@ impl Workspace { open_in_active_window: false, }, ), - NewSessionMenuItem::OpenLaunchConfigDocs => { - ctx.open_url("") - } + NewSessionMenuItem::OpenLaunchConfigDocs => ctx.open_url(""), #[cfg(feature = "local_fs")] NewSessionMenuItem::CreateNewTabConfig => { self.create_and_open_new_tab_config(ctx); @@ -9558,49 +10451,17 @@ impl Workspace { None }; let tabs = self - .tab_views() + .tabs + .iter() .enumerate() .filter(|(tab_index, _)| Some(*tab_index) != transferred_tab_index) - .map(|(tab_index, pane_group_view)| { - let resizable_data = ResizableData::handle(app); - let modal_sizes = resizable_data.as_ref(app).get_all_handles(window_id); - - let left_panel_width = modal_sizes.map(|ms| { - ms.left_panel_width - .lock() - .expect("should be able to lock left panel handle") - .size() - }); - - let right_panel_width = modal_sizes.map(|ms| { - ms.right_panel_width - .lock() - .expect("should be able to lock right panel handle") - .size() - }); - - let pane_group = pane_group_view.as_ref(app); - let root = pane_group.snapshot(app); - let left_panel = - self.compute_left_panel_snapshot(pane_group_view, left_panel_width, app); - let right_panel = - self.compute_right_panel_snapshot(pane_group_view, right_panel_width, app); - TabSnapshot { - root, - custom_title: pane_group.custom_title(app), - default_directory_color: self - .tabs - .get(tab_index) - .and_then(|tab| tab.default_directory_color), - selected_color: self - .tabs - .get(tab_index) - .map(|tab| tab.selected_color) - .unwrap_or_default(), - left_panel, - right_panel, - } - }) + .map(|(_, tab)| self.snapshot_tab(tab, window_id, app)) + .chain( + self.repository_workspace_tabs + .inactive_states() + .flat_map(|(_, state)| state.tabs.iter()) + .map(|tab| self.snapshot_tab(tab, window_id, app)), + ) .filter(|tab| { // Filter out any tab that contains a single, read-only session. !matches!( @@ -9665,6 +10526,19 @@ impl Workspace { WindowSnapshot { tabs, active_tab_index, + active_repository_workspace_id: self.active_repository_workspace_id(), + repository_workspace_states: self + .repository_workspace_tabs + .inactive_states() + .filter_map(|(workspace_id, state)| { + workspace_id.map(|repository_workspace_id| { + RepositoryWorkspaceWindowStateSnapshot { + repository_workspace_id, + active_tab_index: state.active_tab_index, + } + }) + }) + .collect(), bounds: window_bounds, fullscreen_state: window_fullscreen_state, quake_mode, @@ -9681,6 +10555,33 @@ impl Workspace { } } + fn snapshot_tab(&self, tab: &TabData, window_id: WindowId, app: &AppContext) -> TabSnapshot { + let resizable_data = ResizableData::handle(app); + let modal_sizes = resizable_data.as_ref(app).get_all_handles(window_id); + let left_panel_width = modal_sizes.map(|ms| { + ms.left_panel_width + .lock() + .expect("should be able to lock left panel handle") + .size() + }); + let right_panel_width = modal_sizes.map(|ms| { + ms.right_panel_width + .lock() + .expect("should be able to lock right panel handle") + .size() + }); + let pane_group = tab.pane_group.as_ref(app); + TabSnapshot { + repository_workspace_id: tab.repository_workspace_id, + root: pane_group.snapshot(app), + custom_title: pane_group.custom_title(app), + default_directory_color: tab.default_directory_color, + selected_color: tab.selected_color, + left_panel: self.compute_left_panel_snapshot(&tab.pane_group, left_panel_width, app), + right_panel: self.compute_right_panel_snapshot(&tab.pane_group, right_panel_width, app), + } + } + fn compute_left_panel_snapshot( &self, pane_group: &ViewHandle, @@ -9903,6 +10804,7 @@ impl Workspace { } let tab_data = self.tabs.remove(index); + self.sync_project_tree(ctx); if add_to_undo_stack { let handle = ctx.handle(); @@ -10217,7 +11119,10 @@ impl Workspace { /// Update this workspace when it is reopened after being closed. pub fn handle_reopen(&mut self, ctx: &mut ViewContext) { self.sync_window_button_visibility(ctx); - for pane_group in self.tab_views() { + for pane_group in self + .all_repository_workspace_tabs() + .map(|tab| &tab.pane_group) + { pane_group.update(ctx, |pane_group, ctx| { pane_group.reattach_panes(ctx); }) @@ -10244,9 +11149,7 @@ impl Workspace { } pub fn open_autoupdate_failure_link(&mut self, ctx: &mut ViewContext) { - ctx.open_url( - "", - ); + ctx.open_url(""); } pub fn add_terminal_tab(&mut self, hide_homepage: bool, ctx: &mut ViewContext) { @@ -10511,17 +11414,21 @@ impl Workspace { match new_tab_placement_setting { NewTabPlacement::AfterAllTabs => { - self.tabs.push(TabData::new(new_pane_group)); + self.tabs + .push(self.tab_data_for_active_repository_workspace(new_pane_group)); self.activate_tab_internal(self.tab_count() - 1, ctx); } // Add tab after current tab _ => { if self.tab_count() == 0 { - self.tabs.push(TabData::new(new_pane_group)); + self.tabs + .push(self.tab_data_for_active_repository_workspace(new_pane_group)); self.activate_tab_internal(self.tab_count() - 1, ctx); } else { - self.tabs - .insert(self.active_tab_index + 1, TabData::new(new_pane_group)); + self.tabs.insert( + self.active_tab_index + 1, + self.tab_data_for_active_repository_workspace(new_pane_group), + ); self.activate_tab_internal(self.active_tab_index + 1, ctx); } } @@ -10561,6 +11468,7 @@ impl Workspace { pg.set_left_panel_open(true, ctx); }); } + self.sync_project_tree(ctx); } pub fn add_tab_from_existing_pane( @@ -10584,12 +11492,17 @@ impl Workspace { }); if self.tab_count() == 0 { - self.tabs.push(TabData::new(new_pane_group)); + self.tabs + .push(self.tab_data_for_active_repository_workspace(new_pane_group)); self.activate_tab_internal(self.tab_count() - 1, ctx); } else { - self.tabs.insert(new_idx, TabData::new(new_pane_group)); + self.tabs.insert( + new_idx, + self.tab_data_for_active_repository_workspace(new_pane_group), + ); self.activate_tab_internal(new_idx, ctx); } + self.sync_project_tree(ctx); } pub fn add_tab_for_cloud_notebook( @@ -10786,7 +11699,9 @@ impl Workspace { fn open_repository(&mut self, path: Option<&str>, ctx: &mut ViewContext) { match path { - Some(path) => self.handle_open_repository(path, ctx), + Some(path) => { + let _ = self.handle_open_repository(path, ctx); + } None => ctx.open_file_picker( |result, ctx| match result { Ok(paths) => { @@ -10816,11 +11731,28 @@ impl Workspace { } } - fn handle_open_repository(&mut self, path: &str, ctx: &mut ViewContext) { + fn touch_repository_for_open( + path: &Path, + ctx: &mut AppContext, + ) -> Result<(), ProjectOrganizationError> { + ProjectOrganizationModel::handle(ctx) + .update(ctx, |model, ctx| model.touch_repository_path(path, ctx)) + .map(|_| ()) + } + + fn handle_open_repository(&mut self, path: &str, ctx: &mut ViewContext) -> bool { let path_buf = PathBuf::from(path); - ProjectManagementModel::handle(ctx).update(ctx, |projects, ctx| { - projects.upsert_project(path_buf.clone(), ctx); - }); + if let Err(error) = Self::touch_repository_for_open(&path_buf, ctx) { + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!("Failed to open repository: {error}")), + window_id, + ctx, + ); + }); + return false; + } self.add_tab_with_pane_layout( PanesLayout::SingleTerminal(Box::new(NewTerminalOptions { initial_directory: Some(path_buf.clone()), @@ -10831,6 +11763,7 @@ impl Workspace { None, ctx, ); + true } /// Navigate to an existing AI conversation, focusing on its terminal view, if it's open anywhere. @@ -11118,7 +12051,8 @@ impl Workspace { me.handle_file_tree_event(pane_group, event, ctx) }); - self.tabs.push(TabData::new(new_pane_group.clone())); + self.tabs + .push(self.tab_data_for_active_repository_workspace(new_pane_group.clone())); let new_tab_index = self.tab_count() - 1; self.activate_tab_internal(new_tab_index, ctx); @@ -11607,9 +12541,8 @@ impl Workspace { || self.traffic_light_mouse_states.are_traffic_lights_hovered(); // Check if any of the menus/popups rendered relative to the tab bar are open. - let is_vertical_tabs_active = FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(app).use_vertical_tabs - && self.vertical_tabs_panel_open; + let is_vertical_tabs_active = + Self::vertical_tabs_active(app) && self.vertical_tabs_panel_open; let is_tab_menu_open = self.show_tab_bar_overflow_menu || (self.show_tab_right_click_menu.is_some() && !is_vertical_tabs_active) || (self.show_new_session_dropdown_menu.is_some() && !is_vertical_tabs_active) @@ -12466,6 +13399,7 @@ impl Workspace { } pane_group::Event::TerminalViewStateChanged => { self.update_active_session(ctx); + self.sync_project_tree(ctx); ctx.notify(); } pane_group::Event::OnboardingTutorialCompleted => { @@ -12488,11 +13422,7 @@ impl Workspace { self.open_workflow_with_command(command.clone(), ctx) } pane_group::Event::OpenCloudWorkflowForEdit(workflow_id) => self - .open_workflow_with_existing( - *workflow_id, - &ZapDriveObjectSettings::default(), - ctx, - ), + .open_workflow_with_existing(*workflow_id, &ZapDriveObjectSettings::default(), ctx), pane_group::Event::OpenWorkflowModalWithTemporary(workflow) => { self.open_workflow_with_temporary(*workflow.clone(), ctx) } @@ -13898,12 +14828,7 @@ impl Workspace { ); } DrivePanelEvent::OpenSearch => { - self.open_palette_action( - PaletteMode::ZapDrive, - PaletteSource::ZapDrive, - None, - ctx, - ); + self.open_palette_action(PaletteMode::ZapDrive, PaletteSource::ZapDrive, None, ctx); } DrivePanelEvent::OpenNotebook(source) => { self.open_notebook(source, &ZapDriveObjectSettings::default(), ctx, true) @@ -13911,12 +14836,9 @@ impl Workspace { DrivePanelEvent::OpenEnvVarCollection(source) => { self.open_env_var_collection(source, false, ctx) } - DrivePanelEvent::OpenWorkflowInPane(source, mode) => self.open_workflow_in_pane( - source, - &ZapDriveObjectSettings::default(), - *mode, - ctx, - ), + DrivePanelEvent::OpenWorkflowInPane(source, mode) => { + self.open_workflow_in_pane(source, &ZapDriveObjectSettings::default(), *mode, ctx) + } DrivePanelEvent::OpenAIFactCollection => { self.open_ai_fact_collection_pane(None, None, ctx); send_telemetry_from_ctx!( @@ -15827,8 +16749,7 @@ impl Workspace { appearance: &Appearance, ctx: &AppContext, ) -> Box { - let vertical_tabs_active = - FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs; + let vertical_tabs_active = Self::vertical_tabs_active(ctx); let (is_active, tooltip_text, action, keybinding_name, save_position_id) = if vertical_tabs_active { @@ -15847,6 +16768,7 @@ impl Workspace { .copied() .unwrap_or(ToolPanelView::ZapDrive) { + ToolPanelView::ProjectTree => "Repository workspaces".to_string(), ToolPanelView::ProjectExplorer => { crate::t!("workspace-left-panel-project-explorer") } @@ -15916,6 +16838,7 @@ impl Workspace { .copied() .unwrap_or(ToolPanelView::ZapDrive) { + ToolPanelView::ProjectTree => "Repository workspaces".to_string(), ToolPanelView::ProjectExplorer => { crate::t!("workspace-left-panel-project-explorer") } @@ -16311,8 +17234,7 @@ impl Workspace { } // Check if vertical tabs mode is active - let vertical_tabs_active = - FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs; + let vertical_tabs_active = Self::vertical_tabs_active(ctx); // Render config-driven left-side toolbar buttons (both horizontal and vertical tabs) let knowledge_center_closed = true; @@ -16526,8 +17448,7 @@ impl Workspace { if !item.is_available(ctx) { return None; } - let vertical_tabs_active = - FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs; + let vertical_tabs_active = Self::vertical_tabs_active(ctx); let inner = match item { HeaderToolbarItemKind::TabsPanel => self.render_left_toggle_button(appearance, ctx), HeaderToolbarItemKind::ToolsPanel => { @@ -16596,33 +17517,24 @@ impl Workspace { let icon = agent.icon().unwrap_or(icons::Icon::LayoutAlt01); let theme = appearance.theme(); let icon_color = theme.sub_text_color(theme.background()); - let button = icon_button_with_color( - appearance, - icon, - false, - handle.clone(), - icon_color, - ) - .with_hovered_styles(UiComponentStyles { - font_color: Some(icon_color.into()), - background: Some(theme.surface_2().into()), - ..UiComponentStyles::default() - }) - .with_clicked_styles(UiComponentStyles { - font_color: Some(icon_color.into()), - background: Some(theme.background().into()), - ..UiComponentStyles::default() - }) - // 图标缩小到 14×14:padding 从 4 增大到 5.0,按钮外框 24×24 不变 - .with_style(UiComponentStyles::default() - .set_padding(Coords::uniform(5.0)) - ); + let button = + icon_button_with_color(appearance, icon, false, handle.clone(), icon_color) + .with_hovered_styles(UiComponentStyles { + font_color: Some(icon_color.into()), + background: Some(theme.surface_2().into()), + ..UiComponentStyles::default() + }) + .with_clicked_styles(UiComponentStyles { + font_color: Some(icon_color.into()), + background: Some(theme.background().into()), + ..UiComponentStyles::default() + }) + // 图标缩小到 14×14:padding 从 4 增大到 5.0,按钮外框 24×24 不变 + .with_style(UiComponentStyles::default().set_padding(Coords::uniform(5.0))); let agent_name = agent.display_name().to_string(); let button = button - .with_tooltip( - self.render_tab_bar_icon_button_tooltip(appearance, agent_name, None), - ) + .with_tooltip(self.render_tab_bar_icon_button_tooltip(appearance, agent_name, None)) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(WorkspaceAction::AddSpecificAgentTab(agent)); @@ -16778,8 +17690,7 @@ impl Workspace { let zoom_factor = WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor(); let traffic_light_data = traffic_light_data(ctx, self.window_id); if let Some(traffic_light_data) = traffic_light_data.as_ref() { - let vertical_tabs_active = FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(ctx).use_vertical_tabs; + let vertical_tabs_active = Self::vertical_tabs_active(ctx); let right_panel_open = self.current_workspace_state.is_right_panel_open(); let should_reserve_right_traffic_light_space = vertical_tabs_active || !right_panel_open; @@ -17386,8 +18297,7 @@ impl Workspace { None => active_content, }; - let vertical_tabs_active = - FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(app).use_vertical_tabs; + let vertical_tabs_active = Self::vertical_tabs_active(app); let pane_group = self.active_tab_pane_group().as_ref(app); let is_right_open = pane_group.right_panel_open; let is_right_maximized = is_right_open && pane_group.is_right_panel_maximized; @@ -17898,8 +18808,7 @@ impl Workspace { let mut contents = contents; let traffic_light_data = traffic_light_data(app, self.window_id); - let vertical_tabs_active = - FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(app).use_vertical_tabs; + let vertical_tabs_active = Self::vertical_tabs_active(app); // Add a spacer for the traffic light buttons on Windows/Linux. if traffic_light_data.is_some_and(|data| data.side == TrafficLightSide::Right) && *side == PanelPosition::Right @@ -17976,9 +18885,7 @@ impl Workspace { // Config-driven vertical-tabs-era panels (left side). // Hidden for simplified WASM views (notebooks, shared sessions, etc.) // where these panels are unnecessary. - let vertical_tabs_active = !hide_vertical_tabs - && FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(app).use_vertical_tabs; + let vertical_tabs_active = !hide_vertical_tabs && Self::vertical_tabs_active(app); // In vertical tabs mode, config-driven panels are rendered here. // In horizontal tabs mode, they're rendered inside render_banner_and_active_tab. @@ -18670,6 +19577,9 @@ impl Workspace { /// Computes the list of available left panel views based on current AI settings and feature flags. fn compute_left_panel_views(ctx: &AppContext) -> Vec { let mut views = vec![]; + if FeatureFlag::RepositoryWorkspaces.is_enabled() { + views.push(ToolPanelView::ProjectTree); + } if FeatureFlag::AgentViewConversationListView.is_enabled() && AISettings::as_ref(ctx).is_any_ai_enabled(ctx) && *AISettings::as_ref(ctx).show_conversation_history @@ -18693,7 +19603,8 @@ impl Workspace { } // openWarp 独有:SSH 管理器,无 feature flag,默认始终显示。 views.push(ToolPanelView::SshManager); - if FeatureFlag::ServerFileBrowser.is_enabled() && FeatureFlag::SshRemoteServer.is_enabled() { + if FeatureFlag::ServerFileBrowser.is_enabled() && FeatureFlag::SshRemoteServer.is_enabled() + { views.push(ToolPanelView::ServerFileBrowser); } // openWarp 独有:Skill 管理器,无 feature flag,local_fs 构建下默认显示。 @@ -19352,10 +20263,7 @@ impl TypedActionView for Workspace { } } ToggleVerticalTabsSettingsPopup => { - if FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(ctx).use_vertical_tabs - && self.vertical_tabs_panel_open - { + if Self::vertical_tabs_active(ctx) && self.vertical_tabs_panel_open { self.vertical_tabs_panel.show_settings_popup = !self.vertical_tabs_panel.show_settings_popup; ctx.notify(); @@ -20791,8 +21699,7 @@ impl View for Workspace { ); if !use_simplified_wasm_tab_bar - && FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(app).use_vertical_tabs + && Self::vertical_tabs_active(app) && self.vertical_tabs_panel_open && self.vertical_tabs_panel.show_settings_popup { @@ -20813,10 +21720,7 @@ impl View for Workspace { ); } - if FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(app).use_vertical_tabs - && self.vertical_tabs_panel_open - { + if Self::vertical_tabs_active(app) && self.vertical_tabs_panel_open { if let Some(vertical_tabs::DetailSidecarOverlay { anchor_position_id, offset, @@ -20944,9 +21848,7 @@ impl View for Workspace { } if let Some((tab_idx, right_click_menu_anchor)) = self.show_tab_right_click_menu { - let is_vertical = FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(app).use_vertical_tabs - && self.vertical_tabs_panel_open; + let is_vertical = Self::vertical_tabs_active(app) && self.vertical_tabs_panel_open; if tab_bar_mode.has_tab_bar() || is_vertical { let positioning = match (is_vertical, right_click_menu_anchor) { (true, TabContextMenuAnchor::VerticalTabsKebab) => { @@ -20997,9 +21899,7 @@ impl View for Workspace { // Render the new session dropdown menu. This is outside the tab bar visibility // gate because it can also be opened from the vertical tabs panel. if self.show_new_session_dropdown_menu.is_some() { - let is_vertical = FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(app).use_vertical_tabs - && self.vertical_tabs_panel_open; + let is_vertical = Self::vertical_tabs_active(app) && self.vertical_tabs_panel_open; if is_vertical { // Anchor the menu below the vertical-tabs + button. @@ -21243,9 +22143,7 @@ impl View for Workspace { } if self.should_show_session_config_tab_config_chip() { - let use_vertical = FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(app).use_vertical_tabs - && self.vertical_tabs_panel_open; + let use_vertical = Self::vertical_tabs_active(app) && self.vertical_tabs_panel_open; let chip = self.render_session_config_tab_config_chip(use_vertical, Appearance::as_ref(app)); if use_vertical { @@ -21282,6 +22180,14 @@ impl View for Workspace { stack.add_child(self.new_worktree_modal.render()); } + if self.create_workspace_modal.is_open() { + stack.add_child(self.create_workspace_modal.render()); + } + + if self.delete_workspace_dialog.is_open() { + stack.add_child(self.delete_workspace_dialog.render()); + } + if self.workflow_modal.as_ref(app).is_open() { stack.add_child(ChildView::new(&self.workflow_modal).finish()); } @@ -21693,8 +22599,7 @@ impl View for Workspace { } // Add workspace-wide UI event handling. - let stack = if FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(app).use_vertical_tabs + let stack = if Self::vertical_tabs_active(app) && self.vertical_tabs_panel_open // The vertical-tabs detail sidecar can become stale if the pointer moves through a // covered region (for example, its scrollbar gutter) and the row/sidecar hoverables @@ -21766,7 +22671,10 @@ impl View for Workspace { /// Update this workspace when it has been closed, but may still be restored. fn on_window_closed(&mut self, ctx: &mut ViewContext) { if !self.suppress_detach_panes_on_window_close { - for pane_group in self.tab_views() { + for pane_group in self + .all_repository_workspace_tabs() + .map(|tab| &tab.pane_group) + { pane_group.update(ctx, |pane_group, ctx| { pane_group.detach_panes(ctx); }); @@ -21817,6 +22725,7 @@ impl Workspace { Some(TransferredTab { pane_group, + repository_workspace_id: tab.repository_workspace_id, color, custom_title, left_panel_open, @@ -21875,8 +22784,14 @@ impl Workspace { pane_group, color, draggable_state, + repository_workspace_id, .. } = transferred_tab; + if FeatureFlag::RepositoryWorkspaces.is_enabled() + && repository_workspace_id != self.active_repository_workspace_id() + { + self.switch_repository_workspace(repository_workspace_id, ctx); + } ctx.subscribe_to_view(&pane_group, move |me, pane_group, event, ctx| { me.handle_file_tree_event(pane_group, event, ctx) }); @@ -21885,6 +22800,7 @@ impl Workspace { let mut tab_data = TabData::new(pane_group); tab_data.selected_color = color.map_or(SelectedTabColor::Unset, SelectedTabColor::Color); tab_data.draggable_state = draggable_state; + tab_data.repository_workspace_id = repository_workspace_id; self.tabs.insert(index, tab_data); self.activate_tab_internal(index, ctx); ctx.notify(); @@ -22345,9 +23261,7 @@ impl Workspace { return; } - let new_index = if FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(ctx).use_vertical_tabs - { + let new_index = if Self::vertical_tabs_active(ctx) { self.calculate_updated_tab_index_vertical(current_index, position, ctx) } else { self.calculate_updated_tab_index(current_index, position, ctx) diff --git a/app/src/workspace/view/launch_modal/mod.rs b/app/src/workspace/view/launch_modal/mod.rs index 03bdfee2dfa..ed1da90431b 100644 --- a/app/src/workspace/view/launch_modal/mod.rs +++ b/app/src/workspace/view/launch_modal/mod.rs @@ -355,7 +355,9 @@ impl LaunchModal { .content_hyperlink .clone(), ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .with_hyperlink_font_color(theme.accent().into_solid()) .register_default_click_handlers_with_action_support( |hyperlink_lens, _event, ctx| { @@ -527,7 +529,9 @@ impl View for LaunchModal { blended_colors::text_main(theme, blended_colors::neutral_1(theme)), Default::default(), // no hyperlink highlighting needed ) - .with_heading_to_font_size_multipliers(appearance.heading_font_size_multipliers().clone()) + .with_heading_to_font_size_multipliers( + appearance.heading_font_size_multipliers().clone(), + ) .disable_mouse_interaction() .finish(); diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index 68977620553..49dbfd6c661 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; @@ -45,9 +45,7 @@ use crate::workspace::view::conversation_list::view::{ use crate::workspace::view::global_search::view::{ Event as GlobalSearchViewEvent, GlobalSearchEntryFocus, GlobalSearchView, }; -use crate::workspace::view::server_file_browser::{ - ServerFileBrowserEvent, ServerFileBrowserView, -}; +use crate::workspace::view::server_file_browser::{ServerFileBrowserEvent, ServerFileBrowserView}; use crate::workspace::view::{ LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME, LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_SKILL_MANAGER_BINDING_NAME, @@ -61,6 +59,8 @@ use crate::{ drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH}, pane_group::pane::view::header::{components::HEADER_EDGE_PADDING, PANE_HEADER_HEIGHT}, pane_group::{self}, + project_organization::domain::RepositoryWorkspaceId, + project_organization::view::project_tree::{ProjectTreeEvent, ProjectTreePanel}, terminal::resizable_data::{ModalType, ResizableData}, ui_components::{ buttons::{icon_button, icon_button_with_color}, @@ -73,6 +73,7 @@ use crate::{ #[derive(Default)] struct MouseStateHandles { + project_tree_button: MouseStateHandle, project_explorer_button: MouseStateHandle, global_search_button: MouseStateHandle, warp_drive_button: MouseStateHandle, @@ -84,6 +85,7 @@ struct MouseStateHandles { #[derive(Clone, Debug)] pub enum LeftPanelAction { + ProjectTree, ProjectExplorer, GlobalSearch { entry_focus: GlobalSearchEntryFocus }, ZapDrive, @@ -94,6 +96,7 @@ pub enum LeftPanelAction { } pub enum LeftPanelEvent { + ProjectTree(ProjectTreeEvent), #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] FileTree(pane_group::Event), ZapDrive(DrivePanelEvent), @@ -143,6 +146,7 @@ pub enum LeftPanelEvent { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ToolPanelView { + ProjectTree, ProjectExplorer, GlobalSearch { entry_focus: GlobalSearchEntryFocus }, ZapDrive, @@ -219,6 +223,7 @@ pub struct LeftPanelView { ssh_manager_view: ViewHandle, server_file_browser_view: ViewHandle, skill_manager_view: ViewHandle, + project_tree_view: ViewHandle, active_view: active_view_state::ActiveViewState, toolbelt_buttons: Vec, active_pane_group: Option>, @@ -266,6 +271,10 @@ impl LeftPanelView { let ssh_manager_view = ctx.add_typed_action_view(SshManagerPanel::new); let server_file_browser_view = ctx.add_typed_action_view(ServerFileBrowserView::new); let skill_manager_view = ctx.add_typed_action_view(SkillManagerPanel::new); + let project_tree_view = ctx.add_typed_action_view(ProjectTreePanel::new); + ctx.subscribe_to_view(&project_tree_view, |_me, _, event, ctx| { + ctx.emit(LeftPanelEvent::ProjectTree(event.clone())); + }); ctx.subscribe_to_view(&ssh_manager_view, |_me, _, event, ctx| { use crate::ssh_manager::SshManagerPanelEvent; match event { @@ -406,6 +415,7 @@ impl LeftPanelView { ssh_manager_view, server_file_browser_view, skill_manager_view, + project_tree_view, active_view: active_view_state::new(active_view), toolbelt_buttons, active_pane_group: None, @@ -475,6 +485,15 @@ impl LeftPanelView { ctx: &ViewContext, ) -> ToolbeltButtonConfig { match view { + ToolPanelView::ProjectTree => ToolbeltButtonConfig { + icon: Icon::Folder, + active_icon: None, + tooltip_text: "Repository workspaces".to_string(), + action: LeftPanelAction::ProjectTree, + render_with_active_state: false, + tooltip_keybinding: None, + tooltip_keybinding_names: Vec::new(), + }, ToolPanelView::ProjectExplorer => { let tooltip_keybinding_names = vec![ LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, @@ -686,6 +705,36 @@ impl LeftPanelView { self.active_view.get() } + pub fn set_project_tree_tab_counts( + &mut self, + tab_counts: HashMap, + ctx: &mut ViewContext, + ) { + self.project_tree_view.update(ctx, |tree, ctx| { + tree.set_tab_counts(tab_counts, ctx); + }); + } + + pub fn set_project_tree_active_workspace( + &mut self, + workspace_id: Option, + ctx: &mut ViewContext, + ) { + self.project_tree_view.update(ctx, |tree, ctx| { + tree.set_active_workspace(workspace_id, ctx); + }); + } + + pub fn set_project_tree_running_workspaces( + &mut self, + running_workspace_ids: HashSet, + ctx: &mut ViewContext, + ) { + self.project_tree_view.update(ctx, |tree, ctx| { + tree.set_running_workspaces(running_workspace_ids, ctx); + }); + } + pub fn is_warp_drive_active(&self) -> bool { self.active_view.get() == ToolPanelView::ZapDrive } @@ -809,6 +858,9 @@ impl LeftPanelView { pub fn focus_active_view_on_entry(&mut self, ctx: &mut ViewContext) { match self.active_view.get() { + ToolPanelView::ProjectTree => { + ctx.focus(&self.project_tree_view); + } ToolPanelView::ProjectExplorer => { if let Some(file_tree_view) = self.active_file_tree_view(ctx) { file_tree_view.update(ctx, |view, ctx| { @@ -1013,6 +1065,9 @@ impl LeftPanelView { fn update_button_active_states(&mut self) { for button in &mut self.toolbelt_buttons { button.render_with_active_state = match &button.action { + LeftPanelAction::ProjectTree => { + self.active_view.get() == ToolPanelView::ProjectTree + } LeftPanelAction::ProjectExplorer => { self.active_view.get() == ToolPanelView::ProjectExplorer } @@ -1109,6 +1164,9 @@ impl LeftPanelView { ctx: &mut ViewContext, ) { match action { + LeftPanelAction::ProjectTree => { + active_view_state::set(self, ToolPanelView::ProjectTree, ctx); + } LeftPanelAction::ProjectExplorer => { active_view_state::set(self, ToolPanelView::ProjectExplorer, ctx); if force_open { @@ -1267,6 +1325,7 @@ impl View for LeftPanelView { // Focus the active tool panel view on-left-panel-focus. if focus_ctx.is_self_focused() { match self.active_view.get() { + ToolPanelView::ProjectTree => ctx.focus(&self.project_tree_view), ToolPanelView::ProjectExplorer => { if let Some(view) = self.active_file_tree_view(ctx) { ctx.focus(&view); @@ -1290,6 +1349,7 @@ impl View for LeftPanelView { let appearance = Appearance::as_ref(app); let mouse_state_handles = vec![ + self.mouse_state_handles.project_tree_button.clone(), self.mouse_state_handles.project_explorer_button.clone(), self.mouse_state_handles.global_search_button.clone(), self.mouse_state_handles.warp_drive_button.clone(), @@ -1321,6 +1381,14 @@ impl View for LeftPanelView { }; let content_area: Box = match self.active_view.get() { + ToolPanelView::ProjectTree => Shrinkable::new( + 1.0, + Container::new(ChildView::new(&self.project_tree_view).finish()) + .with_padding_left(2.) + .with_padding_right(2.) + .finish(), + ) + .finish(), ToolPanelView::ProjectExplorer => { if let Some(file_tree_view) = self.active_file_tree_view(app) { Shrinkable::new( diff --git a/app/src/workspace/view/onboarding.rs b/app/src/workspace/view/onboarding.rs index d584ae41833..3eae90e0aa6 100644 --- a/app/src/workspace/view/onboarding.rs +++ b/app/src/workspace/view/onboarding.rs @@ -1,9 +1,12 @@ use crate::pane_group::{NewTerminalOptions, PanesLayout}; +use crate::project_organization::model::ProjectOrganizationModel; use crate::settings::AISettings; use crate::terminal; use crate::terminal::view::{ AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction, }; +use crate::view_components::DismissibleToast; +use crate::workspace::ToastStack; use crate::workspace::Workspace; use crate::FeatureFlag; use onboarding::{ProjectOnboardingSettings, SelectedSettings}; @@ -97,7 +100,9 @@ impl Workspace { log::error!("Failed to convert path to string: {path:?}"); return; }; - self.handle_open_repository(path_str, ctx); + if !self.handle_open_repository(path_str, ctx) { + return; + } // Subscribe to the terminal view to wait for init completion if let Some(terminal_view_handle) = self.active_session_view(ctx) { @@ -117,6 +122,21 @@ impl Workspace { ref path, intention, } => { + if let Err(error) = ProjectOrganizationModel::handle(ctx) + .update(ctx, |model, ctx| model.touch_repository_path(path, ctx)) + { + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error(format!( + "Failed to open repository for onboarding: {error}" + )), + window_id, + ctx, + ); + }); + return; + } // Create a new terminal in the project directory self.add_tab_with_pane_layout( PanesLayout::SingleTerminal(Box::new(NewTerminalOptions { diff --git a/app/src/workspace/view/server_file_browser.rs b/app/src/workspace/view/server_file_browser.rs index a4a92559390..f841c17ee0a 100644 --- a/app/src/workspace/view/server_file_browser.rs +++ b/app/src/workspace/view/server_file_browser.rs @@ -21,31 +21,29 @@ use warp_util::standardized_path::StandardizedPath; use warpui::clipboard::ClipboardContent; use warpui::elements::{ Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, - CrossAxisAlignment, - DispatchEventResult, Dismiss, Element, Empty, EventHandler, Flex, Hoverable, MainAxisAlignment, - MainAxisSize, - MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, - SavePosition, ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, - Stack, Text, UniformList, UniformListState, + CrossAxisAlignment, Dismiss, DispatchEventResult, Element, Empty, EventHandler, Flex, + Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, + ParentElement, ParentOffsetBounds, Radius, SavePosition, ScrollStateHandle, Scrollable, + ScrollableElement, ScrollbarWidth, Shrinkable, Stack, Text, UniformList, UniformListState, }; use warpui::modals::{AlertDialogWithCallbacks, ModalButton}; use warpui::platform::{Cursor, FilePickerConfiguration, SaveFilePickerConfiguration}; -use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use warpui::r#async::{SpawnedFutureHandle, Timer}; +use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use warpui::{ AppContext, BlurContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle, }; +use crate::appearance::Appearance; use crate::code::buffer_location::RemotePath; use crate::editor::{ EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions, }; -use crate::appearance::Appearance; use crate::menu::{ - Event as MenuEvent, Menu, MenuItem, MenuItemFields, SubMenu, DEFAULT_WIDTH as MENU_DEFAULT_WIDTH, - MENU_ITEM_VERTICAL_PADDING, SUBMENU_OVERLAP, + Event as MenuEvent, Menu, MenuItem, MenuItemFields, SubMenu, + DEFAULT_WIDTH as MENU_DEFAULT_WIDTH, MENU_ITEM_VERTICAL_PADDING, SUBMENU_OVERLAP, }; use crate::remote_server::manager::RemoteServerManager; use crate::terminal::model::session::{ExecuteCommandOptions, Session}; @@ -81,10 +79,7 @@ pub enum ServerFileBrowserAction { ExpandSelectedItem, CollapseSelectedItem, ExecuteSelectedItem, - OpenContextMenu { - index: usize, - position: Vector2F, - }, + OpenContextMenu { index: usize, position: Vector2F }, DismissContextMenu, CopyPath(String), CopyName(String), @@ -371,8 +366,7 @@ impl ServerFileBrowserView { path: String, ctx: &mut ViewContext, ) { - let should_load = - self.host_id.as_ref() != Some(&host_id) || self.current_path != path; + let should_load = self.host_id.as_ref() != Some(&host_id) || self.current_path != path; self.host_id = Some(host_id); if should_load { self.current_path = path; @@ -396,10 +390,8 @@ impl ServerFileBrowserView { }; let session_id_changed = self.session_id != session_id; let host_changed = self.host_id.as_ref() != Some(&host_id); - let should_load = host_changed - || self.current_path != path - || session_changed - || session_id_changed; + let should_load = + host_changed || self.current_path != path || session_changed || session_id_changed; // host 或 session 切换时,旧的进度轮询已与新连接无关,先停掉避免 // 多余的 timer 回调。若后续加载中产生新的 upload/download 任务, // `schedule_progress_poll` 会被重新启动。 @@ -446,7 +438,12 @@ impl ServerFileBrowserView { } fn jump_to_editor_path(&mut self, ctx: &mut ViewContext) { - let path = self.path_editor.as_ref(ctx).buffer_text(ctx).trim().to_string(); + let path = self + .path_editor + .as_ref(ctx) + .buffer_text(ctx) + .trim() + .to_string(); if path.is_empty() { return; } @@ -455,7 +452,9 @@ impl ServerFileBrowserView { fn client(&self, ctx: &AppContext) -> Option> { let host_id = self.host_id.as_ref()?; - RemoteServerManager::as_ref(ctx).client_for_host(host_id).cloned() + RemoteServerManager::as_ref(ctx) + .client_for_host(host_id) + .cloned() } fn remote_session_id(&self, ctx: &AppContext) -> Option { @@ -491,7 +490,10 @@ impl ServerFileBrowserView { return; } self.loading = false; - self.status = Some(crate::t!("server-file-browser-operation-failed", error = error)); + self.status = Some(crate::t!( + "server-file-browser-operation-failed", + error = error + )); ctx.notify(); } @@ -744,11 +746,16 @@ impl ServerFileBrowserView { } fn sync_row_states(&mut self) { - let active_paths: HashSet = self.entries.iter().map(|entry| entry.path.clone()).collect(); + let active_paths: HashSet = self + .entries + .iter() + .map(|entry| entry.path.clone()) + .collect(); for path in &active_paths { self.row_states.entry(path.clone()).or_default(); } - self.row_states.retain(|path, _| active_paths.contains(path)); + self.row_states + .retain(|path, _| active_paths.contains(path)); } fn toggle_directory(&mut self, path: String, ctx: &mut ViewContext) { @@ -830,11 +837,19 @@ impl ServerFileBrowserView { .selected_index .and_then(|index| self.entries.get(index)) .map(|entry| entry.path.clone()); - let roots = self.entries.iter().filter(|entry| entry.depth == 0).cloned().collect(); + let roots = self + .entries + .iter() + .filter(|entry| entry.depth == 0) + .cloned() + .collect(); self.entries = rebuild_entries_from(roots, &self.expanded_directories, &self.loaded_directories); - self.selected_index = - selected_index_after_rebuild(&self.entries, selected_path.as_deref(), self.selected_index); + self.selected_index = selected_index_after_rebuild( + &self.entries, + selected_path.as_deref(), + self.selected_index, + ); self.sync_row_states(); } @@ -1098,9 +1113,7 @@ impl ServerFileBrowserView { .into_item(), MenuItemFields::new(crate::t!("server-file-browser-menu-new-folder")) .with_icon(Icon::Folder) - .with_on_select_action(ServerFileBrowserAction::CreateFolder( - upload_target, - )) + .with_on_select_action(ServerFileBrowserAction::CreateFolder(upload_target)) .into_item(), ], ), @@ -1117,12 +1130,12 @@ impl ServerFileBrowserView { context_menu_submenu( crate::t!("server-file-browser-menu-terminal"), Icon::Terminal, - vec![MenuItemFields::new(crate::t!( - "server-file-browser-menu-cd-to-terminal" - )) - .with_icon(Icon::Terminal) - .with_on_select_action(ServerFileBrowserAction::CdToTerminal(cd_target)) - .into_item()], + vec![ + MenuItemFields::new(crate::t!("server-file-browser-menu-cd-to-terminal")) + .with_icon(Icon::Terminal) + .with_on_select_action(ServerFileBrowserAction::CdToTerminal(cd_target)) + .into_item(), + ], ), context_menu_submenu( crate::t!("server-file-browser-menu-other"), @@ -1158,7 +1171,10 @@ impl ServerFileBrowserView { ) { self.dismiss_context_menu(ctx); let Some(client) = self.client(ctx) else { - self.set_listing_error(crate::t!("server-file-browser-create-requires-session"), ctx); + self.set_listing_error( + crate::t!("server-file-browser-create-requires-session"), + ctx, + ); return; }; @@ -1221,7 +1237,12 @@ impl ServerFileBrowserView { let Some(entry) = self.entries.get(index).cloned() else { return; }; - let new_name = self.rename_editor.as_ref(ctx).buffer_text(ctx).trim().to_string(); + let new_name = self + .rename_editor + .as_ref(ctx) + .buffer_text(ctx) + .trim() + .to_string(); self.rename_editor.update(ctx, |editor, ctx| { editor.clear_buffer(ctx); }); @@ -1247,10 +1268,12 @@ impl ServerFileBrowserView { let session = self.session.clone(); let client = self.client(ctx); let remote_session_id = self.remote_session_id(ctx); - let can_rename = session.is_some() - || (client.is_some() && remote_session_id.is_some()); + let can_rename = session.is_some() || (client.is_some() && remote_session_id.is_some()); if !can_rename { - self.set_listing_error(crate::t!("server-file-browser-rename-requires-session"), ctx); + self.set_listing_error( + crate::t!("server-file-browser-rename-requires-session"), + ctx, + ); ctx.focus_self(); return; } @@ -1357,7 +1380,10 @@ impl ServerFileBrowserView { me.delete_entry_confirmed(path.clone(), is_directory, ctx); }, ), - ModalButton::for_view(crate::t!("common-cancel"), |_: &mut ServerFileBrowserView, _| {}), + ModalButton::for_view( + crate::t!("common-cancel"), + |_: &mut ServerFileBrowserView, _| {}, + ), ], |_, _| {}, ); @@ -1398,13 +1424,22 @@ impl ServerFileBrowserView { fn remove_path_from_tree_state(&mut self, path: &str) { let child_prefix = child_path_prefix(path); self.expanded_directories.retain(|key| { - key != path && child_prefix.as_ref().is_none_or(|prefix| !key.starts_with(prefix)) + key != path + && child_prefix + .as_ref() + .is_none_or(|prefix| !key.starts_with(prefix)) }); self.loaded_directories.retain(|key, _| { - key != path && child_prefix.as_ref().is_none_or(|prefix| !key.starts_with(prefix)) + key != path + && child_prefix + .as_ref() + .is_none_or(|prefix| !key.starts_with(prefix)) }); self.row_states.retain(|key, _| { - key != path && child_prefix.as_ref().is_none_or(|prefix| !key.starts_with(prefix)) + key != path + && child_prefix + .as_ref() + .is_none_or(|prefix| !key.starts_with(prefix)) }); } @@ -1782,17 +1817,16 @@ impl ServerFileBrowserView { self.upload_pipeline_claimed = true; let conflict_paths: HashSet = conflicts.iter().map(|c| c.path.clone()).collect(); - let directory_overwrite_roots: HashSet = if conflict_policy - == UploadConflictPolicy::OverwriteAll - { - conflicts - .iter() - .filter(|c| c.kind == FileSystemEntryKind::Directory) - .map(|c| c.path.clone()) - .collect() - } else { - HashSet::new() - }; + let directory_overwrite_roots: HashSet = + if conflict_policy == UploadConflictPolicy::OverwriteAll { + conflicts + .iter() + .filter(|c| c.kind == FileSystemEntryKind::Directory) + .map(|c| c.path.clone()) + .collect() + } else { + HashSet::new() + }; let pending_files = filter_upload_tasks_by_policy(pending_files, conflict_policy, &conflict_paths); @@ -1810,7 +1844,8 @@ impl ServerFileBrowserView { let tasks: Vec = pending_files .into_iter() .map(|file| { - let relative = relative_remote_path_from_base(&remote_directory, &file.final_remote_path); + let relative = + relative_remote_path_from_base(&remote_directory, &file.final_remote_path); let staging_remote_path = if relative.is_empty() { staging_root.clone() } else { @@ -1831,9 +1866,7 @@ impl ServerFileBrowserView { let staging_root_for_spawn = staging_root.clone(); let client_for_mkdir = client.clone(); ctx.spawn( - async move { - create_remote_directory(client_for_mkdir, staging_root_for_spawn).await - }, + async move { create_remote_directory(client_for_mkdir, staging_root_for_spawn).await }, move |me, result, ctx| match result { Ok(()) => { me.start_upload_batch_after_staging_ready( @@ -1897,9 +1930,11 @@ impl ServerFileBrowserView { let Some(batch_index) = self.active_upload_batch_index else { return; }; - if self.upload_batches.get(batch_index).is_some_and(|batch| { - batch.next_task_index >= batch.tasks.len() - }) { + if self + .upload_batches + .get(batch_index) + .is_some_and(|batch| batch.next_task_index >= batch.tasks.len()) + { self.stop_progress_poll(); self.verify_and_promote_batch(client, ctx); return; @@ -1912,7 +1947,9 @@ impl ServerFileBrowserView { .unwrap_or(0); if let Some(batch) = self.upload_batches.get_mut(batch_index) { batch.tasks[index].status = UploadTaskStatus::Uploading; - batch.tasks[index].uploaded_bytes.store(0, Ordering::Relaxed); + batch.tasks[index] + .uploaded_bytes + .store(0, Ordering::Relaxed); batch.next_task_index += 1; } @@ -1960,9 +1997,11 @@ impl ServerFileBrowserView { let Some(batch) = self.active_upload_batch() else { return; }; - if batch.tasks.iter().any(|task| { - matches!(task.status, UploadTaskStatus::Failed(_)) - }) { + if batch + .tasks + .iter() + .any(|task| matches!(task.status, UploadTaskStatus::Failed(_))) + { self.finish_upload_batch_failed(ctx); return; } @@ -1979,12 +2018,7 @@ impl ServerFileBrowserView { .tasks .iter() .filter(|task| matches!(task.status, UploadTaskStatus::Completed)) - .map(|task| { - ( - task.staging_remote_path.clone(), - task.total_bytes, - ) - }) + .map(|task| (task.staging_remote_path.clone(), task.total_bytes)) .collect() }) .unwrap_or_default(); @@ -2001,7 +2035,11 @@ impl ServerFileBrowserView { ); } - fn promote_staging_batch(&mut self, client: Arc, ctx: &mut ViewContext) { + fn promote_staging_batch( + &mut self, + client: Arc, + ctx: &mut ViewContext, + ) { let Some(batch_snapshot) = self.active_upload_batch().map(|batch| { ( batch.staging_root.clone(), @@ -2206,7 +2244,10 @@ impl ServerFileBrowserView { ); }, ), - ModalButton::for_view(crate::t!("common-cancel"), |_: &mut ServerFileBrowserView, _| {}), + ModalButton::for_view( + crate::t!("common-cancel"), + |_: &mut ServerFileBrowserView, _| {}, + ), ], |_, _| {}, ); @@ -2318,7 +2359,9 @@ impl ServerFileBrowserView { fn clear_completed_uploads(&mut self, ctx: &mut ViewContext) { for batch in &mut self.upload_batches { - batch.tasks.retain(|task| !matches!(task.status, UploadTaskStatus::Completed)); + batch + .tasks + .retain(|task| !matches!(task.status, UploadTaskStatus::Completed)); } self.upload_batches.retain(|batch| !batch.tasks.is_empty()); for batch in &mut self.download_batches { @@ -2326,7 +2369,8 @@ impl ServerFileBrowserView { .tasks .retain(|task| !matches!(task.status, DownloadTaskStatus::Completed)); } - self.download_batches.retain(|batch| !batch.tasks.is_empty()); + self.download_batches + .retain(|batch| !batch.tasks.is_empty()); if self.upload_batches.is_empty() { self.active_upload_batch_index = None; } else if let Some(active_index) = self.active_upload_batch_index { @@ -2403,11 +2447,10 @@ impl ServerFileBrowserView { if tasks.is_empty() { return; } - self.download_batches - .push(ServerFileDownloadBatch { - tasks, - next_task_index: 0, - }); + self.download_batches.push(ServerFileDownloadBatch { + tasks, + next_task_index: 0, + }); self.upload_progress_panel_open = true; if !self.has_active_download() { self.active_download_batch_index = Some(self.download_batches.len() - 1); @@ -2420,9 +2463,11 @@ impl ServerFileBrowserView { let Some(batch_index) = self.active_download_batch_index else { return; }; - if self.download_batches.get(batch_index).is_some_and(|batch| { - batch.next_task_index >= batch.tasks.len() - }) { + if self + .download_batches + .get(batch_index) + .is_some_and(|batch| batch.next_task_index >= batch.tasks.len()) + { self.finish_download_batch(ctx); return; } @@ -2434,7 +2479,9 @@ impl ServerFileBrowserView { .unwrap_or(0); if let Some(batch) = self.download_batches.get_mut(batch_index) { batch.tasks[index].status = DownloadTaskStatus::Downloading; - batch.tasks[index].downloaded_bytes.store(0, Ordering::Relaxed); + batch.tasks[index] + .downloaded_bytes + .store(0, Ordering::Relaxed); batch.next_task_index += 1; } @@ -2596,12 +2643,9 @@ impl ServerFileBrowserView { self.set_error(crate::t!("server-file-browser-no-session"), ctx); return; }; - ctx.spawn( - async {}, - move |me, _, ctx| { - me.open_upload_files_picker(client, remote_directory, ctx); - }, - ); + ctx.spawn(async {}, move |me, _, ctx| { + me.open_upload_files_picker(client, remote_directory, ctx); + }); } fn open_upload_files_picker( @@ -2619,11 +2663,7 @@ impl ServerFileBrowserView { let remote_directory_for_handler = remote_directory.clone(); ctx.spawn( async move { - collect_upload_tasks( - local_paths, - remote_directory_for_collect, - false, - ) + collect_upload_tasks(local_paths, remote_directory_for_collect, false) }, move |me, result, ctx| { me.handle_collected_upload_tasks( @@ -2649,12 +2689,9 @@ impl ServerFileBrowserView { self.set_error(crate::t!("server-file-browser-no-session"), ctx); return; }; - ctx.spawn( - async {}, - move |me, _, ctx| { - me.open_upload_folder_picker(client, remote_directory, ctx); - }, - ); + ctx.spawn(async {}, move |me, _, ctx| { + me.open_upload_folder_picker(client, remote_directory, ctx); + }); } fn open_upload_folder_picker( @@ -2672,11 +2709,7 @@ impl ServerFileBrowserView { let remote_directory_for_handler = remote_directory.clone(); ctx.spawn( async move { - collect_upload_tasks( - local_paths, - remote_directory_for_collect, - true, - ) + collect_upload_tasks(local_paths, remote_directory_for_collect, true) }, move |me, result, ctx| { me.handle_collected_upload_tasks( @@ -2698,7 +2731,12 @@ impl ServerFileBrowserView { } fn choose_download_destination(&mut self, remote_path: String, ctx: &mut ViewContext) { - let Some(entry) = self.entries.iter().find(|entry| entry.path == remote_path).cloned() else { + let Some(entry) = self + .entries + .iter() + .find(|entry| entry.path == remote_path) + .cloned() + else { return; }; let Some(client) = self.client(ctx) else { @@ -2709,7 +2747,11 @@ impl ServerFileBrowserView { } fn transfer_overall_summary(&self) -> Option<(usize, usize)> { - let upload_total: usize = self.upload_batches.iter().map(|batch| batch.tasks.len()).sum(); + let upload_total: usize = self + .upload_batches + .iter() + .map(|batch| batch.tasks.len()) + .sum(); let download_total: usize = self .download_batches .iter() @@ -2796,13 +2838,7 @@ impl ServerFileBrowserView { .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - Shrinkable::new( - 1.0, - Clipped::new(title).finish(), - ) - .finish(), - ); + .with_child(Shrinkable::new(1.0, Clipped::new(title).finish()).finish()); if self.has_completed_upload_tasks() || self.has_completed_download_tasks() { let clear_label = crate::t!("server-file-browser-upload-clear-completed"); @@ -2829,7 +2865,11 @@ impl ServerFileBrowserView { column.add_child( Container::new( Text::new_inline( - crate::t!("server-file-browser-transfer-overall", done = done, total = total), + crate::t!( + "server-file-browser-transfer-overall", + done = done, + total = total + ), appearance.ui_font_family(), 11.0, ) @@ -2919,7 +2959,9 @@ impl ServerFileBrowserView { let panel_body = Container::new(column.finish()) .with_uniform_padding(12.0) - .with_background(warpui::elements::Fill::Solid(theme.surface_2().into_solid())) + .with_background(warpui::elements::Fill::Solid( + theme.surface_2().into_solid(), + )) .with_border(Border::all(1.0).with_border_color(theme.outline().into())) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.0))) .finish(); @@ -2935,53 +2977,57 @@ impl ServerFileBrowserView { fn render_toolbar(&self, appearance: &crate::appearance::Appearance) -> Box { let theme = appearance.theme(); let icon_color = theme.sub_text_color(theme.background()); - let make_btn = - |icon: Icon, state: MouseStateHandle, action: ServerFileBrowserAction| -> Box { - let icon_el = ConstrainedBox::new(icon.to_warpui_icon(icon_color).finish()) - .with_width(TOOLBAR_ICON_SIZE) - .with_height(TOOLBAR_ICON_SIZE) - .finish(); - Hoverable::new(state, move |_| { - Container::new( - ConstrainedBox::new(icon_el) - .with_width(TOOLBAR_BUTTON_SIZE) - .with_height(TOOLBAR_BUTTON_SIZE) - .finish(), - ) - .with_uniform_padding(2.0) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))) - .finish() - }) - .with_cursor(Cursor::PointingHand) - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(action.clone()); - }) + let make_btn = |icon: Icon, + state: MouseStateHandle, + action: ServerFileBrowserAction| + -> Box { + let icon_el = ConstrainedBox::new(icon.to_warpui_icon(icon_color).finish()) + .with_width(TOOLBAR_ICON_SIZE) + .with_height(TOOLBAR_ICON_SIZE) + .finish(); + Hoverable::new(state, move |_| { + Container::new( + ConstrainedBox::new(icon_el) + .with_width(TOOLBAR_BUTTON_SIZE) + .with_height(TOOLBAR_BUTTON_SIZE) + .finish(), + ) + .with_uniform_padding(2.0) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))) .finish() - }; + }) + .with_cursor(Cursor::PointingHand) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(action.clone()); + }) + .finish() + }; Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(6.0) - .with_child(Shrinkable::new( - 1.0, - appearance - .ui_builder() - .text_input(self.path_editor.clone()) - .with_style(UiComponentStyles { - height: Some(INPUT_HEIGHT), - padding: Some(Coords::uniform(6.0)), - background: Some(theme.surface_2().into()), - border_color: Some(theme.nonactive_ui_detail().into()), - border_width: Some(1.0), - border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.0))), - font_size: Some(ITEM_FONT_SIZE), - ..Default::default() - }) - .build() - .finish(), + .with_child( + Shrinkable::new( + 1.0, + appearance + .ui_builder() + .text_input(self.path_editor.clone()) + .with_style(UiComponentStyles { + height: Some(INPUT_HEIGHT), + padding: Some(Coords::uniform(6.0)), + background: Some(theme.surface_2().into()), + border_color: Some(theme.nonactive_ui_detail().into()), + border_width: Some(1.0), + border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.0))), + font_size: Some(ITEM_FONT_SIZE), + ..Default::default() + }) + .build() + .finish(), + ) + .finish(), ) - .finish()) .with_child(make_btn( Icon::Refresh, self.refresh_button.clone(), @@ -3001,10 +3047,15 @@ impl ServerFileBrowserView { .finish() } - fn render_entries(&self, app: &AppContext, appearance: &crate::appearance::Appearance) -> Box { + fn render_entries( + &self, + app: &AppContext, + appearance: &crate::appearance::Appearance, + ) -> Box { let theme = appearance.theme(); if self.host_id.is_none() { - return self.render_status_text(crate::t!("server-file-browser-no-session"), appearance); + return self + .render_status_text(crate::t!("server-file-browser-no-session"), appearance); } else if self.loading && self.entries.is_empty() { return self.render_status_text(crate::t!("server-file-browser-loading"), appearance); } else if self.entries.is_empty() { @@ -3017,7 +3068,8 @@ impl ServerFileBrowserView { if let Some(status) = &self.status { return self.render_status_text(status.clone(), appearance); } - return self.render_status_text(crate::t!("server-file-browser-empty-directory"), appearance); + return self + .render_status_text(crate::t!("server-file-browser-empty-directory"), appearance); } let entries = self.entries.clone(); @@ -3026,18 +3078,13 @@ impl ServerFileBrowserView { let row_states = self.row_states.clone(); let pending_rename_index = self.pending_rename_index; let rename_editor = self.rename_editor.clone(); - let uniform_list = UniformList::new( - self.list_state.clone(), - entries.len(), - move |range, app| { + let uniform_list = + UniformList::new(self.list_state.clone(), entries.len(), move |range, app| { let appearance = crate::appearance::Appearance::as_ref(app); range .filter_map(|index| { let entry = entries.get(index)?; - let state = row_states - .get(&entry.path) - .cloned() - .unwrap_or_default(); + let state = row_states.get(&entry.path).cloned().unwrap_or_default(); Some(render_entry_row( index, entry, @@ -3051,9 +3098,8 @@ impl ServerFileBrowserView { }) .collect::>() .into_iter() - }, - ) - .finish_scrollable(); + }) + .finish_scrollable(); let scrollable = Shrinkable::new( 1.0, @@ -3113,7 +3159,6 @@ impl ServerFileBrowserView { .with_padding_right(ITEM_PADDING_HORIZONTAL) .finish() } - } #[allow(clippy::too_many_arguments)] @@ -3146,7 +3191,11 @@ fn render_entry_row( .with_width(ITEM_ICON_SIZE) .finish() }; - let icon = if is_directory { Icon::Folder } else { Icon::File }; + let icon = if is_directory { + Icon::Folder + } else { + Icon::File + }; let icon_el = ConstrainedBox::new(icon.to_warpui_icon(icon_color).finish()) .with_width(ITEM_ICON_SIZE) .with_height(ITEM_ICON_SIZE) @@ -3180,9 +3229,13 @@ fn render_entry_row( } } let metadata = (!metadata_parts.is_empty()).then(|| { - Text::new_inline(metadata_parts.join(" · "), appearance.ui_font_family(), 11.0) - .with_color(theme.sub_text_color(theme.background()).into()) - .finish() + Text::new_inline( + metadata_parts.join(" · "), + appearance.ui_font_family(), + 11.0, + ) + .with_color(theme.sub_text_color(theme.background()).into()) + .finish() }); let mut col = Flex::column() @@ -3264,7 +3317,9 @@ impl TypedActionView for ServerFileBrowserView { ctx.focus_self(); self.open_index(*index, ctx); } - ServerFileBrowserAction::ToggleDirectory(path) => self.toggle_directory(path.clone(), ctx), + ServerFileBrowserAction::ToggleDirectory(path) => { + self.toggle_directory(path.clone(), ctx) + } ServerFileBrowserAction::SelectPreviousItem => self.select_previous_item(ctx), ServerFileBrowserAction::SelectNextItem => self.select_next_item(ctx), ServerFileBrowserAction::ExpandSelectedItem => self.expand_selected_item(ctx), @@ -3278,9 +3333,7 @@ impl TypedActionView for ServerFileBrowserView { ServerFileBrowserAction::CopyName(name) => self.copy_name(name.clone(), ctx), ServerFileBrowserAction::CdToTerminal(path) => { self.dismiss_context_menu(ctx); - ctx.emit(ServerFileBrowserEvent::CdToDirectory { - path: path.clone(), - }); + ctx.emit(ServerFileBrowserEvent::CdToDirectory { path: path.clone() }); } ServerFileBrowserAction::Download(path) => { self.dismiss_context_menu(ctx); @@ -3450,7 +3503,12 @@ fn rebuild_entries_from( .filter(|entry| entry.depth == 0) .collect(); let mut rebuilt = Vec::new(); - append_entries_from(roots, expanded_directories, loaded_directories, &mut rebuilt); + append_entries_from( + roots, + expanded_directories, + loaded_directories, + &mut rebuilt, + ); rebuilt } @@ -3553,8 +3611,7 @@ async fn reload_directory_tree( expanded_directories: HashSet, depth_by_path: HashMap, ) -> Result { - let (current_path, root_entries) = - list_directory_with_source(&source, current_path).await?; + let (current_path, root_entries) = list_directory_with_source(&source, current_path).await?; let mut loaded_directories = HashMap::new(); let mut still_expanded = HashSet::new(); @@ -3563,10 +3620,7 @@ async fn reload_directory_tree( match list_directory_with_source(&source, directory_path.clone()).await { Ok((canonical_path, entries)) => { still_expanded.insert(canonical_path.clone()); - loaded_directories.insert( - canonical_path, - entries_with_depth(entries, depth), - ); + loaded_directories.insert(canonical_path, entries_with_depth(entries, depth)); } Err(_) => { // The folder may have been removed or is no longer accessible. @@ -3586,11 +3640,14 @@ async fn resolve_path( client: Arc, path: String, ) -> Result { - let response = client.resolve_path(path).await.map_err(|error| error.to_string())?; + let response = client + .resolve_path(path) + .await + .map_err(|error| error.to_string())?; match response.result { Some(resolve_path_response::Result::Success(success)) => { - let kind = FileSystemEntryKind::try_from(success.kind) - .unwrap_or(FileSystemEntryKind::Other); + let kind = + FileSystemEntryKind::try_from(success.kind).unwrap_or(FileSystemEntryKind::Other); Ok(ResolvedRemotePath { canonical_path: success.canonical_path, kind, @@ -3605,7 +3662,10 @@ async fn list_directory( client: Arc, path: String, ) -> Result<(String, Vec), String> { - let response = client.list_directory(path).await.map_err(|error| error.to_string())?; + let response = client + .list_directory(path) + .await + .map_err(|error| error.to_string())?; match response.result { Some(list_directory_response::Result::Success(success)) => { let canonical_path = success.canonical_path; @@ -3725,9 +3785,7 @@ async fn list_directory_via_session( entries.sort_by(|a, b| { let a_is_dir = a.kind == FileSystemEntryKind::Directory; let b_is_dir = b.kind == FileSystemEntryKind::Directory; - b_is_dir - .cmp(&a_is_dir) - .then_with(|| a.name.cmp(&b.name)) + b_is_dir.cmp(&a_is_dir).then_with(|| a.name.cmp(&b.name)) }); Ok((canonical_path, entries)) } @@ -3808,9 +3866,7 @@ fn collect_upload_tasks( } else { relative_str }; - let total_bytes = std::fs::metadata(path) - .map(|meta| meta.len()) - .unwrap_or(0); + let total_bytes = std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0); files.push(PendingUploadFile { local_path: path.to_path_buf(), final_remote_path, @@ -3820,7 +3876,9 @@ fn collect_upload_tasks( } } } else if local_path.is_file() { - let Some(name) = local_path.file_name().map(|name| name.to_string_lossy().to_string()) + let Some(name) = local_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) else { continue; }; @@ -3989,11 +4047,17 @@ fn download_task_status_label(task: &ServerFileDownloadTask) -> String { DownloadTaskStatus::Pending => crate::t!("server-file-browser-download-status-pending"), DownloadTaskStatus::Downloading => { let percent = (download_task_progress(task) * 100.0).round() as u32; - crate::t!("server-file-browser-download-status-downloading", percent = percent) + crate::t!( + "server-file-browser-download-status-downloading", + percent = percent + ) } DownloadTaskStatus::Completed => crate::t!("server-file-browser-download-status-completed"), DownloadTaskStatus::Failed(error) => { - crate::t!("server-file-browser-download-status-failed", error = error.clone()) + crate::t!( + "server-file-browser-download-status-failed", + error = error.clone() + ) } } } @@ -4006,11 +4070,8 @@ fn render_transfer_task_row( ) -> Box { let theme = appearance.theme(); let sub_text = theme.sub_text_color(theme.background()); - let progress_bar = render_flex_progress_bar( - progress, - theme.accent().into(), - theme.background().into(), - ); + let progress_bar = + render_flex_progress_bar(progress, theme.accent().into(), theme.background().into()); Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child( @@ -4030,11 +4091,7 @@ fn render_transfer_task_row( .with_padding_top(2.0) .finish(), ) - .with_child( - Container::new(progress_bar) - .with_padding_top(4.0) - .finish(), - ) + .with_child(Container::new(progress_bar).with_padding_top(4.0).finish()) .finish() } @@ -4055,7 +4112,11 @@ fn upload_task_progress(task: &ServerFileUploadTask) -> f32 { } } -fn render_flex_progress_bar(progress: f32, accent: pathfinder_color::ColorU, track: pathfinder_color::ColorU) -> Box { +fn render_flex_progress_bar( + progress: f32, + accent: pathfinder_color::ColorU, + track: pathfinder_color::ColorU, +) -> Box { let progress = progress.clamp(0.0, 1.0); let filled_weight = progress.max(0.001); let empty_weight = (1.0 - progress).max(0.001); @@ -4099,15 +4160,22 @@ fn upload_batch_phase_label(phase: UploadBatchPhase) -> String { } fn upload_task_status_label(task: &ServerFileUploadTask, batch_phase: UploadBatchPhase) -> String { - if matches!(task.status, UploadTaskStatus::Completed | UploadTaskStatus::Failed(_)) { + if matches!( + task.status, + UploadTaskStatus::Completed | UploadTaskStatus::Failed(_) + ) { match &task.status { UploadTaskStatus::Completed => { return crate::t!("server-file-browser-upload-status-completed"); } UploadTaskStatus::Failed(error) => { - return crate::t!("server-file-browser-upload-status-failed", error = error.clone()); + return crate::t!( + "server-file-browser-upload-status-failed", + error = error.clone() + ); + } + UploadTaskStatus::Pending | UploadTaskStatus::Uploading | UploadTaskStatus::Skipped => { } - UploadTaskStatus::Pending | UploadTaskStatus::Uploading | UploadTaskStatus::Skipped => {} } } match batch_phase { @@ -4123,11 +4191,17 @@ fn upload_task_status_label(task: &ServerFileUploadTask, batch_phase: UploadBatc UploadTaskStatus::Pending => crate::t!("server-file-browser-upload-status-pending"), UploadTaskStatus::Uploading => { let percent = (upload_task_progress(task) * 100.0).round() as u32; - crate::t!("server-file-browser-upload-status-uploading", percent = percent) + crate::t!( + "server-file-browser-upload-status-uploading", + percent = percent + ) } UploadTaskStatus::Completed => crate::t!("server-file-browser-upload-status-completed"), UploadTaskStatus::Failed(error) => { - crate::t!("server-file-browser-upload-status-failed", error = error.clone()) + crate::t!( + "server-file-browser-upload-status-failed", + error = error.clone() + ) } UploadTaskStatus::Skipped => crate::t!("server-file-browser-upload-status-skipped"), } @@ -4150,8 +4224,7 @@ fn path_is_under_conflict(path: &str, conflict_path: &str) -> bool { if path == conflict_path { return true; } - child_path_prefix(conflict_path) - .is_some_and(|prefix| path.starts_with(&prefix)) + child_path_prefix(conflict_path).is_some_and(|prefix| path.starts_with(&prefix)) } fn filter_upload_tasks_by_policy( @@ -4165,9 +4238,9 @@ fn filter_upload_tasks_by_policy( files .into_iter() .filter(|file| { - !conflict_paths.iter().any(|conflict| { - path_is_under_conflict(&file.final_remote_path, conflict) - }) + !conflict_paths + .iter() + .any(|conflict| path_is_under_conflict(&file.final_remote_path, conflict)) }) .collect() } @@ -4235,7 +4308,10 @@ fn format_upload_promote_error(error: &str) -> String { .nth(1) .filter(|segment| segment.starts_with('/')) { - return crate::t!("server-file-browser-upload-promote-not-replacing", path = path); + return crate::t!( + "server-file-browser-upload-promote-not-replacing", + path = path + ); } return crate::t!("server-file-browser-upload-promote-not-replacing-generic"); } @@ -4320,7 +4396,9 @@ fn staging_cleanup_shell_commands(staging_root: &str) -> String { if let Some(staging_parent) = remote_parent(staging_root) { let escaped_staging_parent = warp_util::path::ShellFamily::Posix.shell_escape(&staging_parent); - commands.push(format!("rmdir -- {escaped_staging_parent} 2>/dev/null || true")); + commands.push(format!( + "rmdir -- {escaped_staging_parent} 2>/dev/null || true" + )); } commands.join("; ") } @@ -4343,9 +4421,8 @@ async fn promote_staging_files( promote_pairs: Vec<(String, String)>, ) -> Result<(), String> { let mut script_lines = Vec::new(); - let cleanup_trap_body = escape_for_single_quoted_trap_body(&staging_cleanup_shell_commands( - &staging_root, - )); + let cleanup_trap_body = + escape_for_single_quoted_trap_body(&staging_cleanup_shell_commands(&staging_root)); script_lines.push(format!("trap '{cleanup_trap_body}' EXIT")); script_lines.push("set -e".to_string()); if conflict_policy == UploadConflictPolicy::OverwriteAll { @@ -4369,9 +4446,7 @@ async fn promote_staging_files( let escaped_staging = warp_util::path::ShellFamily::Posix.shell_escape(&staging_path); let escaped_final = warp_util::path::ShellFamily::Posix.shell_escape(&final_path); script_lines.push(format!("mkdir -p -- {escaped_parent}")); - script_lines.push(format!( - "mv {mv_flag} -- {escaped_staging} {escaped_final}" - )); + script_lines.push(format!("mv {mv_flag} -- {escaped_staging} {escaped_final}")); } let script = script_lines.join("\n"); execute_remote_shell_script(session, Some(client), remote_session_id, script).await @@ -4537,7 +4612,10 @@ async fn rename_remote_path( new_name: String, ) -> Result<(), String> { let parent = remote_parent(&from_path).ok_or_else(|| { - crate::t!("server-file-browser-operation-failed", error = "missing parent path") + crate::t!( + "server-file-browser-operation-failed", + error = "missing parent path" + ) })?; let new_path = join_remote_path(&parent, &new_name); let escaped_from = warp_util::path::ShellFamily::Posix.shell_escape(&from_path); @@ -4688,7 +4766,12 @@ fn remote_basename(path: &str) -> Option { Path::new(path) .file_name() .map(|name| name.to_string_lossy().to_string()) - .or_else(|| path.trim_end_matches('/').rsplit('/').next().map(str::to_string)) + .or_else(|| { + path.trim_end_matches('/') + .rsplit('/') + .next() + .map(str::to_string) + }) } fn format_modified_epoch_millis(epoch_millis: u64) -> Option { @@ -4773,11 +4856,7 @@ mod tests { #[test] fn remap_path_after_rename_updates_directory_subtree() { assert_eq!( - remap_path_after_rename( - "/root/test/old", - "/root/test/old", - "/root/test/new" - ), + remap_path_after_rename("/root/test/old", "/root/test/old", "/root/test/new"), "/root/test/new" ); assert_eq!( @@ -4875,7 +4954,12 @@ mod tests { #[test] fn selected_index_preserves_matching_path_after_rebuild() { let entries = vec![ - entry("/root/.openwarp", ".openwarp", FileSystemEntryKind::Directory, 0), + entry( + "/root/.openwarp", + ".openwarp", + FileSystemEntryKind::Directory, + 0, + ), entry( "/root/.openwarp/remote-server", "remote-server", @@ -4914,7 +4998,12 @@ mod tests { fn next_available_entry_name_appends_suffix_to_avoid_sibling_conflicts() { let entries = vec![ entry("/root/untitled", "untitled", FileSystemEntryKind::File, 0), - entry("/root/untitled 2", "untitled 2", FileSystemEntryKind::File, 0), + entry( + "/root/untitled 2", + "untitled 2", + FileSystemEntryKind::File, + 0, + ), entry( "/root/untitled folder", "untitled folder", @@ -4923,7 +5012,10 @@ mod tests { ), ]; - assert_eq!(next_available_entry_name("untitled", &entries), "untitled 3"); + assert_eq!( + next_available_entry_name("untitled", &entries), + "untitled 3" + ); assert_eq!( next_available_entry_name("untitled folder", &entries), "untitled folder 2" @@ -4958,11 +5050,9 @@ mod tests { #[test] fn clear_context_menu_state_removes_items_and_selection() { let mut position = Some(vec2f(10.0, 20.0)); - let mut menu_items = vec![ - MenuItemFields::new("Refresh") - .with_on_select_action(ServerFileBrowserAction::Refresh) - .into_item(), - ]; + let mut menu_items = vec![MenuItemFields::new("Refresh") + .with_on_select_action(ServerFileBrowserAction::Refresh) + .into_item()]; clear_context_menu_state(&mut position, &mut menu_items); @@ -4980,11 +5070,7 @@ mod tests { )]; assert_eq!( - selected_index_after_rebuild( - &entries, - Some("/root/.openwarp/remote-server"), - Some(4), - ), + selected_index_after_rebuild(&entries, Some("/root/.openwarp/remote-server"), Some(4),), Some(0) ); } diff --git a/app/src/workspace/view/wasm_view.rs b/app/src/workspace/view/wasm_view.rs index a07405e01a2..7a5cef44752 100644 --- a/app/src/workspace/view/wasm_view.rs +++ b/app/src/workspace/view/wasm_view.rs @@ -16,7 +16,7 @@ use crate::ui_components::icons; use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, PrimaryTheme}; use crate::wasm_nux_dialog::{WasmNUXDialog, WasmNUXDialogEvent}; use crate::workspace::action::WorkspaceAction; -use crate::workspace::view::{NotebookSource, ZapDriveObjectSettings, Workspace}; +use crate::workspace::view::{NotebookSource, Workspace, ZapDriveObjectSettings}; use crate::BlocklistAIHistoryModel; const TRANSCRIPT_PANEL_WIDTH: f32 = 280.0; diff --git a/app/src/workspace/view_test.rs b/app/src/workspace/view_test.rs index f9b8af239db..fb665aeb981 100644 --- a/app/src/workspace/view_test.rs +++ b/app/src/workspace/view_test.rs @@ -18,6 +18,7 @@ use crate::network::NetworkStatus; use crate::notebooks::editor::keys::NotebookKeybindings; use crate::notebooks::notebook::NotebookView; use crate::pane_group::{Direction, PaneGroupAction, PaneId}; +use crate::persistence::RepositoryPersistence; use crate::pricing::PricingInfoModel; use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::terminal::shared_session::protocol::SessionSourceType; @@ -186,6 +187,20 @@ fn initialize_app(app: &mut App) { app.update(workspace::init); } +#[test] +fn existing_worktree_source_never_requests_git_cleanup() { + assert!(!source_creates_worktree( + &CreateWorkspaceSource::ExistingWorktree { + local_branch: "feature/adopt".to_string(), + } + )); + assert!(source_creates_worktree( + &CreateWorkspaceSource::ExistingLocalBranch { + local_branch: "feature/create".to_string(), + } + )); +} + fn mock_workspace(app: &mut App) -> ViewHandle { let global_resource_handles = GlobalResourceHandles::mock(app); let active_window_id = app.read(|ctx| ctx.windows().active_window()); @@ -203,6 +218,25 @@ fn mock_workspace(app: &mut App) -> ViewHandle { workspace } +#[test] +fn repository_open_preflight_rejects_missing_path() { + App::test((), |mut app| async move { + app.add_singleton_model(|ctx| { + ProjectOrganizationModel::try_new(vec![], vec![], RepositoryPersistence::new(None), ctx) + .expect("empty project organization model should initialize") + }); + let tempdir = tempfile::tempdir().expect("temporary directory should be created"); + let missing_path = tempdir.path().join("missing-repository"); + + let result = app.update(|ctx| Workspace::touch_repository_for_open(&missing_path, ctx)); + + assert!(matches!( + result, + Err(ProjectOrganizationError::InvalidPath { path, .. }) if path == missing_path + )); + }); +} + fn restored_workspace( app: &mut App, window_snapshot: crate::app_state::WindowSnapshot, diff --git a/crates/channel_versions/src/lib.rs b/crates/channel_versions/src/lib.rs index 2377ebe82bc..0fada66ccd8 100644 --- a/crates/channel_versions/src/lib.rs +++ b/crates/channel_versions/src/lib.rs @@ -85,8 +85,7 @@ fn parse_oss(value: &str) -> Option { let year: i32 = captures.get(1)?.as_str().parse().ok()?; let month: u32 = captures.get(2)?.as_str().parse().ok()?; let day: u32 = captures.get(3)?.as_str().parse().ok()?; - let date = chrono::NaiveDate::from_ymd_opt(year, month, day)? - .and_hms_opt(0, 0, 0)?; + let date = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(0, 0, 0)?; let patch: usize = captures .get(4) .and_then(|m| m.as_str().parse::().ok()) diff --git a/crates/integration/src/test.rs b/crates/integration/src/test.rs index 66ecd916092..9d9e0e8b850 100644 --- a/crates/integration/src/test.rs +++ b/crates/integration/src/test.rs @@ -20,12 +20,12 @@ mod pane_restoration; mod preview_config_migration; mod rules; mod secrets; -mod sftp_browser; mod session_restoration; mod settings_file_errors; mod settings_file_hot_reload; mod settings_file_migration; mod settings_private; +mod sftp_browser; mod ssh; mod ssh_manager_ui; mod subshell; @@ -54,12 +54,12 @@ pub use pane_restoration::*; pub use preview_config_migration::*; pub use rules::*; pub use secrets::*; -pub use sftp_browser::*; pub use session_restoration::*; pub use settings_file_errors::*; pub use settings_file_hot_reload::*; pub use settings_file_migration::*; pub use settings_private::*; +pub use sftp_browser::*; pub use ssh::*; pub use ssh_manager_ui::*; pub use subshell::*; diff --git a/crates/integration/src/test/sftp_browser.rs b/crates/integration/src/test/sftp_browser.rs index 7aed8d6c7f2..9ebe23874c5 100644 --- a/crates/integration/src/test/sftp_browser.rs +++ b/crates/integration/src/test/sftp_browser.rs @@ -11,7 +11,10 @@ use warp::integration_testing::sftp; use warp::integration_testing::sftp::{ConnectionState, Dialog, SftpBrowserAction}; use warp::integration_testing::terminal::wait_until_bootstrapped_single_pane_for_tab; use warp::integration_testing::view_getters::{pane_group_view, workspace_view}; -use warpui::{async_assert, async_assert_eq, integration::AssertionCallback, integration::StepDataMap, integration::TestStep, TypedActionView}; +use warpui::{ + async_assert, async_assert_eq, integration::AssertionCallback, integration::StepDataMap, + integration::TestStep, TypedActionView, +}; use super::{new_builder, Builder}; @@ -94,17 +97,16 @@ pub fn test_sftp_pane_focus_and_keyboard() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify pane still exists") - .add_assertion(|app, window_id| { - let pane_group = pane_group_view(app, window_id, 0); - pane_group.read(app, |pane_group, _ctx| { - async_assert_eq!( - pane_group.pane_count(), - 2, - "SFTP pane should still be visible" - ) - }) - }), + TestStep::new("Verify pane still exists").add_assertion(|app, window_id| { + let pane_group = pane_group_view(app, window_id, 0); + pane_group.read(app, |pane_group, _ctx| { + async_assert_eq!( + pane_group.pane_count(), + 2, + "SFTP pane should still be visible" + ) + }) + }), ) } @@ -122,17 +124,12 @@ pub fn test_sftp_pane_close() -> Builder { .set_post_step_pause(std::time::Duration::from_secs(2)), ) .with_step( - TestStep::new("Verify 2 panes") - .add_assertion(|app, window_id| { - let pane_group = pane_group_view(app, window_id, 0); - pane_group.read(app, |pane_group, _ctx| { - async_assert_eq!( - pane_group.pane_count(), - 2, - "Should have 2 panes" - ) - }) - }), + TestStep::new("Verify 2 panes").add_assertion(|app, window_id| { + let pane_group = pane_group_view(app, window_id, 0); + pane_group.read(app, |pane_group, _ctx| { + async_assert_eq!(pane_group.pane_count(), 2, "Should have 2 panes") + }) + }), ) // 遍历所有可见面板,找到非 terminal 面板(即 SFTP)并关闭 .with_step( @@ -154,17 +151,16 @@ pub fn test_sftp_pane_close() -> Builder { .set_post_step_pause(std::time::Duration::from_secs(1)), ) .with_step( - TestStep::new("Verify back to single pane") - .add_assertion(|app, window_id| { - let pane_group = pane_group_view(app, window_id, 0); - pane_group.read(app, |pane_group, _ctx| { - async_assert_eq!( - pane_group.visible_pane_count(), - 1, - "Should have 1 visible pane after closing SFTP" - ) - }) - }), + TestStep::new("Verify back to single pane").add_assertion(|app, window_id| { + let pane_group = pane_group_view(app, window_id, 0); + pane_group.read(app, |pane_group, _ctx| { + async_assert_eq!( + pane_group.visible_pane_count(), + 1, + "Should have 1 visible pane after closing SFTP" + ) + }) + }), ) } @@ -193,16 +189,12 @@ pub fn test_sftp_pane_tab_switch() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify SFTP pane still visible") - .add_assertion(|app, window_id| { - let pane_group = pane_group_view(app, window_id, 0); - pane_group.read(app, |pane_group, _ctx| { - async_assert!( - pane_group.pane_count() >= 1, - "Should have at least 1 pane" - ) - }) - }), + TestStep::new("Verify SFTP pane still visible").add_assertion(|app, window_id| { + let pane_group = pane_group_view(app, window_id, 0); + pane_group.read(app, |pane_group, _ctx| { + async_assert!(pane_group.pane_count() >= 1, "Should have at least 1 pane") + }) + }), ) } @@ -299,26 +291,21 @@ pub fn test_sftp_toolbar_refresh() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("file1.txt", b"content1"), - ])) + .with_step(open_sftp_with_mock_step(&[("file1.txt", b"content1")])) .with_step( TestStep::new("Click refresh button") .with_click_on_saved_position("sftp_btn:refresh") .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify entries still present after refresh") - .add_assertion(|app, window_id| { + TestStep::new("Verify entries still present after refresh").add_assertion( + |app, window_id| { let view = sftp::sftp_browser_view(app, window_id); view.read(app, |v, _| { - async_assert_eq!( - v.entries().len(), - 1, - "刷新后条目应仍存在" - ) + async_assert_eq!(v.entries().len(), 1, "刷新后条目应仍存在") }) - }), + }, + ), ) } @@ -337,16 +324,15 @@ pub fn test_sftp_toolbar_new_folder() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify CreateFolder dialog is open") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - matches!(v.dialog(), Some(Dialog::CreateFolder { .. })), - "应打开新建文件夹对话框" - ) - }) - }), + TestStep::new("Verify CreateFolder dialog is open").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!( + matches!(v.dialog(), Some(Dialog::CreateFolder { .. })), + "应打开新建文件夹对话框" + ) + }) + }), ) } @@ -365,8 +351,8 @@ pub fn test_sftp_toolbar_upload() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify view still stable after upload click") - .add_assertion(|app, window_id| { + TestStep::new("Verify view still stable after upload click").add_assertion( + |app, window_id| { let view = sftp::sftp_browser_view(app, window_id); view.read(app, |v, _| { async_assert!( @@ -374,7 +360,8 @@ pub fn test_sftp_toolbar_upload() -> Builder { "点击上传后应仍为 Connected" ) }) - }), + }, + ), ) } @@ -386,18 +373,19 @@ pub fn test_sftp_toolbar_up() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("subdir/file.txt", b"content"), - ])) + .with_step(open_sftp_with_mock_step(&[("subdir/file.txt", b"content")])) // 进入子目录 .with_step( TestStep::new("Enter subdirectory") .with_action(|app, window_id, _| { let view = sftp::sftp_browser_view(app, window_id); view.update(app, |v, ctx| { - v.handle_action(&SftpBrowserAction::OpenEntry( - v.entries().iter().position(|e| e.name == "subdir").unwrap(), - ), ctx); + v.handle_action( + &SftpBrowserAction::OpenEntry( + v.entries().iter().position(|e| e.name == "subdir").unwrap(), + ), + ctx, + ); }); }) .set_post_step_pause(std::time::Duration::from_millis(500)), @@ -409,16 +397,15 @@ pub fn test_sftp_toolbar_up() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify navigated back to root") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - v.entries().iter().any(|e| e.name == "subdir"), - "回到上级后应看到 subdir 目录" - ) - }) - }), + TestStep::new("Verify navigated back to root").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!( + v.entries().iter().any(|e| e.name == "subdir"), + "回到上级后应看到 subdir 目录" + ) + }) + }), ) } @@ -440,16 +427,12 @@ pub fn test_sftp_click_file_row_selects() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(300)), ) .with_step( - TestStep::new("Verify file is selected") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - v.selected().contains(&0), - "第一个文件应被选中" - ) - }) - }), + TestStep::new("Verify file is selected").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!(v.selected().contains(&0), "第一个文件应被选中") + }) + }), ) } @@ -461,25 +444,19 @@ pub fn test_sftp_right_click_opens_menu() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("menu_file.txt", b"content"), - ])) + .with_step(open_sftp_with_mock_step(&[("menu_file.txt", b"content")])) .with_step( TestStep::new("Right-click on file row") .with_right_click_on_saved_position("sftp_row:0") .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify context menu is open") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - v.context_menu().is_some(), - "右键菜单应已打开" - ) - }) - }), + TestStep::new("Verify context menu is open").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!(v.context_menu().is_some(), "右键菜单应已打开") + }) + }), ) } @@ -491,9 +468,7 @@ pub fn test_sftp_ctx_menu_delete() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("to_delete.txt", b"delete me"), - ])) + .with_step(open_sftp_with_mock_step(&[("to_delete.txt", b"delete me")])) // 右键打开菜单 .with_step( TestStep::new("Right-click on file") @@ -508,16 +483,15 @@ pub fn test_sftp_ctx_menu_delete() -> Builder { ) // 验证删除确认对话框 .with_step( - TestStep::new("Verify delete confirm dialog") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - matches!(v.dialog(), Some(Dialog::DeleteConfirm { .. })), - "应打开删除确认对话框" - ) - }) - }), + TestStep::new("Verify delete confirm dialog").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!( + matches!(v.dialog(), Some(Dialog::DeleteConfirm { .. })), + "应打开删除确认对话框" + ) + }) + }), ) // 点击确认 .with_step( @@ -527,17 +501,12 @@ pub fn test_sftp_ctx_menu_delete() -> Builder { ) // 验证条目已删除 .with_step( - TestStep::new("Verify file deleted") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert_eq!( - v.entries().len(), - 0, - "删除后应无条目" - ) - }) - }), + TestStep::new("Verify file deleted").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert_eq!(v.entries().len(), 0, "删除后应无条目") + }) + }), ) } @@ -549,9 +518,7 @@ pub fn test_sftp_ctx_menu_rename() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("old_name.txt", b"content"), - ])) + .with_step(open_sftp_with_mock_step(&[("old_name.txt", b"content")])) .with_step( TestStep::new("Right-click on file") .with_right_click_on_saved_position("sftp_row:0") @@ -563,16 +530,15 @@ pub fn test_sftp_ctx_menu_rename() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify rename dialog is open") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - matches!(v.dialog(), Some(Dialog::Rename { .. })), - "应打开重命名对话框" - ) - }) - }), + TestStep::new("Verify rename dialog is open").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!( + matches!(v.dialog(), Some(Dialog::Rename { .. })), + "应打开重命名对话框" + ) + }) + }), ) } @@ -584,9 +550,7 @@ pub fn test_sftp_breadcrumb_root_click() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("subdir/file.txt", b"content"), - ])) + .with_step(open_sftp_with_mock_step(&[("subdir/file.txt", b"content")])) // 进入子目录 .with_step( TestStep::new("Enter subdirectory") @@ -605,22 +569,24 @@ pub fn test_sftp_breadcrumb_root_click() -> Builder { .with_action(|app, window_id, _| { let view = sftp::sftp_browser_view(app, window_id); view.update(app, |v, ctx| { - v.handle_action(&SftpBrowserAction::NavigateTo(std::path::PathBuf::from("/")), ctx); + v.handle_action( + &SftpBrowserAction::NavigateTo(std::path::PathBuf::from("/")), + ctx, + ); }); }) .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify navigated to root") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - v.entries().iter().any(|e| e.name == "subdir"), - "回到根目录后应看到 subdir" - ) - }) - }), + TestStep::new("Verify navigated to root").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!( + v.entries().iter().any(|e| e.name == "subdir"), + "回到根目录后应看到 subdir" + ) + }) + }), ) } @@ -632,9 +598,7 @@ pub fn test_sftp_keyboard_backspace_up() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("subdir/file.txt", b"x"), - ])) + .with_step(open_sftp_with_mock_step(&[("subdir/file.txt", b"x")])) // 进入子目录 .with_step( TestStep::new("Enter subdirectory") @@ -654,16 +618,15 @@ pub fn test_sftp_keyboard_backspace_up() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify back at root") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - v.entries().iter().any(|e| e.name == "subdir"), - "Backspace 后应回到上级看到 subdir" - ) - }) - }), + TestStep::new("Verify back at root").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!( + v.entries().iter().any(|e| e.name == "subdir"), + "Backspace 后应回到上级看到 subdir" + ) + }) + }), ) } @@ -675,9 +638,7 @@ pub fn test_sftp_keyboard_delete() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("del_target.txt", b"x"), - ])) + .with_step(open_sftp_with_mock_step(&[("del_target.txt", b"x")])) // 选中第一个条目 .with_step( TestStep::new("Select first entry") @@ -696,16 +657,15 @@ pub fn test_sftp_keyboard_delete() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify delete confirm dialog") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - matches!(v.dialog(), Some(Dialog::DeleteConfirm { .. })), - "Delete 键应触发删除确认对话框" - ) - }) - }), + TestStep::new("Verify delete confirm dialog").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!( + matches!(v.dialog(), Some(Dialog::DeleteConfirm { .. })), + "Delete 键应触发删除确认对话框" + ) + }) + }), ) } @@ -717,9 +677,7 @@ pub fn test_sftp_keyboard_escape_close_dialog() -> Builder { false.to_string(), )])) .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(open_sftp_with_mock_step(&[ - ("file.txt", b"x"), - ])) + .with_step(open_sftp_with_mock_step(&[("file.txt", b"x")])) // 打开新建文件夹对话框 .with_step( TestStep::new("Open new folder dialog") @@ -732,13 +690,12 @@ pub fn test_sftp_keyboard_escape_close_dialog() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(300)), ) .with_step( - TestStep::new("Verify dialog open") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!(v.dialog().is_some(), "对话框应已打开") - }) - }), + TestStep::new("Verify dialog open").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!(v.dialog().is_some(), "对话框应已打开") + }) + }), ) // 按 Escape 关闭 .with_step( @@ -747,15 +704,11 @@ pub fn test_sftp_keyboard_escape_close_dialog() -> Builder { .set_post_step_pause(std::time::Duration::from_millis(500)), ) .with_step( - TestStep::new("Verify dialog closed") - .add_assertion(|app, window_id| { - let view = sftp::sftp_browser_view(app, window_id); - view.read(app, |v, _| { - async_assert!( - v.dialog().is_none(), - "Escape 后对话框应关闭" - ) - }) - }), + TestStep::new("Verify dialog closed").add_assertion(|app, window_id| { + let view = sftp::sftp_browser_view(app, window_id); + view.read(app, |v, _| { + async_assert!(v.dialog().is_none(), "Escape 后对话框应关闭") + }) + }), ) } diff --git a/crates/integration/src/test/ssh_manager_ui.rs b/crates/integration/src/test/ssh_manager_ui.rs index d17e8369033..c2e78033ca2 100644 --- a/crates/integration/src/test/ssh_manager_ui.rs +++ b/crates/integration/src/test/ssh_manager_ui.rs @@ -10,10 +10,7 @@ use warp::integration_testing::ssh_manager::{ open_ssh_manager_panel, save_server, select_group_by_id, ssh_server_view, }; use warp::workspace::Workspace; -use warpui::{ - async_assert, integration::TestStep, - windowing::WindowManager, SingletonEntity, -}; +use warpui::{async_assert, integration::TestStep, windowing::WindowManager, SingletonEntity}; use crate::Builder; @@ -69,14 +66,16 @@ pub fn test_ssh_server_group_dropdown() -> Builder { let fb = ids.folder_b.clone(); let sid = ids.server.clone(); builder = builder.with_step( - TestStep::new("Create test folders and server via DB").with_action(move |_app, _, _| { - let a_id = create_folder_via_db("GroupA"); - let b_id = create_folder_via_db("GroupB"); - let s_id = create_server_via_db("TestServer", Some(&a_id)); - *fa.lock().unwrap() = Some(a_id); - *fb.lock().unwrap() = Some(b_id); - *sid.lock().unwrap() = Some(s_id); - }), + TestStep::new("Create test folders and server via DB").with_action( + move |_app, _, _| { + let a_id = create_folder_via_db("GroupA"); + let b_id = create_folder_via_db("GroupB"); + let s_id = create_server_via_db("TestServer", Some(&a_id)); + *fa.lock().unwrap() = Some(a_id); + *fb.lock().unwrap() = Some(b_id); + *sid.lock().unwrap() = Some(s_id); + }, + ), ); } @@ -85,33 +84,28 @@ pub fn test_ssh_server_group_dropdown() -> Builder { open_ssh_manager_panel() .set_timeout(std::time::Duration::from_secs(30)) .set_retries(3) - .add_named_assertion( - "SSH manager panel is open", - assert_ssh_manager_panel_open(), - ), + .add_named_assertion("SSH manager panel is open", assert_ssh_manager_panel_open()), ); // Step 3: 打开服务器编辑器 { let sid = ids.server.clone(); builder = builder.with_step( - TestStep::new("Open server editor for test server").with_action( - move |app, _, _| { - let node_id = sid.lock().unwrap().clone().expect("server id should exist"); - let window_id = app.read(|ctx| { - WindowManager::as_ref(ctx) - .active_window() - .expect("no active window") - }); - let workspace = app - .views_of_type::(window_id) - .and_then(|views| views.first().cloned()) - .expect("no workspace view"); - workspace.update(app, |ws, ctx| { - ws.open_ssh_server(node_id, ctx); - }); - }, - ), + TestStep::new("Open server editor for test server").with_action(move |app, _, _| { + let node_id = sid.lock().unwrap().clone().expect("server id should exist"); + let window_id = app.read(|ctx| { + WindowManager::as_ref(ctx) + .active_window() + .expect("no active window") + }); + let workspace = app + .views_of_type::(window_id) + .and_then(|views| views.first().cloned()) + .expect("no workspace view"); + workspace.update(app, |ws, ctx| { + ws.open_ssh_server(node_id, ctx); + }); + }), ); } @@ -178,10 +172,7 @@ pub fn test_ssh_server_group_dropdown() -> Builder { builder = builder.with_step( TestStep::new("Verify group changed to Root") .set_timeout(std::time::Duration::from_secs(10)) - .add_named_assertion( - "current_group_id is None", - assert_server_group_id(None), - ), + .add_named_assertion("current_group_id is None", assert_server_group_id(None)), ); // Step 9: 保存 diff --git a/crates/onboarding/src/slides/agent_slide.rs b/crates/onboarding/src/slides/agent_slide.rs index 9eeb2f1dc77..2285877c069 100644 --- a/crates/onboarding/src/slides/agent_slide.rs +++ b/crates/onboarding/src/slides/agent_slide.rs @@ -911,8 +911,7 @@ impl AgentSlide { ); let step_index = 2; - let step_count = if warp_core::features::FeatureFlag::ZapNewSettingsModes.is_enabled() - { + let step_count = if warp_core::features::FeatureFlag::ZapNewSettingsModes.is_enabled() { 5 } else { 4 diff --git a/crates/onboarding/src/slides/project_slide.rs b/crates/onboarding/src/slides/project_slide.rs index 5aa85272208..ed305442c19 100644 --- a/crates/onboarding/src/slides/project_slide.rs +++ b/crates/onboarding/src/slides/project_slide.rs @@ -297,8 +297,7 @@ impl ProjectSlide { }, ); - let theme_picker_last = - warp_core::features::FeatureFlag::ZapNewSettingsModes.is_enabled(); + let theme_picker_last = warp_core::features::FeatureFlag::ZapNewSettingsModes.is_enabled(); let (label, keystroke, action) = match settings { ProjectOnboardingSettings::Project { .. } => ( diff --git a/crates/onboarding/src/slides/theme_picker_slide.rs b/crates/onboarding/src/slides/theme_picker_slide.rs index 3d660187a89..9949545aeef 100644 --- a/crates/onboarding/src/slides/theme_picker_slide.rs +++ b/crates/onboarding/src/slides/theme_picker_slide.rs @@ -151,8 +151,7 @@ impl ThemePickerSlide { let state = self.onboarding_state.as_ref(app); let is_terminal = matches!(state.intention(), OnboardingIntention::Terminal); let warp_drive_enabled = state.ui_customization().show_warp_drive; - if is_terminal && !warp_drive_enabled && FeatureFlag::ZapNewSettingsModes.is_enabled() - { + if is_terminal && !warp_drive_enabled && FeatureFlag::ZapNewSettingsModes.is_enabled() { content.push(self.render_disclaimer_section(appearance)); } diff --git a/crates/persistence/migrations/2026-07-11-000000_add_repository_workspaces/down.sql b/crates/persistence/migrations/2026-07-11-000000_add_repository_workspaces/down.sql new file mode 100644 index 00000000000..3845d7d7a9a --- /dev/null +++ b/crates/persistence/migrations/2026-07-11-000000_add_repository_workspaces/down.sql @@ -0,0 +1,6 @@ +ALTER TABLE windows DROP COLUMN active_repository_workspace_id; +ALTER TABLE tabs DROP COLUMN repository_workspace_id; + +DROP TABLE repository_workspace_window_states; +DROP TABLE repository_workspaces; +DROP TABLE repositories; diff --git a/crates/persistence/migrations/2026-07-11-000000_add_repository_workspaces/up.sql b/crates/persistence/migrations/2026-07-11-000000_add_repository_workspaces/up.sql new file mode 100644 index 00000000000..aa7a779ed1a --- /dev/null +++ b/crates/persistence/migrations/2026-07-11-000000_add_repository_workspaces/up.sql @@ -0,0 +1,54 @@ +CREATE TABLE repositories ( + id TEXT PRIMARY KEY NOT NULL, + display_name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + remote_url TEXT, + source TEXT NOT NULL CHECK(source IN ('local', 'cloned')), + created_at TIMESTAMP NOT NULL, + last_opened_at TIMESTAMP NOT NULL +); + +INSERT INTO repositories ( + id, + display_name, + path, + remote_url, + source, + created_at, + last_opened_at +) +SELECT + lower(hex(randomblob(4))) || '-' || + lower(hex(randomblob(2))) || '-4' || + substr(lower(hex(randomblob(2))), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(lower(hex(randomblob(2))), 2) || '-' || + lower(hex(randomblob(6))), + path, + path, + NULL, + 'local', + added_ts, + COALESCE(last_opened_ts, added_ts) +FROM projects; + +CREATE TABLE repository_workspaces ( + id TEXT PRIMARY KEY NOT NULL, + repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT, + display_name TEXT NOT NULL, + branch TEXT NOT NULL, + worktree_path TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + last_opened_at TIMESTAMP NOT NULL, + UNIQUE(repository_id, branch) +); + +CREATE TABLE repository_workspace_window_states ( + window_id INTEGER NOT NULL REFERENCES windows(id) ON DELETE CASCADE, + repository_workspace_id TEXT NOT NULL REFERENCES repository_workspaces(id) ON DELETE CASCADE, + active_tab_index INTEGER NOT NULL, + PRIMARY KEY(window_id, repository_workspace_id) +); + +ALTER TABLE tabs ADD COLUMN repository_workspace_id TEXT REFERENCES repository_workspaces(id) ON DELETE SET NULL; +ALTER TABLE windows ADD COLUMN active_repository_workspace_id TEXT REFERENCES repository_workspaces(id) ON DELETE SET NULL; diff --git a/crates/persistence/migrations/2026-07-11-010000_drop_legacy_projects/down.sql b/crates/persistence/migrations/2026-07-11-010000_drop_legacy_projects/down.sql new file mode 100644 index 00000000000..80fb0634dbc --- /dev/null +++ b/crates/persistence/migrations/2026-07-11-010000_drop_legacy_projects/down.sql @@ -0,0 +1,9 @@ +CREATE TABLE projects ( + path TEXT PRIMARY KEY NOT NULL, + added_ts TIMESTAMP NOT NULL, + last_opened_ts TIMESTAMP +); + +INSERT INTO projects (path, added_ts, last_opened_ts) +SELECT path, created_at, last_opened_at +FROM repositories; diff --git a/crates/persistence/migrations/2026-07-11-010000_drop_legacy_projects/up.sql b/crates/persistence/migrations/2026-07-11-010000_drop_legacy_projects/up.sql new file mode 100644 index 00000000000..48a1f84b556 --- /dev/null +++ b/crates/persistence/migrations/2026-07-11-010000_drop_legacy_projects/up.sql @@ -0,0 +1 @@ +DROP TABLE projects; diff --git a/crates/persistence/src/model.rs b/crates/persistence/src/model.rs index fcaecdd3146..dc9ad0a3121 100644 --- a/crates/persistence/src/model.rs +++ b/crates/persistence/src/model.rs @@ -14,9 +14,10 @@ use super::schema::{ generic_string_objects, ignored_suggestions, mcp_environment_variables, mcp_server_installations, mcp_server_panes, notebook_panes, notebooks, object_actions, object_metadata, object_permissions, pane_branches, pane_leaves, pane_nodes, panels, - project_rules, projects, server_experiments, settings_panes, ssh_nodes, ssh_onekey_credentials, - ssh_servers, sync_meta, tabs, team_members, team_settings, teams, terminal_panes, - user_profiles, welcome_panes, windows, workflow_panes, workflows, workspace_teams, workspaces, + project_rules, repositories, repository_workspace_window_states, repository_workspaces, + server_experiments, settings_panes, ssh_nodes, ssh_onekey_credentials, ssh_servers, sync_meta, + tabs, team_members, team_settings, teams, terminal_panes, user_profiles, welcome_panes, + windows, workflow_panes, workflows, workspace_teams, workspaces, }; #[derive(Insertable)] @@ -43,6 +44,7 @@ pub struct Window { pub left_panel_open: Option, pub vertical_tabs_panel_open: Option, pub theme_override: Option, + pub active_repository_workspace_id: Option, } #[derive(Identifiable, Insertable, Queryable)] @@ -183,28 +185,30 @@ pub struct NewProjectRules { pub project_root: String, } -#[derive(Default, Clone, Debug, Insertable, Queryable, AsChangeset)] -#[diesel(table_name = projects)] -pub struct Project { +#[derive(Clone, Debug, Eq, Identifiable, Insertable, PartialEq, Queryable, AsChangeset)] +#[diesel(table_name = repositories)] +pub struct Repository { + pub id: String, + pub display_name: String, pub path: String, - pub added_ts: NaiveDateTime, - pub last_opened_ts: Option, -} - -impl Project { - pub fn last_used_at(&self) -> NaiveDateTime { - self.last_opened_ts.unwrap_or(self.added_ts) - } + pub remote_url: Option, + pub source: String, + pub created_at: NaiveDateTime, + pub last_opened_at: NaiveDateTime, } -impl PartialEq for Project { - fn eq(&self, other: &Self) -> bool { - self.path == other.path - } +#[derive(Clone, Debug, Eq, Identifiable, Insertable, PartialEq, Queryable, AsChangeset)] +#[diesel(table_name = repository_workspaces)] +pub struct RepositoryWorkspace { + pub id: String, + pub repository_id: String, + pub display_name: String, + pub branch: String, + pub worktree_path: String, + pub created_at: NaiveDateTime, + pub last_opened_at: NaiveDateTime, } -impl Eq for Project {} - #[derive(Identifiable, Insertable, Queryable)] pub struct WorkspaceTeam { pub id: i32, @@ -305,6 +309,7 @@ pub struct NewWindow { pub left_panel_open: Option, pub vertical_tabs_panel_open: Option, pub theme_override: Option, + pub active_repository_workspace_id: Option, } #[derive(Identifiable, Queryable, Associations)] @@ -314,6 +319,7 @@ pub struct Tab { pub window_id: i32, pub custom_title: Option, pub color: Option, + pub repository_workspace_id: Option, } #[derive(Insertable)] @@ -322,6 +328,15 @@ pub struct NewTab { pub window_id: i32, pub custom_title: Option, pub color: Option, + pub repository_workspace_id: Option, +} + +#[derive(Insertable)] +#[diesel(table_name = repository_workspace_window_states)] +pub struct NewRepositoryWorkspaceWindowState { + pub window_id: i32, + pub repository_workspace_id: String, + pub active_tab_index: i32, } /// The panes data model includes pane_nodes, pane_leaves and pane_branches. diff --git a/crates/persistence/src/schema.rs b/crates/persistence/src/schema.rs index 881d51b7ddf..263ecca5dcb 100644 --- a/crates/persistence/src/schema.rs +++ b/crates/persistence/src/schema.rs @@ -333,10 +333,34 @@ diesel::table! { } diesel::table! { - projects (path) { + repositories (id) { + id -> Text, + display_name -> Text, path -> Text, - added_ts -> Timestamp, - last_opened_ts -> Nullable, + remote_url -> Nullable, + source -> Text, + created_at -> Timestamp, + last_opened_at -> Timestamp, + } +} + +diesel::table! { + repository_workspace_window_states (window_id, repository_workspace_id) { + window_id -> Integer, + repository_workspace_id -> Text, + active_tab_index -> Integer, + } +} + +diesel::table! { + repository_workspaces (id) { + id -> Text, + repository_id -> Text, + display_name -> Text, + branch -> Text, + worktree_path -> Text, + created_at -> Timestamp, + last_opened_at -> Timestamp, } } @@ -400,6 +424,7 @@ diesel::table! { window_id -> Integer, custom_title -> Nullable, color -> Nullable, + repository_workspace_id -> Nullable, } } @@ -488,6 +513,7 @@ diesel::table! { left_panel_open -> Nullable, vertical_tabs_panel_open -> Nullable, theme_override -> Nullable, + active_repository_workspace_id -> Nullable, } } @@ -538,11 +564,16 @@ diesel::joinable!(pane_branches -> pane_nodes (pane_node_id)); diesel::joinable!(pane_leaves -> pane_nodes (pane_node_id)); diesel::joinable!(pane_nodes -> tabs (tab_id)); diesel::joinable!(panels -> tabs (tab_id)); +diesel::joinable!(repository_workspace_window_states -> repository_workspaces (repository_workspace_id)); +diesel::joinable!(repository_workspace_window_states -> windows (window_id)); +diesel::joinable!(repository_workspaces -> repositories (repository_id)); diesel::joinable!(ssh_servers -> ssh_onekey_credentials (credential_id)); diesel::joinable!(ssh_servers -> ssh_nodes (node_id)); +diesel::joinable!(tabs -> repository_workspaces (repository_workspace_id)); diesel::joinable!(tabs -> windows (window_id)); diesel::joinable!(team_members -> teams (team_id)); diesel::joinable!(team_settings -> teams (team_id)); +diesel::joinable!(windows -> repository_workspaces (active_repository_workspace_id)); diesel::allow_tables_to_appear_in_same_query!( ambient_agent_panes, @@ -551,6 +582,9 @@ diesel::allow_tables_to_appear_in_same_query!( pane_leaves, pane_nodes, panels, + repositories, + repository_workspace_window_states, + repository_workspaces, tabs, windows, ); diff --git a/crates/remote_server/src/client/mod.rs b/crates/remote_server/src/client/mod.rs index 46c7d97beee..1ebf54ce0db 100644 --- a/crates/remote_server/src/client/mod.rs +++ b/crates/remote_server/src/client/mod.rs @@ -10,15 +10,15 @@ use futures::io::{AsyncRead, AsyncWrite}; use warpui::r#async::{executor, FutureExt as _}; use crate::proto::{ - client_message, server_message, Abort, Authenticate, BufferEdit, ClientMessage, CloseBuffer, - CreateDirectory, CreateDirectoryResponse, DeleteFile, ErrorCode, Initialize, - InitializeResponse, ListDirectory, ListDirectoryResponse, LoadRepoMetadataDirectoryResponse, - NavigatedToDirectoryResponse, OpenBuffer, OpenBufferResponse, ReadFileChunk, - read_file_chunk_response, ReadFileChunkResponse, ReadFileContextRequest, - ReadFileContextResponse, ResolveConflict, - ResolveConflictResponse, ResolvePath, ResolvePathResponse, RunCommandRequest, - RunCommandResponse, SaveBuffer, SaveBufferResponse, ServerMessage, SessionBootstrapped, - TextEdit, WriteFile, WriteFileChunk, WriteFileChunkResponse, + client_message, read_file_chunk_response, server_message, Abort, Authenticate, BufferEdit, + ClientMessage, CloseBuffer, CreateDirectory, CreateDirectoryResponse, DeleteFile, ErrorCode, + Initialize, InitializeResponse, ListDirectory, ListDirectoryResponse, + LoadRepoMetadataDirectoryResponse, NavigatedToDirectoryResponse, OpenBuffer, + OpenBufferResponse, ReadFileChunk, ReadFileChunkResponse, ReadFileContextRequest, + ReadFileContextResponse, ResolveConflict, ResolveConflictResponse, ResolvePath, + ResolvePathResponse, RunCommandRequest, RunCommandResponse, SaveBuffer, SaveBufferResponse, + ServerMessage, SessionBootstrapped, TextEdit, WriteFile, WriteFileChunk, + WriteFileChunkResponse, }; use crate::protocol::{self, ProtocolError, RequestId}; @@ -475,7 +475,9 @@ impl RemoteServerClient { let mut bytes = Vec::new(); let mut offset = 0u64; loop { - let response = self.read_file_chunk(path.clone(), offset, CHUNK_SIZE).await?; + let response = self + .read_file_chunk(path.clone(), offset, CHUNK_SIZE) + .await?; let success = match response.result { Some(read_file_chunk_response::Result::Success(success)) => success, Some(read_file_chunk_response::Result::Error(err)) => { diff --git a/crates/remote_server/src/setup_tests.rs b/crates/remote_server/src/setup_tests.rs index 3eede7d2e1d..44e9efa5def 100644 --- a/crates/remote_server/src/setup_tests.rs +++ b/crates/remote_server/src/setup_tests.rs @@ -226,8 +226,7 @@ fn oss_download_tarball_url_uses_github_release_asset() { fn install_script_uses_zap_asset_and_staging_placeholder() { let script = install_script(Some("~/.zap/remote-server/zap-upload.tar.gz")); - assert!(script - .contains("staging_tarball_path=\"~/.zap/remote-server/zap-upload.tar.gz\"")); + assert!(script.contains("staging_tarball_path=\"~/.zap/remote-server/zap-upload.tar.gz\"")); assert!(script.contains("zap-$os_name-$arch_name.tar.gz")); assert!(!script.contains("app.warp.dev")); assert!(!script.contains("/download/cli")); diff --git a/crates/warp_completer/src/completer/engine/path_test.rs b/crates/warp_completer/src/completer/engine/path_test.rs index 1b636a52f6c..69bd96272ec 100644 --- a/crates/warp_completer/src/completer/engine/path_test.rs +++ b/crates/warp_completer/src/completer/engine/path_test.rs @@ -23,12 +23,7 @@ use unix_constants::*; #[test] fn test_split_path() { let path = TypedPathBuf::from_unix("/Users/warpuser"); - let split_path = SplitPath::new( - path.to_path(), - "~/Zap.app", - Some("/Users/warpuser"), - &['/'], - ); + let split_path = SplitPath::new(path.to_path(), "~/Zap.app", Some("/Users/warpuser"), &['/']); assert_eq!( split_path, diff --git a/crates/warp_core/src/ui/appearance.rs b/crates/warp_core/src/ui/appearance.rs index f139e36c743..24400bd2e25 100644 --- a/crates/warp_core/src/ui/appearance.rs +++ b/crates/warp_core/src/ui/appearance.rs @@ -371,7 +371,10 @@ impl Appearance { return &self.ui_builder; } match current_render_window() { - Some(w) => self.ui_builder_overrides.get(&w).unwrap_or(&self.ui_builder), + Some(w) => self + .ui_builder_overrides + .get(&w) + .unwrap_or(&self.ui_builder), None => &self.ui_builder, } } diff --git a/crates/warp_core/src/ui/appearance_tests.rs b/crates/warp_core/src/ui/appearance_tests.rs index 59655246bf6..153e807fac1 100644 --- a/crates/warp_core/src/ui/appearance_tests.rs +++ b/crates/warp_core/src/ui/appearance_tests.rs @@ -3,8 +3,8 @@ use crate::ui::theme::mock_terminal_colors; use warpui::color::ColorU; fn mock_appearance() -> Appearance { - use crate::ui::theme::Details; use super::super::theme::Fill; + use crate::ui::theme::Details; let theme = WarpTheme::new( Fill::Solid(ColorU::from_u32(0x000000ff)), @@ -139,10 +139,18 @@ fn test_semantic_font_size_ordering() { let display = appearance.ui_font_display(); let hero = appearance.ui_font_hero(); - assert!(overline <= footnote, "overline <= footnote at base={}", size); + assert!( + overline <= footnote, + "overline <= footnote at base={}", + size + ); assert!(footnote <= body, "footnote <= body at base={}", size); assert!(body <= body_large, "body <= body_large at base={}", size); - assert!(body_large <= subheading, "body_large <= subheading at base={}", size); + assert!( + body_large <= subheading, + "body_large <= subheading at base={}", + size + ); assert!(subheading <= h3, "subheading <= h3 at base={}", size); assert!(h3 <= h2, "h3 <= h2 at base={}", size); assert!(h2 <= h1, "h2 <= h1 at base={}", size); @@ -166,19 +174,39 @@ fn test_dropdown_top_bar_height_scaling() { let mut appearance = mock_appearance(); appearance.set_ui_font_size_test(8.0); - assert_eq!(appearance.dropdown_top_bar_height(), 30.0, "min size should clamp to 30.0"); + assert_eq!( + appearance.dropdown_top_bar_height(), + 30.0, + "min size should clamp to 30.0" + ); appearance.set_ui_font_size_test(10.0); - assert_eq!(appearance.dropdown_top_bar_height(), 30.0, "size 10: 10*2.5=25, clamped to 30.0"); + assert_eq!( + appearance.dropdown_top_bar_height(), + 30.0, + "size 10: 10*2.5=25, clamped to 30.0" + ); appearance.set_ui_font_size_test(12.0); - assert_eq!(appearance.dropdown_top_bar_height(), 30.0, "size 12: 12*2.5=30, exactly 30.0"); + assert_eq!( + appearance.dropdown_top_bar_height(), + 30.0, + "size 12: 12*2.5=30, exactly 30.0" + ); appearance.set_ui_font_size_test(16.0); - assert_eq!(appearance.dropdown_top_bar_height(), 40.0, "size 16: 16*2.5=40"); + assert_eq!( + appearance.dropdown_top_bar_height(), + 40.0, + "size 16: 16*2.5=40" + ); appearance.set_ui_font_size_test(20.0); - assert_eq!(appearance.dropdown_top_bar_height(), 50.0, "size 20: 20*2.5=50"); + assert_eq!( + appearance.dropdown_top_bar_height(), + 50.0, + "size 20: 20*2.5=50" + ); } /// 作者: logic @@ -240,7 +268,10 @@ fn test_per_window_theme_override_resolution() { // global theme/ui_builder is always returned. set_current_render_window(Some(window_a)); assert!(std::ptr::eq(appearance.theme(), &appearance.theme)); - assert!(std::ptr::eq(appearance.ui_builder(), &appearance.ui_builder)); + assert!(std::ptr::eq( + appearance.ui_builder(), + &appearance.ui_builder + )); set_current_render_window(None); // Insert an override for window A directly (bypassing `ModelContext`, which @@ -268,7 +299,10 @@ fn test_per_window_theme_override_resolution() { // Ambient == B (no override) → the global theme is returned. set_current_render_window(Some(window_b)); assert!(std::ptr::eq(appearance.theme(), &appearance.theme)); - assert!(std::ptr::eq(appearance.ui_builder(), &appearance.ui_builder)); + assert!(std::ptr::eq( + appearance.ui_builder(), + &appearance.ui_builder + )); // Ambient == None (e.g. non-render reads) → the global theme is returned. set_current_render_window(None); diff --git a/crates/warp_core/src/ui/color/hex_color_alpha.rs b/crates/warp_core/src/ui/color/hex_color_alpha.rs index 8b83d1f873b..55620a0d0ef 100644 --- a/crates/warp_core/src/ui/color/hex_color_alpha.rs +++ b/crates/warp_core/src/ui/color/hex_color_alpha.rs @@ -27,7 +27,9 @@ fn coloru_from_hex_alpha(s: &str) -> Result { // 展开 3 位缩写: #RGB -> #RRGGBB let expanded: String = if hex.len() == SHORT_LEN { - hex.chars().flat_map(|c| std::iter::repeat_n(c, 2)).collect() + hex.chars() + .flat_map(|c| std::iter::repeat_n(c, 2)) + .collect() } else { hex.to_string() }; @@ -105,7 +107,9 @@ pub mod option { { let opt: Option = Option::deserialize(deserializer)?; match opt { - Some(s) => coloru_from_hex_alpha(&s).map(Some).map_err(de::Error::custom), + Some(s) => coloru_from_hex_alpha(&s) + .map(Some) + .map_err(de::Error::custom), None => Ok(None), } } @@ -155,13 +159,23 @@ mod tests { #[test] fn test_serialize_opaque() { - let c = ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 255 }; + let c = ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 255, + }; assert_eq!(coloru_to_hex_alpha_string(&c), "#3994bc"); } #[test] fn test_serialize_with_alpha() { - let c = ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0xB3 }; + let c = ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0xB3, + }; assert_eq!(coloru_to_hex_alpha_string(&c), "#3994bcb3"); } } diff --git a/crates/warp_core/src/ui/theme/mod.rs b/crates/warp_core/src/ui/theme/mod.rs index c7bdb669390..7bcfec0cf5a 100644 --- a/crates/warp_core/src/ui/theme/mod.rs +++ b/crates/warp_core/src/ui/theme/mod.rs @@ -1,6 +1,6 @@ pub mod color; -pub mod ui_colors; pub mod phenomenon; +pub mod ui_colors; use std::path::PathBuf; diff --git a/crates/warp_core/src/ui/theme/theme_tests.rs b/crates/warp_core/src/ui/theme/theme_tests.rs index 1e8bfdd0c30..26711aeaf57 100644 --- a/crates/warp_core/src/ui/theme/theme_tests.rs +++ b/crates/warp_core/src/ui/theme/theme_tests.rs @@ -384,15 +384,30 @@ ui_colors: let colors = theme.ui_colors().unwrap(); assert_eq!( colors.surface_1.unwrap(), - ColorU { r: 0x1E, g: 0x1F, b: 0x20, a: 255 } + ColorU { + r: 0x1E, + g: 0x1F, + b: 0x20, + a: 255 + } ); assert_eq!( colors.border.unwrap(), - ColorU { r: 0x33, g: 0x35, b: 0x36, a: 255 } + ColorU { + r: 0x33, + g: 0x35, + b: 0x36, + a: 255 + } ); assert_eq!( colors.main_text.unwrap(), - ColorU { r: 0xED, g: 0xED, b: 0xED, a: 255 } + ColorU { + r: 0xED, + g: 0xED, + b: 0xED, + a: 255 + } ); assert!(colors.surface_2.is_none()); } @@ -436,24 +451,114 @@ name: VS Code 2026 Dark fn test_ui_colors() -> super::ui_colors::UiColors { use super::ui_colors::UiColors; UiColors { - surface_1: Some(ColorU { r: 0x11, g: 0x11, b: 0x11, a: 255 }), - surface_2: Some(ColorU { r: 0x22, g: 0x22, b: 0x22, a: 255 }), - surface_3: Some(ColorU { r: 0x33, g: 0x33, b: 0x33, a: 255 }), - border: Some(ColorU { r: 0x44, g: 0x44, b: 0x44, a: 255 }), - focus_border: Some(ColorU { r: 0x55, g: 0x55, b: 0x55, a: 128 }), - split_pane_border: Some(ColorU { r: 0x66, g: 0x66, b: 0x66, a: 255 }), - main_text: Some(ColorU { r: 0x77, g: 0x77, b: 0x77, a: 255 }), - sub_text: Some(ColorU { r: 0x88, g: 0x88, b: 0x88, a: 255 }), - hint_text: Some(ColorU { r: 0x99, g: 0x99, b: 0x99, a: 255 }), - disabled_text: Some(ColorU { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }), - selection: Some(ColorU { r: 0xBB, g: 0xBB, b: 0xBB, a: 128 }), - text_selection: Some(ColorU { r: 0xBB, g: 0xBB, b: 0xBB, a: 0x99 }), - hover: Some(ColorU { r: 0xCC, g: 0xCC, b: 0xCC, a: 128 }), - active: Some(ColorU { r: 0xDD, g: 0xDD, b: 0xDD, a: 255 }), - warning: Some(ColorU { r: 0xEE, g: 0x00, b: 0x00, a: 255 }), - error: Some(ColorU { r: 0x00, g: 0xEE, b: 0x00, a: 255 }), - success: Some(ColorU { r: 0x00, g: 0x00, b: 0xEE, a: 255 }), - link: Some(ColorU { r: 0xFF, g: 0xFF, b: 0x00, a: 255 }), + surface_1: Some(ColorU { + r: 0x11, + g: 0x11, + b: 0x11, + a: 255, + }), + surface_2: Some(ColorU { + r: 0x22, + g: 0x22, + b: 0x22, + a: 255, + }), + surface_3: Some(ColorU { + r: 0x33, + g: 0x33, + b: 0x33, + a: 255, + }), + border: Some(ColorU { + r: 0x44, + g: 0x44, + b: 0x44, + a: 255, + }), + focus_border: Some(ColorU { + r: 0x55, + g: 0x55, + b: 0x55, + a: 128, + }), + split_pane_border: Some(ColorU { + r: 0x66, + g: 0x66, + b: 0x66, + a: 255, + }), + main_text: Some(ColorU { + r: 0x77, + g: 0x77, + b: 0x77, + a: 255, + }), + sub_text: Some(ColorU { + r: 0x88, + g: 0x88, + b: 0x88, + a: 255, + }), + hint_text: Some(ColorU { + r: 0x99, + g: 0x99, + b: 0x99, + a: 255, + }), + disabled_text: Some(ColorU { + r: 0xAA, + g: 0xAA, + b: 0xAA, + a: 255, + }), + selection: Some(ColorU { + r: 0xBB, + g: 0xBB, + b: 0xBB, + a: 128, + }), + text_selection: Some(ColorU { + r: 0xBB, + g: 0xBB, + b: 0xBB, + a: 0x99, + }), + hover: Some(ColorU { + r: 0xCC, + g: 0xCC, + b: 0xCC, + a: 128, + }), + active: Some(ColorU { + r: 0xDD, + g: 0xDD, + b: 0xDD, + a: 255, + }), + warning: Some(ColorU { + r: 0xEE, + g: 0x00, + b: 0x00, + a: 255, + }), + error: Some(ColorU { + r: 0x00, + g: 0xEE, + b: 0x00, + a: 255, + }), + success: Some(ColorU { + r: 0x00, + g: 0x00, + b: 0xEE, + a: 255, + }), + link: Some(ColorU { + r: 0xFF, + g: 0xFF, + b: 0x00, + a: 255, + }), } } @@ -644,23 +749,31 @@ surface_1: "#GHIJKL" fn hex_alpha_roundtrip_with_alpha_via_ui_colors() { let mut colors = test_ui_colors(); // 确保 surface_1 有特定 alpha 值 - colors.surface_1 = Some(ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0x26 }); + colors.surface_1 = Some(ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0x26, + }); colors.surface_2 = None; colors.surface_3 = None; let yaml = serde_yaml::to_string(&colors).expect("序列化失败"); - let restored: super::ui_colors::UiColors = - serde_yaml::from_str(&yaml).expect("反序列化失败"); + let restored: super::ui_colors::UiColors = serde_yaml::from_str(&yaml).expect("反序列化失败"); assert_eq!(restored.surface_1, colors.surface_1); } #[test] fn hex_alpha_roundtrip_opaque_via_ui_colors() { let mut colors = test_ui_colors(); - colors.surface_1 = Some(ColorU { r: 0xFF, g: 0x00, b: 0x80, a: 255 }); + colors.surface_1 = Some(ColorU { + r: 0xFF, + g: 0x00, + b: 0x80, + a: 255, + }); colors.surface_2 = None; colors.surface_3 = None; let yaml = serde_yaml::to_string(&colors).expect("序列化失败"); - let restored: super::ui_colors::UiColors = - serde_yaml::from_str(&yaml).expect("反序列化失败"); + let restored: super::ui_colors::UiColors = serde_yaml::from_str(&yaml).expect("反序列化失败"); assert_eq!(restored.surface_1, colors.surface_1); } diff --git a/crates/warp_core/src/ui/theme/ui_colors.rs b/crates/warp_core/src/ui/theme/ui_colors.rs index 8dff565a0a6..80cff28633b 100644 --- a/crates/warp_core/src/ui/theme/ui_colors.rs +++ b/crates/warp_core/src/ui/theme/ui_colors.rs @@ -10,58 +10,130 @@ use crate::ui::color::hex_color_alpha; /// UI 颜色覆盖映射。所有字段可选,缺失时使用 WarpTheme 的默认派生值。 #[derive(Serialize, Clone, Debug, Deserialize, PartialEq, Eq)] pub struct UiColors { - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub surface_1: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub surface_2: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub surface_3: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub border: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub focus_border: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub split_pane_border: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub main_text: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub sub_text: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub hint_text: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub disabled_text: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub selection: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub text_selection: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub hover: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub active: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub warning: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub error: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub success: Option, - #[serde(default, with = "hex_color_alpha::option", skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "hex_color_alpha::option", + skip_serializing_if = "Option::is_none" + )] pub link: Option, } diff --git a/crates/warp_core/src/ui/theme/ui_colors_tests.rs b/crates/warp_core/src/ui/theme/ui_colors_tests.rs index 0b41c81cb9c..a7174eb9a30 100644 --- a/crates/warp_core/src/ui/theme/ui_colors_tests.rs +++ b/crates/warp_core/src/ui/theme/ui_colors_tests.rs @@ -26,11 +26,51 @@ hover: "#FFFFFF0D" "##; let colors: UiColors = serde_yaml::from_str(yaml).expect("反序列化失败"); - assert_eq!(colors.surface_1.unwrap(), ColorU { r: 0x20, g: 0x21, b: 0x22, a: 255 }); - assert_eq!(colors.surface_2.unwrap(), ColorU { r: 0x24, g: 0x25, b: 0x26, a: 255 }); - assert_eq!(colors.focus_border.unwrap(), ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0xB3 }); - assert_eq!(colors.selection.unwrap(), ColorU { r: 0x39, g: 0x94, b: 0xBC, a: 0x33 }); - assert_eq!(colors.hover.unwrap(), ColorU { r: 0xFF, g: 0xFF, b: 0xFF, a: 0x0D }); + assert_eq!( + colors.surface_1.unwrap(), + ColorU { + r: 0x20, + g: 0x21, + b: 0x22, + a: 255 + } + ); + assert_eq!( + colors.surface_2.unwrap(), + ColorU { + r: 0x24, + g: 0x25, + b: 0x26, + a: 255 + } + ); + assert_eq!( + colors.focus_border.unwrap(), + ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0xB3 + } + ); + assert_eq!( + colors.selection.unwrap(), + ColorU { + r: 0x39, + g: 0x94, + b: 0xBC, + a: 0x33 + } + ); + assert_eq!( + colors.hover.unwrap(), + ColorU { + r: 0xFF, + g: 0xFF, + b: 0xFF, + a: 0x0D + } + ); // 未设置的字段应为 None assert!(colors.main_text.is_none()); } @@ -39,9 +79,19 @@ hover: "#FFFFFF0D" #[test] fn serialize_ui_colors_skips_none() { let colors = UiColors { - surface_1: Some(ColorU { r: 0x20, g: 0x21, b: 0x22, a: 255 }), + surface_1: Some(ColorU { + r: 0x20, + g: 0x21, + b: 0x22, + a: 255, + }), surface_2: None, - border: Some(ColorU { r: 0x33, g: 0x35, b: 0x36, a: 255 }), + border: Some(ColorU { + r: 0x33, + g: 0x35, + b: 0x36, + a: 255, + }), surface_3: None, focus_border: None, split_pane_border: None, diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index fb03a5707b5..a8a9bb9fd60 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -174,6 +174,9 @@ pub enum FeatureFlag { /// Enables multi-workspace selection. MultiWorkspace, + /// 启用仓库工作区。 + RepositoryWorkspaces, + /// Maximizes data in flat storage to reduce memory usage. MaximizeFlatStorage, @@ -718,6 +721,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::LazySceneBuilding, FeatureFlag::SshDragAndDrop, FeatureFlag::MultiWorkspace, + FeatureFlag::RepositoryWorkspaces, FeatureFlag::ImeMarkedText, FeatureFlag::MSYS2Shells, FeatureFlag::RetryTruncatedCodeResponses, @@ -1015,3 +1019,7 @@ impl From for Option { #[cfg(test)] #[path = "features_test.rs"] mod tests; + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod lib_tests; diff --git a/crates/warp_features/src/lib_tests.rs b/crates/warp_features/src/lib_tests.rs new file mode 100644 index 00000000000..c157c8d1d73 --- /dev/null +++ b/crates/warp_features/src/lib_tests.rs @@ -0,0 +1,8 @@ +use super::{FeatureFlag, DOGFOOD_FLAGS, PREVIEW_FLAGS, RELEASE_FLAGS}; + +#[test] +fn repository_workspaces_is_dogfood_only() { + assert!(DOGFOOD_FLAGS.contains(&FeatureFlag::RepositoryWorkspaces)); + assert!(!PREVIEW_FLAGS.contains(&FeatureFlag::RepositoryWorkspaces)); + assert!(!RELEASE_FLAGS.contains(&FeatureFlag::RepositoryWorkspaces)); +} diff --git a/crates/warp_ssh_manager/src/ssh_command_tests.rs b/crates/warp_ssh_manager/src/ssh_command_tests.rs index fcb1d665df3..0c1a18698af 100644 --- a/crates/warp_ssh_manager/src/ssh_command_tests.rs +++ b/crates/warp_ssh_manager/src/ssh_command_tests.rs @@ -98,10 +98,12 @@ fn test_connection_requires_password_for_password_auth() { let rt = tokio::runtime::Runtime::new().unwrap(); let result = rt.block_on(test_connection(&s, None)); assert_eq!(result.status, ConnectionStatus::Offline); - assert!(result - .error_message - .unwrap() - .contains("Password not provided")); + assert!( + result + .error_message + .unwrap() + .contains("Password not provided") + ); } #[test] @@ -111,10 +113,12 @@ fn test_connection_requires_password_for_onekey_auth() { let rt = tokio::runtime::Runtime::new().unwrap(); let result = rt.block_on(test_connection(&s, None)); assert_eq!(result.status, ConnectionStatus::Offline); - assert!(result - .error_message - .unwrap() - .contains("Password not provided")); + assert!( + result + .error_message + .unwrap() + .contains("Password not provided") + ); } #[test] diff --git a/crates/warp_util/src/path.rs b/crates/warp_util/src/path.rs index 59ad957122d..27d17f5ebcd 100644 --- a/crates/warp_util/src/path.rs +++ b/crates/warp_util/src/path.rs @@ -471,11 +471,7 @@ pub fn convert_wsl_to_windows_host_path( _ => { let mut windows_path = TypedPathBuf::new(PathType::Windows); windows_path.push(format!(r"\\WSL$\{distro_name}")); - for component in unix_path - .with_windows_encoding() - .components() - .skip(1) - { + for component in unix_path.with_windows_encoding().components().skip(1) { windows_path.push(component.as_bytes()); } windows_path diff --git a/crates/warpui_core/src/core/app.rs b/crates/warpui_core/src/core/app.rs index 394d5e69e48..4679b87483a 100644 --- a/crates/warpui_core/src/core/app.rs +++ b/crates/warpui_core/src/core/app.rs @@ -62,11 +62,12 @@ use crate::{ }, rendering, util::post_inc, - Action, AddWindowOptions, AnyModel, AnyModelHandle, AnyView, ApplicationBundleInfo, CursorInfo, - CurrentRenderWindowGuard, Effect, Element, Entity, EntityId, Event, GetSingletonModelHandle, - ModelAsRef, ModelContext, ModelHandle, NextNewWindowsHasThisWindowsBoundsUponClose, Presenter, - ReadModel, ReadView, SingletonEntity, SpawnedFuture, TaskId, TypedActionView, UpdateModel, - UpdateView, View, ViewAsRef, ViewContext, ViewHandle, WindowId, WindowInvalidation, + Action, AddWindowOptions, AnyModel, AnyModelHandle, AnyView, ApplicationBundleInfo, + CurrentRenderWindowGuard, CursorInfo, Effect, Element, Entity, EntityId, Event, + GetSingletonModelHandle, ModelAsRef, ModelContext, ModelHandle, + NextNewWindowsHasThisWindowsBoundsUponClose, Presenter, ReadModel, ReadView, SingletonEntity, + SpawnedFuture, TaskId, TypedActionView, UpdateModel, UpdateView, View, ViewAsRef, ViewContext, + ViewHandle, WindowId, WindowInvalidation, }; use super::{ diff --git a/crates/warpui_core/src/elements/formatted_text_element_tests.rs b/crates/warpui_core/src/elements/formatted_text_element_tests.rs index 4b036bb46c0..5be78696d95 100644 --- a/crates/warpui_core/src/elements/formatted_text_element_tests.rs +++ b/crates/warpui_core/src/elements/formatted_text_element_tests.rs @@ -128,7 +128,12 @@ fn test_heading_multipliers_size_ordering() { #[test] fn test_get_multiplier_const_fn() { const M: HeadingFontSizeMultipliers = HeadingFontSizeMultipliers { - h1: 2.0, h2: 1.5, h3: 1.17, h4: 1.0, h5: 0.83, h6: 0.75, + h1: 2.0, + h2: 1.5, + h3: 1.17, + h4: 1.0, + h5: 0.83, + h6: 0.75, }; const H1: f32 = M.get_multiplier(1); const H4: f32 = M.get_multiplier(4); diff --git a/crates/warpui_core/src/elements/hoverable.rs b/crates/warpui_core/src/elements/hoverable.rs index e40164afab8..7023d158105 100644 --- a/crates/warpui_core/src/elements/hoverable.rs +++ b/crates/warpui_core/src/elements/hoverable.rs @@ -359,9 +359,15 @@ impl Hoverable { /// If there is another element above this one at the cursor position, then we treat that as /// outside the element for purposes of [`MouseState`]. fn is_mouse_over_element(&self, ctx: &EventContext, position: Vector2F) -> bool { - let Some(origin) = self.origin else { return false; }; - let Some(size) = self.size() else { return false; }; - let Some(z_index) = self.child_max_z_index else { return false; }; + let Some(origin) = self.origin else { + return false; + }; + let Some(size) = self.size() else { + return false; + }; + let Some(z_index) = self.child_max_z_index else { + return false; + }; let is_hovering = ctx .visible_rect(origin, size) diff --git a/crates/zap_sftp/src/session.rs b/crates/zap_sftp/src/session.rs index 331008080a4..024a109d10c 100644 --- a/crates/zap_sftp/src/session.rs +++ b/crates/zap_sftp/src/session.rs @@ -19,8 +19,13 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); /// 认证方式 #[derive(Debug, Clone)] pub enum AuthMethod { - Password { password: String }, - PublicKey { key_path: PathBuf, passphrase: Option }, + Password { + password: String, + }, + PublicKey { + key_path: PathBuf, + passphrase: Option, + }, } /// SFTP 会话,封装 ssh2 连接 @@ -51,54 +56,57 @@ impl SftpSession { let addr = format!("{host}:{port}"); // 通过 ToSocketAddrs 进行 DNS 解析,支持主机名和 IP 地址 - let socket_addr = addr.to_socket_addrs() + let socket_addr = addr + .to_socket_addrs() .map_err(|e| SftpError::ConnectionFailed(format!("地址解析失败: {e}")))? .next() .ok_or_else(|| SftpError::ConnectionFailed(format!("DNS 解析无结果: {addr}")))?; // 使用带超时的 TCP 连接 - let tcp = TcpStream::connect_timeout(&socket_addr, effective_timeout) - .map_err(|e| { - if e.kind() == std::io::ErrorKind::TimedOut { - SftpError::Timeout - } else { - SftpError::ConnectionFailed(format!("连接 {addr} 失败: {e}")) - } - })?; + let tcp = TcpStream::connect_timeout(&socket_addr, effective_timeout).map_err(|e| { + if e.kind() == std::io::ErrorKind::TimedOut { + SftpError::Timeout + } else { + SftpError::ConnectionFailed(format!("连接 {addr} 失败: {e}")) + } + })?; let mut session = ssh2::Session::new() .map_err(|e| SftpError::ConnectionFailed(format!("创建 SSH 会话失败: {e}")))?; - let tcp_for_session = tcp.try_clone() + let tcp_for_session = tcp + .try_clone() .map_err(|e| SftpError::ConnectionFailed(format!("克隆 TCP 流失败: {e}")))?; session.set_tcp_stream(tcp_for_session); // 设置 SSH 会话超时(毫秒),影响 handshake 和后续所有阻塞操作 session.set_timeout(effective_timeout.as_millis() as u32); - session.handshake() - .map_err(|e| { - if is_timeout_error(&e) { - SftpError::Timeout - } else { - SftpError::ConnectionFailed(format!("SSH 握手失败: {e}")) - } - })?; + session.handshake().map_err(|e| { + if is_timeout_error(&e) { + SftpError::Timeout + } else { + SftpError::ConnectionFailed(format!("SSH 握手失败: {e}")) + } + })?; match &auth { AuthMethod::Password { password } => { - session.userauth_password(username, password) - .map_err(|e| { - if is_timeout_error(&e) { - SftpError::Timeout - } else { - SftpError::AuthFailed(format!("密码认证失败: {e}")) - } - })?; + session.userauth_password(username, password).map_err(|e| { + if is_timeout_error(&e) { + SftpError::Timeout + } else { + SftpError::AuthFailed(format!("密码认证失败: {e}")) + } + })?; } - AuthMethod::PublicKey { key_path, passphrase } => { + AuthMethod::PublicKey { + key_path, + passphrase, + } => { let pass = passphrase.as_deref(); - session.userauth_pubkey_file(username, None, key_path, pass) + session + .userauth_pubkey_file(username, None, key_path, pass) .map_err(|e| { if is_timeout_error(&e) { SftpError::Timeout diff --git a/crates/zap_sftp/src/types.rs b/crates/zap_sftp/src/types.rs index 8632aa5e024..2424d68a074 100644 --- a/crates/zap_sftp/src/types.rs +++ b/crates/zap_sftp/src/types.rs @@ -81,12 +81,12 @@ impl Metadata { size: m.size.unwrap_or(0), uid: m.uid.unwrap_or(0), gid: m.gid.unwrap_or(0), - accessed: m.atime.map(|t| { - std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(t) - }), - modified: m.mtime.map(|t| { - std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(t) - }), + accessed: m + .atime + .map(|t| std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(t)), + modified: m + .mtime + .map(|t| std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(t)), } } } diff --git a/crates/zap_sftp/tests/zap_sftp_types_tests.rs b/crates/zap_sftp/tests/zap_sftp_types_tests.rs index 4a1ef63e38a..44304147d63 100644 --- a/crates/zap_sftp/tests/zap_sftp_types_tests.rs +++ b/crates/zap_sftp/tests/zap_sftp_types_tests.rs @@ -82,18 +82,36 @@ fn test_file_permissions_from_mode_644() { #[test] fn test_file_permissions_from_mode_777() { let p = FilePermissions::from_mode(0o777); - assert!(p.owner_read && p.owner_write && p.owner_exec, "owner 位应全部为 true"); - assert!(p.group_read && p.group_write && p.group_exec, "group 位应全部为 true"); - assert!(p.other_read && p.other_write && p.other_exec, "other 位应全部为 true"); + assert!( + p.owner_read && p.owner_write && p.owner_exec, + "owner 位应全部为 true" + ); + assert!( + p.group_read && p.group_write && p.group_exec, + "group 位应全部为 true" + ); + assert!( + p.other_read && p.other_write && p.other_exec, + "other 位应全部为 true" + ); } /// 验证 0o000 => 所有位均为 false #[test] fn test_file_permissions_from_mode_000() { let p = FilePermissions::from_mode(0o000); - assert!(!p.owner_read && !p.owner_write && !p.owner_exec, "owner 位应全部为 false"); - assert!(!p.group_read && !p.group_write && !p.group_exec, "group 位应全部为 false"); - assert!(!p.other_read && !p.other_write && !p.other_exec, "other 位应全部为 false"); + assert!( + !p.owner_read && !p.owner_write && !p.owner_exec, + "owner 位应全部为 false" + ); + assert!( + !p.group_read && !p.group_write && !p.group_exec, + "group 位应全部为 false" + ); + assert!( + !p.other_read && !p.other_write && !p.other_exec, + "other 位应全部为 false" + ); } /// 验证 0o111 => 仅执行位为 true @@ -143,7 +161,11 @@ fn test_open_options_write() { fn test_open_options_append() { let opts = OpenOptions::append(); assert!(!opts.read, "read 应为 false"); - assert_eq!(opts.write, Some(WriteMode::Append), "write 应为 Some(Append)"); + assert_eq!( + opts.write, + Some(WriteMode::Append), + "write 应为 Some(Append)" + ); assert!(opts.create, "create 应为 true"); assert!(!opts.truncate, "truncate 应为 false"); } diff --git a/crates/zap_sync/src/crypto.rs b/crates/zap_sync/src/crypto.rs index 774ba730606..5236352c867 100644 --- a/crates/zap_sync/src/crypto.rs +++ b/crates/zap_sync/src/crypto.rs @@ -5,7 +5,7 @@ use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng}; use aes_gcm::{Aes256Gcm, Nonce}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -35,8 +35,8 @@ fn derive_key(token: &str) -> [u8; 32] { /// 使用用户 Token 派生加密密钥 pub fn encrypt(token: &str, plaintext: &str) -> Result { let key = derive_key(token); - let cipher = Aes256Gcm::new_from_slice(&key) - .map_err(|e| CryptoError::Encrypt(e.to_string()))?; + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| CryptoError::Encrypt(e.to_string()))?; let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, plaintext.as_bytes()) @@ -60,8 +60,8 @@ pub fn decrypt(token: &str, encoded: &str) -> Result { } let (nonce_bytes, ciphertext) = combined.split_at(12); let nonce = Nonce::from_slice(nonce_bytes); - let cipher = Aes256Gcm::new_from_slice(&key) - .map_err(|e| CryptoError::Decrypt(e.to_string()))?; + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| CryptoError::Decrypt(e.to_string()))?; let plaintext = cipher .decrypt(nonce, ciphertext) .map_err(|e| CryptoError::Decrypt(e.to_string()))?; diff --git a/crates/zap_sync/src/gist_client.rs b/crates/zap_sync/src/gist_client.rs index c8aa0069cd3..8856c69f235 100644 --- a/crates/zap_sync/src/gist_client.rs +++ b/crates/zap_sync/src/gist_client.rs @@ -34,19 +34,43 @@ pub enum GistClientError { /// Gist 操作 trait,支持真实客户端和测试 mock pub trait GistOps: Send + Sync { /// 验证 Token 是否有效,返回用户名 - fn validate_token(&self, platform: SyncPlatform, token: String) -> impl std::future::Future> + Send; + fn validate_token( + &self, + platform: SyncPlatform, + token: String, + ) -> impl std::future::Future> + Send; /// 查找 description 为 ZAP_CONFIG 的 Gist - fn find_gist(&self, platform: SyncPlatform, token: String) -> impl std::future::Future, GistClientError>> + Send; + fn find_gist( + &self, + platform: SyncPlatform, + token: String, + ) -> impl std::future::Future, GistClientError>> + Send; /// 创建新 Gist - fn create_gist(&self, platform: SyncPlatform, token: String, content: String) -> impl std::future::Future> + Send; + fn create_gist( + &self, + platform: SyncPlatform, + token: String, + content: String, + ) -> impl std::future::Future> + Send; /// 更新已有 Gist - fn update_gist(&self, platform: SyncPlatform, token: String, gist_id: String, content: String) -> impl std::future::Future> + Send; + fn update_gist( + &self, + platform: SyncPlatform, + token: String, + gist_id: String, + content: String, + ) -> impl std::future::Future> + Send; /// 获取 Gist 文件内容 - fn get_gist_content(&self, platform: SyncPlatform, token: String, gist_id: String) -> impl std::future::Future> + Send; + fn get_gist_content( + &self, + platform: SyncPlatform, + token: String, + gist_id: String, + ) -> impl std::future::Future> + Send; } /// Gist API 客户端,支持 GitHub 和 Gitee @@ -293,23 +317,47 @@ impl GistClient { } impl GistOps for GistClient { - async fn validate_token(&self, platform: SyncPlatform, token: String) -> Result { + async fn validate_token( + &self, + platform: SyncPlatform, + token: String, + ) -> Result { self.validate_token(platform, &token).await } - async fn find_gist(&self, platform: SyncPlatform, token: String) -> Result, GistClientError> { + async fn find_gist( + &self, + platform: SyncPlatform, + token: String, + ) -> Result, GistClientError> { self.find_gist(platform, &token).await } - async fn create_gist(&self, platform: SyncPlatform, token: String, content: String) -> Result { + async fn create_gist( + &self, + platform: SyncPlatform, + token: String, + content: String, + ) -> Result { self.create_gist(platform, &token, &content).await } - async fn update_gist(&self, platform: SyncPlatform, token: String, gist_id: String, content: String) -> Result<(), GistClientError> { + async fn update_gist( + &self, + platform: SyncPlatform, + token: String, + gist_id: String, + content: String, + ) -> Result<(), GistClientError> { self.update_gist(platform, &token, &gist_id, &content).await } - async fn get_gist_content(&self, platform: SyncPlatform, token: String, gist_id: String) -> Result { + async fn get_gist_content( + &self, + platform: SyncPlatform, + token: String, + gist_id: String, + ) -> Result { self.get_gist_content(platform, &token, &gist_id).await } } @@ -338,15 +386,30 @@ mod tests { // validate_token / find_gist / create_gist / update_gist / get_gist_content 应当在 token 为空时立即返回 NoToken,不发起任何 HTTP 请求 for platform in [SyncPlatform::GitHub, SyncPlatform::Gitee] { let r = client.validate_token(platform, "").await; - assert!(matches!(r, Err(GistClientError::NoToken)), "validate_token 空 token"); + assert!( + matches!(r, Err(GistClientError::NoToken)), + "validate_token 空 token" + ); let r = client.find_gist(platform, "").await; - assert!(matches!(r, Err(GistClientError::NoToken)), "find_gist 空 token"); + assert!( + matches!(r, Err(GistClientError::NoToken)), + "find_gist 空 token" + ); let r = client.create_gist(platform, "", "{}").await; - assert!(matches!(r, Err(GistClientError::NoToken)), "create_gist 空 token"); + assert!( + matches!(r, Err(GistClientError::NoToken)), + "create_gist 空 token" + ); let r = client.update_gist(platform, "", "x", "{}").await; - assert!(matches!(r, Err(GistClientError::NoToken)), "update_gist 空 token"); + assert!( + matches!(r, Err(GistClientError::NoToken)), + "update_gist 空 token" + ); let r = client.get_gist_content(platform, "", "x").await; - assert!(matches!(r, Err(GistClientError::NoToken)), "get_gist_content 空 token"); + assert!( + matches!(r, Err(GistClientError::NoToken)), + "get_gist_content 空 token" + ); } } } diff --git a/crates/zap_sync/src/sync_engine.rs b/crates/zap_sync/src/sync_engine.rs index 2147acf1b09..c8f0e3f94a3 100644 --- a/crates/zap_sync/src/sync_engine.rs +++ b/crates/zap_sync/src/sync_engine.rs @@ -104,7 +104,9 @@ impl SyncEngine { .map_err(|e| SyncEngineError::Gist(e.to_string()))?; } - tokio::task::block_in_place(|| version_store.update_sync_meta(&Utc::now().to_rfc3339(), platform.to_db_str()))?; + tokio::task::block_in_place(|| { + version_store.update_sync_meta(&Utc::now().to_rfc3339(), platform.to_db_str()) + })?; Ok(SyncResult::Success { version: local_version, platform, @@ -152,7 +154,9 @@ impl SyncEngine { } tokio::task::block_in_place(|| version_store.set_sync_version(remote_data.version))?; - tokio::task::block_in_place(|| version_store.update_sync_meta(&Utc::now().to_rfc3339(), platform.to_db_str()))?; + tokio::task::block_in_place(|| { + version_store.update_sync_meta(&Utc::now().to_rfc3339(), platform.to_db_str()) + })?; Ok(SyncResult::Success { version: remote_data.version, @@ -242,7 +246,9 @@ impl SyncEngine { return Err(SyncEngineError::Gist(format!("{e}{rollback_msg}"))); } - tokio::task::block_in_place(|| version_store.update_sync_meta(&Utc::now().to_rfc3339(), platform.to_db_str()))?; + tokio::task::block_in_place(|| { + version_store.update_sync_meta(&Utc::now().to_rfc3339(), platform.to_db_str()) + })?; Ok(SyncResult::Success { version: new_version, @@ -330,25 +336,49 @@ mod tests { } impl GistOps for MockGistOps { - async fn validate_token(&self, platform: SyncPlatform, _token: String) -> Result { + async fn validate_token( + &self, + platform: SyncPlatform, + _token: String, + ) -> Result { *self.last_platform.lock().unwrap() = Some(platform); Ok("testuser".to_string()) } - async fn find_gist(&self, platform: SyncPlatform, _token: String) -> Result, GistClientError> { + async fn find_gist( + &self, + platform: SyncPlatform, + _token: String, + ) -> Result, GistClientError> { *self.last_platform.lock().unwrap() = Some(platform); Ok(self.find_result.lock().unwrap().clone()) } - async fn create_gist(&self, platform: SyncPlatform, _token: String, _content: String) -> Result { + async fn create_gist( + &self, + platform: SyncPlatform, + _token: String, + _content: String, + ) -> Result { *self.last_platform.lock().unwrap() = Some(platform); *self.create_called.lock().unwrap() = true; Ok("new_gist_id".to_string()) } - async fn update_gist(&self, platform: SyncPlatform, _token: String, _gist_id: String, _content: String) -> Result<(), GistClientError> { + async fn update_gist( + &self, + platform: SyncPlatform, + _token: String, + _gist_id: String, + _content: String, + ) -> Result<(), GistClientError> { *self.last_platform.lock().unwrap() = Some(platform); *self.update_called.lock().unwrap() = true; Ok(()) } - async fn get_gist_content(&self, platform: SyncPlatform, _token: String, _gist_id: String) -> Result { + async fn get_gist_content( + &self, + platform: SyncPlatform, + _token: String, + _gist_id: String, + ) -> Result { *self.last_platform.lock().unwrap() = Some(platform); Ok(self.content.clone()) } @@ -357,11 +387,17 @@ mod tests { struct MockProvider; impl SyncDataProvider for MockProvider { - fn section_key(&self) -> &str { "ssh" } + fn section_key(&self) -> &str { + "ssh" + } fn collect_data(&self, _token: &str) -> Result { Ok(serde_json::json!({"nodes": []})) } - fn apply_data(&self, _token: &str, _data: &serde_json::Value) -> Result<(), SyncEngineError> { + fn apply_data( + &self, + _token: &str, + _data: &serde_json::Value, + ) -> Result<(), SyncEngineError> { Ok(()) } } @@ -394,7 +430,10 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(1); - let result = engine.upload(platform, "token", &[&provider], &store).await.unwrap(); + let result = engine + .upload(platform, "token", &[&provider], &store) + .await + .unwrap(); assert!(matches!(result, SyncResult::Success { version: 1, .. })); assert!(*engine.client.create_called.lock().unwrap()); // 断言 platform 真的传到 GistOps,避免 mock 吞参数 @@ -416,7 +455,10 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(1); - let result = engine.upload(platform, "token", &[&provider], &store).await.unwrap(); + let result = engine + .upload(platform, "token", &[&provider], &store) + .await + .unwrap(); assert!(matches!(result, SyncResult::Success { version: 1, .. })); assert!(*engine.client.update_called.lock().unwrap()); } @@ -436,8 +478,17 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(1); - let result = engine.upload(platform, "token", &[&provider], &store).await.unwrap(); - assert!(matches!(result, SyncResult::Conflict { local_version: 1, remote_version: 5 })); + let result = engine + .upload(platform, "token", &[&provider], &store) + .await + .unwrap(); + assert!(matches!( + result, + SyncResult::Conflict { + local_version: 1, + remote_version: 5 + } + )); } #[tokio::test(flavor = "multi_thread")] @@ -455,7 +506,10 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(5); - let result = engine.download(platform, "token", &[&provider], &store).await.unwrap(); + let result = engine + .download(platform, "token", &[&provider], &store) + .await + .unwrap(); assert!(matches!(result, SyncResult::AlreadyUpToDate { version: 1 })); } @@ -474,7 +528,10 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(1); - let result = engine.download(platform, "token", &[&provider], &store).await.unwrap(); + let result = engine + .download(platform, "token", &[&provider], &store) + .await + .unwrap(); assert!(matches!(result, SyncResult::Success { version: 10, .. })); assert_eq!(store.get_sync_version().unwrap(), 10); } @@ -494,7 +551,9 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(1); - let result = engine.download(platform, "token", &[&provider], &store).await; + let result = engine + .download(platform, "token", &[&provider], &store) + .await; assert!(result.is_err()); } @@ -506,7 +565,10 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(3); - let result = engine.upload(SyncPlatform::GitHub, "token", &[&provider], &store).await.unwrap(); + let result = engine + .upload(SyncPlatform::GitHub, "token", &[&provider], &store) + .await + .unwrap(); assert!(matches!(result, SyncResult::AlreadyUpToDate { version: 3 })); // 远程版本未变,不应触发任何写操作 assert!(!*engine.client.update_called.lock().unwrap()); @@ -519,7 +581,10 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(1); - let result = engine.force_upload(SyncPlatform::GitHub, "token", &[&provider], &store).await.unwrap(); + let result = engine + .force_upload(SyncPlatform::GitHub, "token", &[&provider], &store) + .await + .unwrap(); assert!(matches!(result, SyncResult::Success { version: 2, .. })); assert_eq!(store.get_sync_version().unwrap(), 2); } @@ -530,7 +595,10 @@ mod tests { let engine = SyncEngine::with_client(mock); let provider = MockProvider; let store = MockVersionStore::new(3); - let result = engine.force_upload(SyncPlatform::GitHub, "token", &[&provider], &store).await.unwrap(); + let result = engine + .force_upload(SyncPlatform::GitHub, "token", &[&provider], &store) + .await + .unwrap(); assert!(matches!(result, SyncResult::Success { version: 6, .. })); assert_eq!(store.get_sync_version().unwrap(), 6); } diff --git a/crates/zap_sync/src/types.rs b/crates/zap_sync/src/types.rs index 7387abeea56..41a88412a27 100644 --- a/crates/zap_sync/src/types.rs +++ b/crates/zap_sync/src/types.rs @@ -42,9 +42,17 @@ impl SyncPlatform { /// 同步结果 #[derive(Debug, Clone)] pub enum SyncResult { - Success { version: i64, platform: SyncPlatform }, - Conflict { local_version: i64, remote_version: i64 }, - AlreadyUpToDate { version: i64 }, + Success { + version: i64, + platform: SyncPlatform, + }, + Conflict { + local_version: i64, + remote_version: i64, + }, + AlreadyUpToDate { + version: i64, + }, } /// Gist 列表条目(API 返回) diff --git a/docs/superpowers/plans/2026-07-11-repository-workspaces.md b/docs/superpowers/plans/2026-07-11-repository-workspaces.md new file mode 100644 index 00000000000..016eb5627d6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-repository-workspaces.md @@ -0,0 +1,1255 @@ +# Repository Workspaces Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 Dogfood Feature Flag 下实现 repository → workspace 双层项目组织、独立 Git worktree、workspace 级完整页签集合和安全删除流程。 + +**Architecture:** 新增 `app/src/project_organization/` 领域模块和 SQLite 表;Git 操作集中到结构化服务。窗口根保留当前 `tabs: Vec` 作为活动 workspace 页签,通过活动/非活动集合整体交换复用现有 Tab、PaneGroup 和 Terminal 能力。 + +**Tech Stack:** Rust 2021、WarpUI Entity/View、Diesel + SQLite、`crates/command`、现有 `crates/integration` Builder/TestStep、Cargo nextest。 + +--- + +## Execution Preconditions + +- 执行前阅读并遵循 `specs/repository-workspaces/PRODUCT.md` 与 `TECH.md`。 +- 使用 `superpowers:using-git-worktrees` 在 `.worktrees/repository-workspaces` 创建隔离 worktree;若当前已经是 linked worktree,则继续使用当前隔离环境。 +- 基线验证运行 `cargo check`。若失败,记录原始失败并在获得用户确认前不继续实现。 +- UI 任务开始前重新读取 `warp-ui-guidelines`;逻辑任务使用 `superpowers:test-driven-development`;Feature Flag 使用 `add-feature-flag`;单测使用 `rust-unit-tests`;集成测试使用 `warp-integration-test`。 + +### Task 1: Add the Dogfood feature flag + +**Files:** +- Modify: `crates/warp_features/src/lib.rs` +- Create: `crates/warp_features/src/lib_tests.rs` + +- [ ] **Step 1: Write the failing flag placement test** + +```rust +use super::{FeatureFlag, DOGFOOD_FLAGS, PREVIEW_FLAGS, RELEASE_FLAGS}; + +#[test] +fn repository_workspaces_is_dogfood_only() { + assert!(DOGFOOD_FLAGS.contains(&FeatureFlag::RepositoryWorkspaces)); + assert!(!PREVIEW_FLAGS.contains(&FeatureFlag::RepositoryWorkspaces)); + assert!(!RELEASE_FLAGS.contains(&FeatureFlag::RepositoryWorkspaces)); +} +``` + +在 `lib.rs` 末尾注册: + +```rust +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: `cargo test -p warp_features repository_workspaces_is_dogfood_only` + +Expected: FAIL,因为 `FeatureFlag::RepositoryWorkspaces` 尚不存在。 + +- [ ] **Step 3: Add the enum variant and Dogfood placement** + +在 `FeatureFlag` 中按现有字母/主题顺序加入: + +```rust +RepositoryWorkspaces, +``` + +在 `DOGFOOD_FLAGS` 中加入: + +```rust +FeatureFlag::RepositoryWorkspaces, +``` + +- [ ] **Step 4: Run the test and check the crate** + +Run: + +```bash +cargo test -p warp_features repository_workspaces_is_dogfood_only +cargo check -p warp_features +``` + +Expected: PASS,且无 warning。 + +- [ ] **Step 5: Commit** + +```bash +git add crates/warp_features/src/lib.rs crates/warp_features/src/lib_tests.rs +git commit -m "feat: add repository workspaces flag" +``` + +### Task 2: Add persistence schema and row models + +**Files:** +- Create: `crates/persistence/migrations/2026-07-11-000000_add_repository_workspaces/up.sql` +- Create: `crates/persistence/migrations/2026-07-11-000000_add_repository_workspaces/down.sql` +- Modify: `crates/persistence/src/model.rs` +- Modify: `crates/persistence/src/schema.rs` (generated) +- Modify: `app/src/persistence/mod.rs` +- Modify: `app/src/persistence/sqlite.rs` +- Modify: `app/src/persistence/sqlite_tests.rs` + +- [ ] **Step 1: Add a failing repository row round-trip test** + +扩展 `sqlite_tests.rs`,断言 repository row 可通过新的 SQLite helper round-trip: + +```rust +#[test] +fn repository_rows_round_trip() { + let now = chrono::Utc::now().naive_utc(); + let repository = model::Repository { + id: uuid::Uuid::from_u128(7).to_string(), + display_name: "zap".to_string(), + path: "/tmp/zap".to_string(), + remote_url: None, + source: "local".to_string(), + created_at: now, + last_opened_at: now, + }; + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let mut conn = setup_database(&database_path).expect("database should initialize"); + save_repository(&mut conn, repository.clone()).expect("repository should save"); + assert_eq!(get_all_repositories(&mut conn).unwrap(), vec![repository]); +} +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: `cargo test -p warp --lib repository_rows_round_trip` + +Expected: FAIL,因为 repository 表和 row API 尚不存在。 + +- [ ] **Step 3: Create the migration** + +`up.sql` 使用以下结构: + +```sql +CREATE TABLE repositories ( + id TEXT PRIMARY KEY NOT NULL, + display_name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + remote_url TEXT, + source TEXT NOT NULL CHECK (source IN ('local', 'cloned')), + created_at TIMESTAMP NOT NULL, + last_opened_at TIMESTAMP NOT NULL +); + +INSERT INTO repositories (id, display_name, path, source, created_at, last_opened_at) +SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || + substr(lower(hex(randomblob(2))), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))), + path, + path, + 'local', + added_ts, + coalesce(last_opened_ts, added_ts) +FROM projects; + +CREATE TABLE repository_workspaces ( + id TEXT PRIMARY KEY NOT NULL, + repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT, + display_name TEXT NOT NULL, + branch TEXT NOT NULL, + worktree_path TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + last_opened_at TIMESTAMP NOT NULL, + UNIQUE(repository_id, branch) +); + +CREATE TABLE repository_workspace_window_states ( + window_id INTEGER NOT NULL REFERENCES windows(id) ON DELETE CASCADE, + repository_workspace_id TEXT NOT NULL REFERENCES repository_workspaces(id) ON DELETE CASCADE, + active_tab_index INTEGER NOT NULL, + PRIMARY KEY(window_id, repository_workspace_id) +); + +ALTER TABLE tabs ADD COLUMN repository_workspace_id TEXT + REFERENCES repository_workspaces(id) ON DELETE SET NULL; +ALTER TABLE windows ADD COLUMN active_repository_workspace_id TEXT + REFERENCES repository_workspaces(id) ON DELETE SET NULL; +``` + +首次加载时,Rust 模型把 `display_name == path` 的迁移行更新为目录 basename。第一条 migration 暂时保留 `projects`,让旧模型和新表在迁移提交中同时可编译。`down.sql` 使用项目 SQLite 版本支持的 `DROP COLUMN`: + +```sql +ALTER TABLE tabs DROP COLUMN repository_workspace_id; +ALTER TABLE windows DROP COLUMN active_repository_workspace_id; +DROP TABLE repository_workspace_window_states; +DROP TABLE repository_workspaces; +DROP TABLE repositories; +``` + +- [ ] **Step 4: Add Diesel row types and persistence events** + +在 `crates/persistence/src/model.rs` 添加: + +```rust +#[derive(Clone, Debug, Eq, Identifiable, Insertable, PartialEq, Queryable, AsChangeset)] +#[diesel(table_name = repositories)] +pub struct Repository { + pub id: String, + pub display_name: String, + pub path: String, + pub remote_url: Option, + pub source: String, + pub created_at: NaiveDateTime, + pub last_opened_at: NaiveDateTime, +} + +#[derive(Clone, Debug, Eq, Identifiable, Insertable, PartialEq, Queryable, AsChangeset)] +#[diesel(table_name = repository_workspaces)] +pub struct RepositoryWorkspace { + pub id: String, + pub repository_id: String, + pub display_name: String, + pub branch: String, + pub worktree_path: String, + pub created_at: NaiveDateTime, + pub last_opened_at: NaiveDateTime, +} +``` + +在 `ModelEvent` 添加显式 CRUD 变体,不使用通用 JSON: + +```rust +UpsertRepository { repository: model::Repository }, +DeleteRepository { repository_id: String }, +UpsertRepositoryWorkspace { workspace: model::RepositoryWorkspace }, +DeleteRepositoryWorkspace { workspace_id: String }, +``` + +- [ ] **Step 5: Regenerate schema and implement SQLite handlers** + +Run: + +```bash +tmp_db="$(mktemp -t zap-repository-workspaces.XXXXXX.db)" +DATABASE_URL="$tmp_db" diesel migration run +DATABASE_URL="$tmp_db" diesel print-schema +rm -f "$tmp_db" +``` + +实现 `save_repository`、`delete_repository`、`save_repository_workspace`、`delete_repository_workspace`,并在加载结构中返回两类行。 + +- [ ] **Step 6: Run persistence tests and check generated diff** + +Run: + +```bash +cargo test -p warp --lib repository_rows_round_trip +cargo test -p warp --lib persistence::sqlite_tests +git diff --check +``` + +Expected: PASS;`schema.rs` 只包含 migration 对应的生成变化。 + +- [ ] **Step 7: Commit** + +```bash +git add crates/persistence app/src/persistence +git commit -m "feat: persist repository workspaces" +``` + +### Task 3: Add domain types and repository model + +**Files:** +- Create: `app/src/project_organization/mod.rs` +- Create: `app/src/project_organization/domain.rs` +- Create: `app/src/project_organization/model.rs` +- Create: `app/src/project_organization/model_tests.rs` +- Modify: `app/src/lib.rs` +- Modify: `app/src/search/command_search/projects/project_data_source.rs` +- Modify: `app/src/pane_group/pane/welcome_view.rs` +- Modify: `app/src/terminal/view.rs` +- Create: `crates/persistence/migrations/2026-07-11-010000_drop_legacy_projects/up.sql` +- Create: `crates/persistence/migrations/2026-07-11-010000_drop_legacy_projects/down.sql` +- Modify: `crates/persistence/src/model.rs` +- Modify: `crates/persistence/src/schema.rs` (generated) +- Modify: `app/src/persistence/mod.rs` +- Modify: `app/src/persistence/sqlite.rs` + +- [ ] **Step 1: Write failing CRUD and uniqueness tests** + +```rust +#[test] +fn repository_paths_and_workspace_branches_are_unique() { + let tempdir = tempfile::tempdir().unwrap(); + let mut model = ProjectOrganizationModel::new(Vec::new(), Vec::new(), None); + let repository = model.add_local_repository(tempdir.path().to_path_buf()).unwrap(); + assert!(matches!( + model.add_local_repository(tempdir.path().to_path_buf()), + Err(ProjectOrganizationError::RepositoryAlreadyExists { .. }) + )); + + let workspace = RepositoryWorkspace { + id: RepositoryWorkspaceId(uuid::Uuid::from_u128(2)), + repository_id: repository.id, + display_name: "feature/a".to_string(), + branch: "feature/a".to_string(), + worktree_path: tempdir.path().join("worktree-a"), + created_at: chrono::Utc::now().naive_utc(), + last_opened_at: chrono::Utc::now().naive_utc(), + }; + model.insert_workspace(workspace.clone()).unwrap(); + assert!(matches!( + model.insert_workspace(RepositoryWorkspace { + id: RepositoryWorkspaceId(uuid::Uuid::from_u128(3)), + ..workspace + }), + Err(ProjectOrganizationError::WorkspaceBranchAlreadyExists { .. }) + )); +} +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `cargo test -p warp --lib project_organization::model_tests` + +Expected: FAIL,模块不存在。 + +- [ ] **Step 3: Implement stable IDs, entities, and errors** + +```rust +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct RepositoryId(pub uuid::Uuid); + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct RepositoryWorkspaceId(pub uuid::Uuid); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RepositorySource { + Local, + Cloned, +} + +#[derive(Debug, thiserror::Error)] +pub enum ProjectOrganizationError { + #[error("repository already exists at {path}")] + RepositoryAlreadyExists { path: PathBuf }, + #[error("branch {branch} already has a workspace")] + WorkspaceBranchAlreadyExists { branch: String }, + #[error("repository {repository_id:?} still has workspaces")] + RepositoryHasWorkspaces { repository_id: RepositoryId }, + #[error(transparent)] + Persistence(#[from] anyhow::Error), +} +``` + +- [ ] **Step 4: Implement `ProjectOrganizationModel` and replace project consumers** + +模型使用 canonical path 索引 repository,使用 `(RepositoryId, branch)` 索引 workspace,并通过明确事件通知 UI: + +```rust +pub enum ProjectOrganizationEvent { + RepositoryAdded(RepositoryId), + RepositoryUpdated(RepositoryId), + RepositoryRemoved(RepositoryId), + WorkspaceAdded(RepositoryWorkspaceId), + WorkspaceUpdated(RepositoryWorkspaceId), + WorkspaceRemoved(RepositoryWorkspaceId), +} +``` + +将搜索、欢迎页和终端的 `ProjectManagementModel::upsert_project` 调用改为新模型的 `touch_repository_path`。Flag 关闭时这些消费者仍读取新 repository 数据,不保留双写旧 projects 表。 + +所有消费者切换后添加第二条 migration: + +```sql +-- up.sql +DROP TABLE projects; + +-- down.sql +CREATE TABLE projects ( + path TEXT NOT NULL PRIMARY KEY, + added_ts DATETIME NOT NULL, + last_opened_ts DATETIME +); +INSERT INTO projects (path, added_ts, last_opened_ts) +SELECT path, created_at, last_opened_at FROM repositories; +``` + +删除 `model::Project`、`ModelEvent::UpsertProject/DeleteProject` 和旧 SQLite handlers,然后重新运行 `diesel print-schema`。 + +- [ ] **Step 5: Run tests and check app** + +Run: + +```bash +tmp_db="$(mktemp -t zap-repository-workspaces.XXXXXX.db)" +DATABASE_URL="$tmp_db" diesel migration run +DATABASE_URL="$tmp_db" diesel print-schema +rm -f "$tmp_db" +cargo test -p warp --lib project_organization::model_tests +cargo check -p warp +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/src/project_organization app/src/lib.rs app/src/search app/src/pane_group/pane/welcome_view.rs app/src/terminal/view.rs app/src/persistence crates/persistence +git commit -m "feat: add repository workspace model" +``` + +### Task 4: Implement repository validation, clone, and ref discovery + +**Files:** +- Create: `app/src/project_organization/git.rs` +- Create: `app/src/project_organization/git_tests.rs` +- Modify: `app/src/project_organization/mod.rs` + +- [ ] **Step 1: Write failing tests using temporary Git repositories** + +```rust +struct GitFixture { + tempdir: tempfile::TempDir, + root: PathBuf, +} + +impl GitFixture { + fn new() -> Self { + let tempdir = tempfile::tempdir().unwrap(); + let root = tempdir.path().join("repo"); + std::fs::create_dir(&root).unwrap(); + run_git(&root, &["init", "-b", "main"]); + std::fs::write(root.join("README.md"), "fixture").unwrap(); + run_git(&root, &["add", "README.md"]); + run_git(&root, &["-c", "user.name=Zap Tests", "-c", "user.email=zap@example.com", "commit", "-m", "init"]); + Self { tempdir, root } + } + + fn add_linked_worktree(&self, branch: &str) -> PathBuf { + let path = self.tempdir.path().join(branch.replace('/', "-")); + run_git(&self.root, &["worktree", "add", "-b", branch, path.to_str().unwrap()]); + path + } +} + +fn run_git(cwd: &Path, args: &[&str]) { + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +#[test] +fn rejects_linked_worktree_as_repository() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/a"); + let error = validate_repository(&worktree_path).unwrap_err(); + assert!(matches!(error, GitWorkspaceError::LinkedWorktree { .. })); +} + +#[test] +fn classifies_local_and_remote_refs_without_prefix_guessing() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "origin/foo"]); + let refs = list_branch_refs(&fixture.root).unwrap(); + assert!(refs.iter().any(|r| matches!(r, BranchRef::Local { name } if name == "origin/foo"))); +} +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `cargo test -p warp --lib project_organization::git_tests` + +- [ ] **Step 3: Implement typed command execution and errors** + +```rust +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BranchRef { + Local { name: String, full_ref: String }, + Remote { remote: String, name: String, full_ref: String }, +} + +fn git_output(repo: &Path, args: &[&str]) -> Result { + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output()?; + if output.status.success() { + Ok(output) + } else { + Err(GitWorkspaceError::CommandFailed { + args: args.iter().map(|arg| (*arg).to_string()).collect(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) + } +} +``` + +实现 `validate_repository`、`clone_repository`、`fetch_and_list_refs`、`list_worktrees` 和默认分支解析。异步 UI API 包装 blocking 实现,不在 UI 线程执行 Git。 + +- [ ] **Step 4: Add clone cleanup and safe path tests** + +覆盖已存在目标目录不删除、本次创建空目录失败后清理、带空格/引号路径、URL repository 名解析,以及 safe branch slug: + +```rust +assert_eq!(workspace_dir_name("feature/a b", "12345678"), "feature-a-b-12345678"); +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +cargo test -p warp --lib project_organization::git_tests +cargo check -p warp +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/src/project_organization +git commit -m "feat: add repository git service" +``` + +### Task 5: Implement worktree creation and deletion preflight + +**Files:** +- Modify: `app/src/project_organization/git.rs` +- Modify: `app/src/project_organization/git_tests.rs` + +- [ ] **Step 1: Write failing creation tests** + +```rust +#[test] +fn creates_new_branch_from_remote_ref_without_tracking() { + let fixture = GitFixture::new(); + let bare = fixture.tempdir.path().join("remote.git"); + run_git(fixture.tempdir.path(), &["init", "--bare", bare.to_str().unwrap()]); + run_git(&fixture.root, &["remote", "add", "origin", bare.to_str().unwrap()]); + run_git(&fixture.root, &["push", "-u", "origin", "main"]); + run_git(&fixture.root, &["fetch", "origin"]); + let path = fixture.tempdir.path().join("worktree"); + create_from_remote(&fixture.root, "refs/remotes/origin/main", "feature/a", &path).unwrap(); + assert_eq!(current_branch(&path).unwrap(), "feature/a"); + assert_eq!(branch_upstream(&fixture.root, "feature/a").unwrap(), None); +} + +#[test] +fn reports_the_path_that_already_checks_out_a_local_branch() { + let fixture = GitFixture::new(); + let occupied = fixture.add_linked_worktree("feature/a"); + let error = create_from_local(&fixture.root, "feature/a", fixture.tempdir.path().join("second")) + .unwrap_err(); + assert!(matches!( + error, + GitWorkspaceError::BranchAlreadyCheckedOut { path, .. } if path == occupied + )); +} +``` + +- [ ] **Step 2: Verify RED, then implement minimal creation APIs** + +```rust +pub fn create_from_remote( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, +) -> Result<(), GitWorkspaceError>; + +pub fn create_from_local( + repository: &Path, + local_branch: &str, + worktree_path: &Path, +) -> Result<(), GitWorkspaceError>; +``` + +Commands must be argument arrays equivalent to: + +```text +git -C worktree add --no-track -b +git -C worktree add refs/heads/ +``` + +- [ ] **Step 3: Write failing deletion safety tests** + +```rust +#[test] +fn dirty_worktree_blocks_deletion_before_mutation() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/a"); + std::fs::write(worktree_path.join("dirty.txt"), "dirty").unwrap(); + assert!(matches!( + deletion_preflight(&fixture.root, &worktree_path, true), + Err(GitWorkspaceError::DirtyWorktree { .. }) + )); + assert!(worktree_path.exists()); +} +``` + +- [ ] **Step 4: Implement preflight and delete APIs** + +```rust +pub struct DeletionPreflight { + pub branch: String, + pub is_merged: bool, + pub merge_target: String, +} + +pub fn remove_workspace( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, +) -> Result<(), GitWorkspaceError>; +``` + +先执行 `status --porcelain`、worktree/branch 一致性和 merge-base 检查;只有调用 `remove_workspace` 时才执行 `worktree remove` 与 `branch -d/-D`。 + +- [ ] **Step 5: Run tests and commit** + +```bash +cargo test -p warp --lib project_organization::git_tests +git add app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +git commit -m "feat: manage repository worktrees safely" +``` + +### Task 6: Persist workspace-scoped Tab snapshots + +**Files:** +- Modify: `app/src/app_state.rs` +- Modify: `app/src/tab.rs` +- Modify: `app/src/workspace/view.rs` +- Modify: `app/src/persistence/sqlite.rs` +- Modify: `app/src/persistence/sqlite_tests.rs` +- Modify: `app/src/launch_configs/launch_config.rs` +- Modify: `app/src/launch_configs/launch_config_tests.rs` + +- [ ] **Step 1: Add failing snapshot grouping tests** + +```rust +#[test] +fn repository_workspace_state_round_trips() { + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(7)); + let mut window = test_terminal_window_snapshot(false); + window.active_repository_workspace_id = Some(workspace_id); + window.repository_workspace_states = vec![RepositoryWorkspaceWindowStateSnapshot { + repository_workspace_id: workspace_id, + active_tab_index: 0, + }]; + window.tabs[0].repository_workspace_id = Some(workspace_id); + + let state = AppState { + windows: vec![window], + active_window_index: Some(0), + block_lists: Default::default(), + running_mcp_servers: Default::default(), + }; + let tempdir = tempfile::tempdir().unwrap(); + let mut conn = setup_database(&tempdir.path().join("warp.sqlite")).unwrap(); + save_app_state(&mut conn, &state).unwrap(); + let restored = read_sqlite_data(&mut conn, None).unwrap().app_state; + + assert_eq!(restored, state); +} +``` + +- [ ] **Step 2: Verify RED and extend snapshot types** + +```rust +pub struct RepositoryWorkspaceWindowStateSnapshot { + pub repository_workspace_id: RepositoryWorkspaceId, + pub active_tab_index: usize, +} + +pub struct WindowSnapshot { + pub tabs: Vec, + pub active_repository_workspace_id: Option, + pub repository_workspace_states: Vec, + // existing fields unchanged +} + +pub struct TabSnapshot { + pub repository_workspace_id: Option, + // existing fields unchanged +} +``` + +在 `TabData` 和 `TransferredTab` 添加相同归属字段;LaunchConfig 转换显式把归属设为 `None`,因为配置模板不是 repository workspace 实例。 + +- [ ] **Step 3: Update SQLite save/restore in one transaction** + +保存时扁平写入所有 TabSnapshot,并写 `tabs.repository_workspace_id`。获得新 `window_id` 后插入 `repository_workspace_window_states`。恢复时解析 UUID,非法值返回持久化错误而不是静默丢弃。 + +- [ ] **Step 4: Run snapshot and persistence tests** + +Run: + +```bash +cargo test -p warp --lib repository_workspace_state_round_trips +cargo test -p warp --lib launch_configs::launch_config_tests +cargo test -p warp --lib persistence::sqlite_tests +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/src/app_state.rs app/src/tab.rs app/src/workspace/view.rs app/src/persistence app/src/launch_configs +git commit -m "feat: persist workspace tab ownership" +``` + +### Task 7: Add active/inactive workspace Tab collections + +**Files:** +- Create: `app/src/workspace/repository_workspace_tabs.rs` +- Create: `app/src/workspace/repository_workspace_tabs_tests.rs` +- Modify: `app/src/workspace/mod.rs` +- Modify: `app/src/workspace/view.rs` +- Modify: `app/src/workspace/cross_window_tab_drag.rs` +- Modify: `app/src/root_view.rs` + +- [ ] **Step 1: Write failing collection swap tests** + +```rust +#[test] +fn switching_workspaces_swaps_tabs_without_dropping_pane_groups() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut active_tabs = vec![10_u64]; + let mut active_tab_index = 0; + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive(Some(workspace_b), RepositoryWorkspaceTabState::new(vec![20_u64], 0)); + sets.switch_to(Some(workspace_b), &mut active_tabs, &mut active_tab_index); + assert_eq!(active_tabs, vec![20]); + sets.switch_to(Some(workspace_a), &mut active_tabs, &mut active_tab_index); + assert_eq!(active_tabs, vec![10]); +} + +#[test] +fn each_window_remembers_an_active_tab_per_workspace() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut active_tabs = vec![10_u64, 11, 12]; + let mut active_tab_index = 0; + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive(Some(workspace_b), RepositoryWorkspaceTabState::new(vec![20_u64], 0)); + active_tab_index = 2; + sets.switch_to(Some(workspace_b), &mut active_tabs, &mut active_tab_index); + sets.switch_to(Some(workspace_a), &mut active_tabs, &mut active_tab_index); + assert_eq!(active_tab_index, 2); +} +``` + +- [ ] **Step 2: Verify RED and implement the focused helper** + +```rust +pub struct RepositoryWorkspaceTabState { + pub tabs: Vec, + pub active_tab_index: usize, +} + +pub struct RepositoryWorkspaceTabSets { + active_workspace_id: Option, + inactive: HashMap, RepositoryWorkspaceTabState>, +} +``` + +该泛型 helper 只负责交换状态、索引 clamp、快照遍历和归属断言;生产代码使用 `RepositoryWorkspaceTabSets`,PaneGroup 创建/关闭仍由 `Workspace` 负责。 + +- [ ] **Step 3: Integrate with `Workspace`** + +新增 `switch_repository_workspace`、`all_repository_workspace_tabs` 和 `active_repository_workspace_id`。所有新建 TabData 使用当前 workspace id。活动集合为空时渲染项目空态,并让依赖 `active_tab_pane_group()` 的 actions 提前 no-op/disabled;不要添加虚假终端 fallback。 + +- [ ] **Step 4: Preserve ownership across cross-window drag** + +`TransferredTab` 携带 workspace id。插入目标窗口前调用 `switch_repository_workspace`,然后按现有插入索引逻辑插入并激活。 + +- [ ] **Step 5: Run focused view tests** + +Run: + +```bash +cargo test -p warp --lib workspace::repository_workspace_tabs_tests +cargo test -p warp --lib workspace::view_test +cargo test -p warp --lib workspace::view::vertical_tabs_tests +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/src/workspace app/src/root_view.rs +git commit -m "feat: scope tabs to repository workspaces" +``` + +### Task 8: Implement startup migration and reconciliation + +**Files:** +- Create: `app/src/project_organization/migration.rs` +- Create: `app/src/project_organization/migration_tests.rs` +- Modify: `app/src/project_organization/mod.rs` +- Modify: `app/src/lib.rs` + +- [ ] **Step 1: Write failing migration tests** + +```rust +#[test] +fn linked_worktree_tabs_migrate_together() { + let identity = WorktreeIdentity { + repository_path: PathBuf::from("/repo"), + worktree_path: PathBuf::from("/repo-worktrees/feature-a"), + branch: "feature/a".to_string(), + }; + assert_eq!(classify_tab_worktree([Some(identity.clone()), Some(identity.clone())]), Some(identity)); +} + +#[test] +fn mixed_worktree_tab_remains_unclassified() { + let first = WorktreeIdentity { + repository_path: PathBuf::from("/repo"), + worktree_path: PathBuf::from("/worktree-a"), + branch: "feature/a".to_string(), + }; + let second = WorktreeIdentity { + repository_path: PathBuf::from("/repo"), + worktree_path: PathBuf::from("/worktree-b"), + branch: "feature/b".to_string(), + }; + assert_eq!(classify_tab_worktree([Some(first), Some(second)]), None); +} +``` + +- [ ] **Step 2: Verify RED and implement deterministic classification** + +对每个 TabSnapshot 收集所有 terminal cwd,解析 `--show-toplevel`、`--git-dir` 和 `--git-common-dir`。只有所有可识别 cwd 指向同一 linked worktree 时才分配;主 checkout、多 worktree、非 Git 或错误全部返回 `None`。 + +生产类型与纯分类函数固定为: + +```rust +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorktreeIdentity { + pub repository_path: PathBuf, + pub worktree_path: PathBuf, + pub branch: String, +} + +pub fn classify_tab_worktree( + identities: impl IntoIterator>, +) -> Option { + let mut identities = identities.into_iter().flatten(); + let first = identities.next()?; + identities.all(|identity| identity == first).then_some(first) +} +``` + +- [ ] **Step 3: Add idempotency and external-state reconciliation** + +使用 repository path 和 `(repository_id, branch)` 唯一索引复用既有记录。启动时返回明确状态: + +```rust +pub enum WorkspaceHealth { + Ready, + RepositoryMissing, + WorktreeMissing, + BranchMissing, + WorktreeBranchMismatch { actual: String }, +} +``` + +- [ ] **Step 4: Run tests and commit** + +```bash +cargo test -p warp --lib project_organization::migration_tests +git add app/src/project_organization app/src/lib.rs +git commit -m "feat: migrate repository workspace state" +``` + +### Task 9: Build the repository/workspace tree + +**Files:** +- Create: `app/src/project_organization/view/mod.rs` +- Create: `app/src/project_organization/view/project_tree.rs` +- Create: `app/src/project_organization/view/project_tree_tests.rs` +- Modify: `app/src/workspace/view.rs` +- Modify: `app/src/workspace/action.rs` +- Modify: `app/src/workspace/tab_settings.rs` +- Modify: `app/i18n/en/warp.ftl` +- Modify: `app/i18n/zh-CN/warp.ftl` +- Modify: `app/i18n/ja/warp.ftl` + +- [ ] **Step 1: Write failing tree interaction tests** + +```rust +#[test] +fn tree_renders_two_levels_and_selects_workspace() { + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut state = ProjectTreeState::new(vec![RepositoryTreeNode { + repository_id, + display_name: "zap".to_string(), + expanded: true, + workspaces: vec![WorkspaceTreeNode { + workspace_id, + display_name: "Feature A".to_string(), + branch: "feature/a".to_string(), + tab_count: 2, + }], + }]); + assert_eq!(state.visible_rows().len(), 2); + state.select_workspace(workspace_id); + assert_eq!(state.selected_workspace_id(), Some(workspace_id)); +} + +#[test] +fn flag_keeps_vertical_tabs_setting_but_uses_horizontal_tabbar() { + assert_eq!( + resolved_project_organization_tab_layout(true, true), + TabLayout::Horizontal + ); + assert_eq!( + resolved_project_organization_tab_layout(false, true), + TabLayout::Vertical + ); +} +``` + +- [ ] **Step 2: Verify RED and build the tree with existing components** + +先实现纯状态类型 `ProjectTreeState`、`RepositoryTreeNode`、`WorkspaceTreeNode` 和 `TabLayout`,再让 View 渲染这些状态。Tree rows use existing theme accessors and button themes. Add icon-only plus/more buttons with Tooltip. Do not add a feature-specific `ActionButtonTheme`. + +- [ ] **Step 3: Integrate the panel in the main window** + +当 Flag 开启时,左侧可调整区域渲染 `ProjectTree`,`uses_vertical_tabs` 返回 false 但不写回 `TabSettings::use_vertical_tabs`。workspace 选择事件调用 `Workspace::switch_repository_workspace`。 + +- [ ] **Step 4: Add empty, loading, and health states** + +实现无 repository、无 workspace、无 tabs、Git 操作中和 `WorkspaceHealth` 错误状态。所有文本走现有 i18n 机制;新增 key 同步写入 `app/i18n/en/warp.ftl`、`app/i18n/zh-CN/warp.ftl` 和 `app/i18n/ja/warp.ftl`,不得硬编码英文。 + +- [ ] **Step 5: Run UI tests and commit** + +```bash +cargo test -p warp --lib project_organization::view::project_tree_tests +cargo test -p warp --lib workspace::view_test +git add app/src/project_organization app/src/workspace app/i18n +git commit -m "feat: add repository workspace tree" +``` + +### Task 10: Add repository and create workspace modals + +**Files:** +- Create: `app/src/project_organization/view/add_repository_modal.rs` +- Create: `app/src/project_organization/view/add_repository_modal_tests.rs` +- Create: `app/src/project_organization/view/create_workspace_modal.rs` +- Create: `app/src/project_organization/view/create_workspace_modal_tests.rs` +- Modify: `app/src/project_organization/view/mod.rs` +- Modify: `app/src/workspace/view.rs` + +- [ ] **Step 1: Write failing modal state tests** + +```rust +#[test] +fn switching_creation_mode_clears_stale_branch_selection() { + let mut state = CreateWorkspaceForm::remote("refs/remotes/origin/main", "feature/a"); + state.switch_mode(CreateWorkspaceMode::LocalBranch); + assert_eq!(state.remote_ref, None); + assert_eq!(state.new_branch_name, None); +} + +#[test] +fn delete_branch_is_not_part_of_create_form() { + let state = CreateWorkspaceForm::default(); + assert!(state.validate().is_err()); +} +``` + +- [ ] **Step 2: Verify RED and implement form models first** + +使用显式 enum: + +```rust +pub enum AddRepositoryMode { LocalDirectory, GitUrl } +pub enum CreateWorkspaceMode { RemoteBase, LocalBranch } +``` + +表单校验返回结构化字段错误;视图只负责渲染和发事件。 + +- [ ] **Step 3: Implement async orchestration in the model** + +Add Repository 调用 validate/clone 后写 model。Create Workspace 按 TECH 顺序执行 preflight → worktree → DB → 首个终端。失败时调用补偿函数,并把错误保留在 modal 中。 + +- [ ] **Step 4: Wire the first terminal to the worktree path** + +复用 `NewTerminalOptions`/现有新 session API,显式传入 workspace path;禁止通过创建 TabConfig 再执行 `cd` 命令。 + +- [ ] **Step 5: Run modal and workspace tests** + +```bash +cargo test -p warp --lib add_repository_modal_tests +cargo test -p warp --lib create_workspace_modal_tests +cargo test -p warp --lib workspace::view_test +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/src/project_organization app/src/workspace/view.rs +git commit -m "feat: create repositories and workspaces" +``` + +### Task 11: Add safe workspace and repository removal UI + +**Files:** +- Create: `app/src/project_organization/view/delete_workspace_dialog.rs` +- Create: `app/src/project_organization/view/delete_workspace_dialog_tests.rs` +- Create: `app/src/project_organization/view/remove_repository_dialog.rs` +- Modify: `app/src/project_organization/view/mod.rs` +- Modify: `app/src/project_organization/model.rs` +- Modify: `app/src/workspace/view.rs` + +- [ ] **Step 1: Write failing confirmation tests** + +```rust +#[test] +fn delete_branch_is_checked_by_default() { + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + assert!(DeleteWorkspaceDialogState::new(workspace_id).delete_branch); +} + +#[test] +fn unmerged_branch_requires_second_confirmation_before_closing_tabs() { + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let mut flow = DeleteWorkspaceFlow::new(workspace_id); + flow.apply_preflight(DeletionPreflight { + branch: "feature/a".to_string(), + is_merged: false, + merge_target: "main".to_string(), + }); + assert_eq!(flow.next_action(), DeleteWorkspaceAction::ConfirmForceBranchDelete); + assert!(!flow.may_close_tabs()); +} +``` + +- [ ] **Step 2: Verify RED and implement the state machine** + +```rust +pub enum DeleteWorkspaceAction { + RunPreflight, + ConfirmForceBranchDelete, + CloseTabsAndDelete, + Finished, +} +``` + +只有进入 `CloseTabsAndDelete` 后才关闭 PaneGroup/terminal。dirty、missing 或 mismatch 错误停留在 dialog,并保持磁盘和运行时状态。 + +- [ ] **Step 3: Implement repository removal rules** + +有 workspace 时禁用移除。Cloned repository 显示“同时删除本地目录”且默认 false;Local repository 永不显示目录删除选项。 + +- [ ] **Step 4: Run tests and commit** + +```bash +cargo test -p warp --lib delete_workspace_dialog_tests +cargo test -p warp --lib project_organization::model_tests +git add app/src/project_organization app/src/workspace/view.rs +git commit -m "feat: remove repository workspaces safely" +``` + +### Task 12: Add end-to-end integration coverage + +**Files:** +- Create: `app/src/integration_testing/repository_workspaces/mod.rs` +- Create: `app/src/integration_testing/repository_workspaces/step.rs` +- Create: `app/src/integration_testing/repository_workspaces/assertion.rs` +- Modify: `app/src/integration_testing/mod.rs` +- Create: `crates/integration/src/test/repository_workspaces.rs` +- Modify: `crates/integration/src/test.rs` +- Modify: `crates/integration/src/bin/integration.rs` + +- [ ] **Step 1: Add a failing local repository flow** + +Build steps that create a temporary bare remote and local clone, enable the Feature Flag, add repository, create remote-base workspace, open three terminal tabs, switch away/back, and assert all session IDs still exist。 + +```rust +pub fn test_repository_workspace_remote_flow() -> Builder { + let mut builder = new_builder(); + builder.add_steps(vec![ + setup_repository_fixture("repository fixture"), + enable_repository_workspaces_flag(), + add_local_repository("repository fixture"), + create_remote_base_workspace("origin/main", "feature/a"), + add_workspace_terminal_tab(), + add_workspace_terminal_tab(), + assert_active_workspace("feature/a"), + assert_workspace_tab_count("feature/a", 3), + switch_to_unclassified_tabs(), + switch_to_workspace("feature/a"), + assert_workspace_sessions_alive("feature/a", 3), + ]); + builder +} +``` + +在 `step.rs` 定义动作并通过 Builder data 保存 fixture 路径/ID。核心 fixture step 使用真实 Git 命令和临时 HOME: + +```rust +pub struct RepositoryWorkspaceFixture { + pub tempdir: tempfile::TempDir, + pub repository_path: PathBuf, + pub remote_path: PathBuf, +} + +impl RepositoryWorkspaceFixture { + pub fn new() -> Self { + let tempdir = tempfile::tempdir().unwrap(); + let repository_path = tempdir.path().join("repository"); + let remote_path = tempdir.path().join("remote.git"); + run_fixture_git(tempdir.path(), &["init", "--bare", remote_path.to_str().unwrap()]); + run_fixture_git(tempdir.path(), &["clone", remote_path.to_str().unwrap(), repository_path.to_str().unwrap()]); + run_fixture_git(&repository_path, &["switch", "-c", "main"]); + std::fs::write(repository_path.join("README.md"), "fixture").unwrap(); + run_fixture_git(&repository_path, &["add", "README.md"]); + run_fixture_git(&repository_path, &["-c", "user.name=Zap Tests", "-c", "user.email=zap@example.com", "commit", "-m", "init"]); + run_fixture_git(&repository_path, &["push", "-u", "origin", "main"]); + Self { tempdir, repository_path, remote_path } + } +} + +fn run_fixture_git(cwd: &Path, args: &[&str]) { + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +pub fn setup_repository_fixture(key: &'static str) -> TestStep { + TestStep::new("Set up repository workspace fixture").with_action(move |_, _, data| { + let fixture = RepositoryWorkspaceFixture::new(); + data.insert(key, fixture); + }) +} + +pub fn enable_repository_workspaces_flag() -> TestStep { + TestStep::new("Enable repository workspaces").with_action(|app, _, _| { + app.update(|_| FeatureFlag::RepositoryWorkspaces.set_enabled(true)); + }) +} +``` + +在 `assertion.rs` 实现 `assert_active_workspace`、`assert_workspace_tab_count` 和 `assert_workspace_sessions_alive`;`crates/integration/src/test.rs` 添加 `mod repository_workspaces;` 与 `pub use repository_workspaces::*;`,runner 为每个测试函数添加 `register_test!`。 + +- [ ] **Step 2: Run the single integration test and verify RED** + +Run: + +```bash +WARPUI_USE_REAL_DISPLAY_IN_INTEGRATION_TESTS=1 \ +RUST_BACKTRACE=full \ +WARP_SHELL_PATH=/bin/bash \ +cargo run -p integration --bin integration -- test_repository_workspace_remote_flow +``` + +Expected: FAIL,且失败必须指向缺失的 step/action wiring,而不是 fixture 初始化。 + +- [ ] **Step 3: Implement test helpers and all acceptance flows** + +添加以下具名 Builder,复用相同 fixture/step API: + +```rust +pub fn test_repository_workspace_clone_destination() -> Builder { + builder_with_steps(vec![setup_remote_fixture(), clone_to_edited_destination(), assert_clone_destination()]) +} + +pub fn test_repository_workspace_local_branch_conflict() -> Builder { + builder_with_steps(vec![setup_occupied_branch_fixture(), attempt_local_branch_workspace(), assert_branch_occupied_error()]) +} + +pub fn test_repository_workspace_migration() -> Builder { + builder_with_steps(vec![setup_linked_worktree_snapshot(), restart_app(), assert_migrated_and_unclassified_tabs()]) +} + +pub fn test_repository_workspace_restoration() -> Builder { + builder_with_steps(vec![create_two_workspaces(), select_second_workspace_tab(), restart_app(), assert_workspace_selection_restored()]) +} + +pub fn test_repository_workspace_deletion() -> Builder { + builder_with_steps(vec![create_dirty_and_clean_workspaces(), exercise_delete_confirmations(), assert_expected_branches_and_worktrees()]) +} + +pub fn test_repository_workspace_external_removal() -> Builder { + builder_with_steps(vec![create_workspace(), remove_worktree_outside_app(), restart_app(), assert_missing_worktree_health()]) +} + +fn builder_with_steps(steps: Vec) -> Builder { + let mut builder = new_builder(); + builder.add_steps(steps); + builder +} +``` + +`builder_with_steps` 是本测试文件中的小 helper:创建 `new_builder()`、调用 `add_steps` 并返回 Builder。所有引用的 step/assertion 函数分别在 `step.rs`/`assertion.rs` 中实现,名称与上面保持一致。 + +- [ ] **Step 4: Run integration tests** + +Run the new test module with `--no-fail-fast`, then run the nearest existing tab/workspace suites to detect regressions. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/integration_testing crates/integration +git commit -m "test: cover repository workspace flows" +``` + +### Task 13: Final cleanup, verification, and review + +**Files:** +- Modify only files required by compiler/test feedback +- Update: `specs/repository-workspaces/PRODUCT.md` +- Update: `specs/repository-workspaces/TECH.md` + +- [ ] **Step 1: Keep specs synchronized** + +Compare shipped behavior and module boundaries with both specs. Update them only for actual implementation decisions; do not leave stale paths, commands, or behavior. + +- [ ] **Step 2: Confirm old and new entrypoints are correctly gated** + +With Flag off, old worktree/TabConfig behavior remains available. With Flag on, tree-driven repository/workspace entrypoints are visible and Vertical Tabs is render-disabled without changing the stored setting. + +- [ ] **Step 3: Run formatting and focused tests** + +```bash +cargo fmt --all -- --check +cargo test -p warp_features repository_workspaces_is_dogfood_only +cargo test -p warp --lib project_organization +cargo test -p warp --lib workspace::repository_workspace_tabs_tests +cargo test -p warp --lib persistence::sqlite_tests +``` + +- [ ] **Step 4: Run full verification** + +```bash +cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2 +cargo check +``` + +Expected: exit 0。若存在已知无关失败,保存完整输出并运行所有受影响的针对性套件;不得把部分验证描述为全量通过。 + +- [ ] **Step 5: Review the diff** + +使用 `superpowers:requesting-code-review` 执行规格一致性和代码质量审查。若收到 review 反馈,先完整呈现给用户并等待确认要修复的项目,再按 `superpowers:receiving-code-review` 处理。 + +- [ ] **Step 6: Commit final fixes** + +```bash +git add specs/repository-workspaces app crates +git commit -m "feat: organize terminals by repository workspace" +``` + +- [ ] **Step 7: Complete branch handoff** + +使用 `superpowers:finishing-a-development-branch` 汇总验证证据,并提供合并、PR 或保留分支选项。 diff --git a/docs/superpowers/plans/2026-07-12-project-organization-persistence-ack.md b/docs/superpowers/plans/2026-07-12-project-organization-persistence-ack.md new file mode 100644 index 00000000000..9c70e69b36e --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-project-organization-persistence-ack.md @@ -0,0 +1,669 @@ +# Project Organization Persistence Acknowledgement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 消除 repository/workspace 路径恢复后的唯一性漏洞,并让领域 CRUD 仅在 SQLite writer 确认提交后更新内存和发送 UI event。 + +**Architecture:** `ProjectOrganizationModel` 使用统一 canonical path resolver 区分零匹配、唯一匹配和歧义。repository/workspace persistence 使用现有 SQLite writer 上的领域专用 request/response 事件;writer 返回 paused、database 或 channel 错误,模型只在 acknowledgement 成功后提交内存状态。 + +**Tech Stack:** Rust 2021、WarpUI Entity/ModelContext、Diesel 2.3 + SQLite、`std::sync::mpsc`、现有 persistence writer 和 Cargo tests。 + +--- + +## Execution Preconditions + +- Work from `/Users/admin/project/opensource/zap/.worktrees/repository-workspaces` on `feat/repository-workspaces`. +- Read `docs/superpowers/specs/2026-07-12-project-organization-persistence-ack-design.md` before editing. +- Use `superpowers:test-driven-development`, `rust-unit-tests`, and `superpowers:verification-before-completion`. +- Do not run full-workspace `cargo fmt`; format only explicitly changed Rust files and revert any unrelated formatting immediately. +- Existing baseline warnings are out of scope. + +### Task 1: Add deterministic canonical path resolution + +**Files:** +- Modify: `app/src/project_organization/domain.rs` +- Modify: `app/src/project_organization/model.rs` +- Modify: `app/src/project_organization/model_tests.rs` + +- [ ] **Step 1: Write failing recovered-alias ambiguity tests** + +Add tests that load two distinct missing aliases, create their parent directories and shared target after model initialization, then exercise the canonical path. + +```rust +#[test] +fn add_repository_rejects_ambiguous_recovered_aliases() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let target = tempdir.path().join("repository"); + let first_alias = tempdir.path().join("first").join("..").join("repository"); + let second_alias = tempdir.path().join("second").join("..").join("repository"); + let first_id = RepositoryId::from(Uuid::from_u128(1)); + let second_id = RepositoryId::from(Uuid::from_u128(2)); + let (model, _operations) = create_model( + &mut app, + vec![ + persisted_repository(first_id, &first_alias), + persisted_repository(second_id, &second_alias), + ], + vec![], + ); + std::fs::create_dir(tempdir.path().join("first")).unwrap(); + std::fs::create_dir(tempdir.path().join("second")).unwrap(); + std::fs::create_dir(&target).unwrap(); + + let error = model + .update(&mut app, |model, ctx| model.add_local_repository(&target, ctx)) + .unwrap_err(); + + assert!(matches!( + error, + ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path, + repository_ids, + } if canonical_path == dunce::canonicalize(&target).unwrap() + && repository_ids == vec![first_id, second_id] + )); + }); +} +``` + +Add the equivalent tests for: + +- `touch_repository_path` with two recovered repository aliases. +- `insert_workspace` with two recovered worktree aliases. +- `update_workspace` excluding its own ID while still rejecting two other matches. + +Use fixed UUIDs and assert sorted ID vectors so error output is deterministic. + +- [ ] **Step 2: Run the ambiguity tests and verify RED** + +Run: + +```bash +cargo test -p warp --lib project_organization::model::model_tests::add_repository_rejects_ambiguous_recovered_aliases +cargo test -p warp --lib project_organization::model::model_tests::touch_repository_rejects_ambiguous_recovered_aliases +cargo test -p warp --lib project_organization::model::model_tests::insert_workspace_rejects_ambiguous_recovered_aliases +``` + +Expected: FAIL because `add_local_repository` creates a duplicate and `touch_repository_path` selects a `HashMap` entry rather than returning ambiguity. + +- [ ] **Step 3: Add structured ambiguity errors** + +Add to `ProjectOrganizationError`: + +```rust +#[error("canonical repository path `{canonical_path}` matches multiple repositories: {repository_ids:?}")] +AmbiguousRepositoryPath { + canonical_path: PathBuf, + repository_ids: Vec, +}, + +#[error("canonical worktree path `{canonical_path}` matches multiple workspaces: {workspace_ids:?}")] +AmbiguousWorkspacePath { + canonical_path: PathBuf, + workspace_ids: Vec, +}, +``` + +- [ ] **Step 4: Implement one canonical resolver per domain collection** + +Add a private result type in `model.rs`: + +```rust +enum CanonicalPathMatch { + None, + Unique(Id), + Ambiguous(Vec), +} +``` + +Implement deterministic resolvers. Repository IDs must be sorted by their UUID value before returning ambiguity. + +```rust +fn repository_match_for_canonical_path( + &self, + canonical_path: &Path, + excluded_id: Option, +) -> CanonicalPathMatch { + let mut matches = self + .repositories + .iter() + .filter_map(|(repository_id, repository)| { + if Some(*repository_id) == excluded_id { + return None; + } + let candidate = dunce::canonicalize(&repository.path).ok()?; + (candidate == canonical_path).then_some(*repository_id) + }) + .collect::>(); + matches.sort_by_key(|id| id.0); + match matches.as_slice() { + [] => CanonicalPathMatch::None, + [repository_id] => CanonicalPathMatch::Unique(*repository_id), + _ => CanonicalPathMatch::Ambiguous(matches), + } +} +``` + +Implement the workspace equivalent using `worktree_path` and `RepositoryWorkspaceId`. + +- [ ] **Step 5: Route strict mutators through the resolver** + +After strict canonicalization, use the resolver in: + +- `add_local_repository` +- `touch_repository_path` +- `insert_repository` +- `update_repository` with `excluded_id = Some(repository.id)` +- `insert_workspace` +- `update_workspace` with `excluded_id = Some(workspace.id)` + +Required behavior: + +```rust +match self.repository_match_for_canonical_path(&canonical_path, None) { + CanonicalPathMatch::None => { /* insert */ } + CanonicalPathMatch::Unique(existing_repository_id) => { + return Err(ProjectOrganizationError::RepositoryAlreadyExists { + existing_repository_id, + canonical_path, + }); + } + CanonicalPathMatch::Ambiguous(repository_ids) => { + return Err(ProjectOrganizationError::AmbiguousRepositoryPath { + canonical_path, + repository_ids, + }); + } +} +``` + +`touch_repository_path` uses the unique ID, migrates its stored path/index to the canonical path, and updates `last_opened_at`. Delete the existing `HashMap::find_map` implementation. + +- [ ] **Step 6: Add index cleanup and update regression tests** + +Add focused tests: + +- successful workspace delete allows reusing its old branch and worktree path. +- successful repository delete allows reusing its old path. +- repository path update removes the old index and rejects the new duplicate. +- workspace branch/path update removes both old indexes and rejects both new duplicates. +- duplicate repository/workspace IDs and orphan workspace insert remain fail-fast. + +- [ ] **Step 7: Run Task 1 tests and check** + +Run: + +```bash +cargo test -p warp --lib project_organization::model::model_tests +cargo test -p warp --lib workspace::view::tests::repository_open_preflight_rejects_missing_path +cargo check -p warp +git diff --check +``` + +Expected: all tests pass; only baseline warnings remain. + +- [ ] **Step 8: Commit Task 1** + +```bash +git add app/src/project_organization/domain.rs \ + app/src/project_organization/model.rs \ + app/src/project_organization/model_tests.rs +git commit -m "fix: resolve repository path ambiguity" +``` + +### Task 2: Add acknowledged repository persistence requests + +**Files:** +- Modify: `app/src/persistence/mod.rs` +- Modify: `app/src/persistence/sqlite.rs` +- Modify: `app/src/persistence/sqlite_tests.rs` + +- [ ] **Step 1: Write failing persistence acknowledgement tests** + +Add tests in `sqlite_tests.rs` for a real temporary SQLite writer: + +```rust +#[test] +fn repository_persistence_acknowledges_committed_upsert() { + let tempdir = tempfile::tempdir().unwrap(); + let database_path = tempdir.path().join("warp.sqlite"); + let conn = setup_database(&database_path).unwrap(); + let handles = start_writer(conn, database_path.clone()).unwrap(); + let persistence = RepositoryPersistence::new(Some(handles.sender.clone())); + let repository = repository_row( + "123e4567-e89b-12d3-a456-426614174100", + "/tmp/ack-repository", + ); + + persistence + .execute(RepositoryPersistenceOperation::UpsertRepository { + repository: repository.clone(), + }) + .unwrap(); + + let mut read_conn = setup_database(&database_path).unwrap(); + assert_eq!(get_all_repositories(&mut read_conn).unwrap(), vec![repository]); + handles.sender.send(ModelEvent::Terminate).unwrap(); + handles.handle.join().unwrap(); +} +``` + +Add tests for: + +- unique constraint failure is returned as `RepositoryPersistenceError::Database`. +- paused writer returns `RepositoryPersistenceError::Paused` and does not write. +- missing sender returns `RepositoryPersistenceError::Unavailable`. +- disconnected request channel returns `RequestDisconnected`. +- response channel disconnect returns `ResponseDisconnected` using a controlled receiver thread that drops the request responder. + +- [ ] **Step 2: Run acknowledgement tests and verify RED** + +Run: + +```bash +cargo test -p warp --lib persistence::sqlite::tests::repository_persistence_acknowledges_committed_upsert +cargo test -p warp --lib persistence::sqlite::tests::repository_persistence_returns_database_error +cargo test -p warp --lib persistence::sqlite::tests::repository_persistence_fails_while_writer_is_paused +``` + +Expected: FAIL because `RepositoryPersistence`, operation/request types, response errors, and writer acknowledgement do not exist. + +- [ ] **Step 3: Define operation, request, client, and errors** + +In `app/src/persistence/mod.rs`, add: + +```rust +#[derive(Debug)] +pub enum RepositoryPersistenceOperation { + UpsertRepository { repository: model::Repository }, + DeleteRepository { repository_id: String }, + UpsertRepositoryWorkspace { workspace: model::RepositoryWorkspace }, + DeleteRepositoryWorkspace { workspace_id: String }, +} + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum RepositoryPersistenceError { + #[error("repository persistence is unavailable")] + Unavailable, + #[error("SQLite writer is paused")] + Paused, + #[error("repository persistence request channel disconnected: {details}")] + RequestDisconnected { details: String }, + #[error("repository persistence response channel disconnected: {details}")] + ResponseDisconnected { details: String }, + #[error("repository persistence database operation failed: {details}")] + Database { details: String }, +} + +#[derive(Debug)] +pub struct RepositoryPersistenceRequest { + pub operation: RepositoryPersistenceOperation, + pub response: SyncSender>, +} + +#[derive(Clone)] +pub struct RepositoryPersistence { + sender: Option>, +} +``` + +Implement `RepositoryPersistence::new` and `execute`: + +```rust +pub fn execute( + &self, + operation: RepositoryPersistenceOperation, +) -> Result<(), RepositoryPersistenceError> { + let sender = self + .sender + .as_ref() + .ok_or(RepositoryPersistenceError::Unavailable)?; + let (response, receiver) = std::sync::mpsc::sync_channel(1); + sender + .send(ModelEvent::RepositoryPersistence(RepositoryPersistenceRequest { + operation, + response, + })) + .map_err(|error| RepositoryPersistenceError::RequestDisconnected { + details: error.to_string(), + })?; + receiver + .recv() + .map_err(|error| RepositoryPersistenceError::ResponseDisconnected { + details: error.to_string(), + })? +} +``` + +Add the acknowledged request variant alongside the four existing repository/workspace CRUD variants: + +```rust +RepositoryPersistence(RepositoryPersistenceRequest), +``` + +Do not remove the old variants in this task. `ProjectOrganizationModel` still uses them until Task 3, so temporary coexistence is required for this intermediate commit to compile. Task 3 removes the old variants immediately after switching the model. + +- [ ] **Step 4: Add writer-side operation dispatch** + +In `sqlite.rs`, add: + +```rust +fn handle_repository_persistence_operation( + operation: RepositoryPersistenceOperation, + connection: &mut SqliteConnection, +) -> anyhow::Result<()> { + match operation { + RepositoryPersistenceOperation::UpsertRepository { repository } => { + save_repository(connection, repository).context("error upserting repository") + } + RepositoryPersistenceOperation::DeleteRepository { repository_id } => { + delete_repository(connection, &repository_id).context("error deleting repository") + } + RepositoryPersistenceOperation::UpsertRepositoryWorkspace { workspace } => { + save_repository_workspace(connection, workspace) + .context("error upserting repository workspace") + } + RepositoryPersistenceOperation::DeleteRepositoryWorkspace { workspace_id } => { + delete_repository_workspace(connection, &workspace_id) + .context("error deleting repository workspace") + } + } +} +``` + +Handle `ModelEvent::RepositoryPersistence(request)` in the writer loop before the generic paused-event branch: + +```rust +ModelEvent::RepositoryPersistence(request) => { + let result = if paused { + Err(RepositoryPersistenceError::Paused) + } else { + handle_repository_persistence_operation(request.operation, &mut current_conn) + .map_err(|error| RepositoryPersistenceError::Database { + details: format!("{error:#}"), + }) + }; + if request.response.send(result).is_err() { + log::error!("Repository persistence requester disconnected before acknowledgement"); + } +} +``` + +Keep the old repository CRUD arms in `handle_model_event` for the Task 2 intermediate commit. Add `RepositoryPersistence` to its control-flow panic arm because acknowledged requests must be handled by the writer loop. Task 3 removes the old arms after all production callers move to acknowledgement. + +- [ ] **Step 5: Run writer acknowledgement tests** + +Run: + +```bash +cargo test -p warp --lib persistence::sqlite::tests::repository_persistence +cargo test -p warp --lib persistence::sqlite::tests +cargo check -p warp +git diff --check +``` + +Expected: acknowledgement tests and the complete SQLite suite pass. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add app/src/persistence/mod.rs \ + app/src/persistence/sqlite.rs \ + app/src/persistence/sqlite_tests.rs +git commit -m "feat: acknowledge repository persistence" +``` + +### Task 3: Require acknowledgement before model state changes + +**Files:** +- Modify: `app/src/lib.rs` +- Modify: `app/src/persistence/mod.rs` +- Modify: `app/src/persistence/sqlite.rs` +- Modify: `app/src/project_organization/model.rs` +- Modify: `app/src/project_organization/model_tests.rs` + +- [ ] **Step 1: Replace the model test persistence harness** + +In `model_tests.rs`, replace the raw `Receiver` helper with a responder thread that records `RepositoryPersistenceOperation` and returns a configured result. + +```rust +struct PersistenceHarness { + operations: Receiver, +} + +fn acknowledged_persistence( + result: Result<(), RepositoryPersistenceError>, +) -> (RepositoryPersistence, PersistenceHarness) { + let (event_sender, event_receiver) = mpsc::sync_channel(20); + let (operation_sender, operation_receiver) = mpsc::sync_channel(20); + std::thread::spawn(move || { + while let Ok(ModelEvent::RepositoryPersistence(request)) = event_receiver.recv() { + operation_sender.send(request.operation).unwrap(); + request.response.send(result.clone()).unwrap(); + } + }); + ( + RepositoryPersistence::new(Some(event_sender)), + PersistenceHarness { + operations: operation_receiver, + }, + ) +} +``` + +`create_model` accepts `RepositoryPersistence` instead of an optional sender. + +- [ ] **Step 2: Write failing persistence atomicity tests** + +Add tests for every mutation class. Example: + +```rust +#[test] +fn repository_add_does_not_change_memory_when_persistence_fails() { + App::test((), |mut app| async move { + let tempdir = TempDir::new().unwrap(); + let repository_path = tempdir.path().join("repository"); + std::fs::create_dir(&repository_path).unwrap(); + let (persistence, _harness) = acknowledged_persistence(Err( + RepositoryPersistenceError::Database { + details: "injected failure".to_string(), + }, + )); + let model = create_model_with_persistence(&mut app, vec![], vec![], persistence); + + let error = model + .update(&mut app, |model, ctx| { + model.add_local_repository(&repository_path, ctx) + }) + .unwrap_err(); + + assert!(matches!(error, ProjectOrganizationError::Persistence { .. })); + assert_eq!(model.read(&app, |model, _| model.repositories().count()), 0); + }); +} +``` + +Add equivalent tests for: + +- repository update failure retains old path/index/data. +- repository delete failure retains record/path index. +- workspace insert failure creates no record/index. +- workspace update failure retains old branch/path indexes. +- workspace delete failure retains record/index. +- unavailable persistence fails without memory changes. +- successful acknowledgement emits exactly one domain event and records exactly one operation. + +Use a real model subscription for the event assertion: + +```rust +struct ProjectOrganizationEventProbe; + +impl Entity for ProjectOrganizationEventProbe { + type Event = (); +} + +let emitted_events = Arc::new(Mutex::new(Vec::new())); +let captured_events = emitted_events.clone(); +let subscribed_model = model.clone(); +app.add_model(move |ctx| { + ctx.subscribe_to_model(&subscribed_model, move |_, _, event, _| { + captured_events.lock().unwrap().push(event.clone()); + }); + ProjectOrganizationEventProbe +}); +``` + +After a successful mutation, assert one persistence operation and one matching `ProjectOrganizationEvent`. After a failed acknowledgement, assert the event vector remains empty. + +- [ ] **Step 3: Run atomicity tests and verify RED** + +Run: + +```bash +cargo test -p warp --lib project_organization::model::model_tests::repository_add_does_not_change_memory_when_persistence_fails +cargo test -p warp --lib project_organization::model::model_tests::workspace_update_does_not_change_indexes_when_persistence_fails +``` + +Expected: FAIL because the model still treats channel enqueue as persistence success or permits missing persistence. + +- [ ] **Step 4: Store `RepositoryPersistence` in the model** + +Change the model field and constructor: + +```rust +pub struct ProjectOrganizationModel { + // indexes unchanged + persistence: RepositoryPersistence, +} + +pub fn try_new( + persisted_repositories: Vec, + persisted_workspaces: Vec, + persistence: RepositoryPersistence, + _ctx: &mut ModelContext, +) -> Result; +``` + +Map acknowledgement errors without discarding context: + +```rust +fn persist( + &self, + operation: RepositoryPersistenceOperation, + operation_name: &'static str, +) -> Result<(), ProjectOrganizationError> { + self.persistence + .execute(operation) + .map_err(|error| ProjectOrganizationError::Persistence { + operation: operation_name, + details: error.to_string(), + }) +} +``` + +Replace all old `send_model_event` calls. Keep the existing validate -> persist -> memory/index -> domain event order. + +After the model no longer constructs the old events: + +- remove `ModelEvent::UpsertRepository`. +- remove `ModelEvent::DeleteRepository`. +- remove `ModelEvent::UpsertRepositoryWorkspace`. +- remove `ModelEvent::DeleteRepositoryWorkspace`. +- remove their four `handle_model_event` arms from `sqlite.rs`. + +- [ ] **Step 5: Wire production initialization** + +In `app/src/lib.rs`, construct the client from the writer sender: + +```rust +let project_organization_persistence = + persistence::RepositoryPersistence::new(persistence_writer.sender()); +ctx.add_singleton_model(|ctx| { + ProjectOrganizationModel::try_new( + persisted_repositories, + persisted_repository_workspaces, + project_organization_persistence, + ctx, + ) + .unwrap_or_else(|error| panic!("Failed to initialize project organization: {error:#}")) +}); +``` + +Do not replace unavailable persistence with a no-op client. + +- [ ] **Step 6: Run model, persistence, and consumer tests** + +Run: + +```bash +cargo test -p warp --lib project_organization::model::model_tests +cargo test -p warp --lib persistence::sqlite::tests +cargo test -p warp --lib workspace::view::tests::repository_open_preflight_rejects_missing_path +cargo check -p warp +git diff --check +``` + +Expected: all tests pass; repository/workspace operations fail cleanly when persistence is unavailable or returns an error. + +- [ ] **Step 7: Review residual event usage** + +Run: + +```bash +rg -n "UpsertRepository|DeleteRepository|UpsertRepositoryWorkspace|DeleteRepositoryWorkspace|RepositoryPersistence" app/src +``` + +Expected: + +- old fire-and-forget repository CRUD variants have no remaining definitions or callers. +- repository/workspace mutations flow through `RepositoryPersistenceOperation`. +- SQLite writer is the only production acknowledgement responder. + +- [ ] **Step 8: Commit Task 3** + +```bash +git add app/src/lib.rs \ + app/src/persistence/mod.rs \ + app/src/persistence/sqlite.rs \ + app/src/project_organization/model.rs \ + app/src/project_organization/model_tests.rs +git commit -m "fix: commit repository state after persistence" +``` + +### Task 4: Final verification and Task 3 review closure + +**Files:** +- Modify only files required by compiler or test feedback. + +- [ ] **Step 1: Run focused verification** + +```bash +cargo test -p warp --lib project_organization::model::model_tests +cargo test -p warp --lib persistence::sqlite::tests +cargo test -p warp --lib workspace::view::tests::repository_open_preflight_rejects_missing_path +cargo check -p warp +git diff --check +``` + +- [ ] **Step 2: Verify worktree scope** + +```bash +git status --short +git diff --stat f13c23333657ab705365ace2fb3b6850c46543de..HEAD +``` + +Expected: no uncommitted changes; only repository-workspace plan/design and implementation files are changed. + +- [ ] **Step 3: Re-run Task 3 spec and quality reviews** + +Use `superpowers:requesting-code-review` with the complete Task 3 range. Confirm: + +- canonical 0/1/many resolution is deterministic. +- acknowledgement covers unavailable, paused, disconnected, and database failure. +- model failure paths leave memory/indexes/events unchanged. +- Task 4 can use acknowledged workspace insert/delete as compensation boundaries. + +- [ ] **Step 4: Commit review fixes if required** + +Use a scoped English commit message describing only the reviewed fix. Do not squash or rewrite the existing task history unless explicitly requested. diff --git a/docs/superpowers/plans/2026-07-13-worktree-git-ownership.md b/docs/superpowers/plans/2026-07-13-worktree-git-ownership.md new file mode 100644 index 00000000000..68642fffd77 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-worktree-git-ownership.md @@ -0,0 +1,2138 @@ +# Worktree Git Ownership Safety Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 关闭 worktree 创建目标目录的 TOCTOU、创建失败时的 branch 误清理,以及删除校验完成后的 branch/merge-target 漂移窗口。 + +**Architecture:** 创建流程通过共享 `TargetDirectoryClaim` 原子创建最终目录;remote 创建由单个 `git worktree add --no-track -b` 创建 branch,失败时只清理未注册且为空的 claimed 目录。删除流程通过 prepared `git update-ref --stdin` transaction 锁定 branch 和 merge target,在持锁后重新执行 worktree、branch、dirty、target selection 和 merge 权威校验,再跨越 `git worktree remove` 提交 branch delete。 + +**Tech Stack:** Rust 2021、`command::blocking::Command`、Git `worktree` / `update-ref --stdin`、`tempfile`、Tokio `spawn_blocking`。 + +--- + +## File Map + +- Create `app/src/project_organization/git/ref_transaction.rs`: `update-ref --stdin` 协议、stderr drain、prepare/abort/commit 生命周期。 +- Create `app/src/project_organization/git/ref_transaction_tests.rs`: 真实 Git transaction prototype、ref lock、abort 和 target drift 测试。 +- Modify `app/src/project_organization/git.rs`: 目标目录 claim、创建残留、preflight snapshot、锁内删除编排和结构化错误。 +- Modify `app/src/project_organization/git_tests.rs`: 创建 TOCTOU、残留、锁前/锁后竞态、部分 mutation 和 source chain 回归测试。 +- Modify `specs/repository-workspaces/TECH.md`: 同步最终创建与删除语义。 + +### Task 1: Add the prepared ref transaction protocol + +**Files:** +- Create: `app/src/project_organization/git/ref_transaction.rs` +- Create: `app/src/project_organization/git/ref_transaction_tests.rs` +- Modify: `app/src/project_organization/git.rs:1-10` + +- [ ] **Step 1: Add the private module and write the failing real-Git prototype** + +在 `git.rs` 顶部加入: + +```rust +mod ref_transaction; +``` + +在 `ref_transaction.rs` 末尾接入测试文件: + +```rust +#[cfg(test)] +#[path = "ref_transaction_tests.rs"] +mod tests; +``` + +测试 fixture 必须使用 `command::blocking::Command`,并提供这些具名 helper: + +```rust +struct TransactionFixture { + _tempdir: tempfile::TempDir, + root: PathBuf, +} + +impl TransactionFixture { + fn new() -> Self; + fn add_worktree(&self, branch: &str) -> PathBuf; + fn rev_parse(&self, full_ref: &str) -> String; + fn ref_exists(&self, full_ref: &str) -> bool; + fn advance_ref(&self, full_ref: &str) -> String; +} + +fn run_git(repository: &Path, args: &[&str]); +``` + +`new` 创建 `main` repository、配置 test identity、提交 `README.md`;`add_worktree` 执行真实 `git worktree add -b`。先写 prototype: + +```rust +#[test] +fn prepared_delete_transaction_spans_worktree_remove() { + let fixture = TransactionFixture::new(); + let worktree = fixture.add_worktree("feature/transaction"); + let branch_ref = "refs/heads/feature/transaction"; + let branch_oid = fixture.rev_parse(branch_ref); + let target_ref = "refs/heads/main"; + let target_oid = fixture.rev_parse(target_ref); + + let transaction = PreparedRefDelete::prepare( + &fixture.root, + branch_ref, + &branch_oid, + Some(LockedRef { + full_ref: target_ref, + oid: &target_oid, + }), + ) + .unwrap(); + + let status = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["worktree", "remove"]) + .arg(&worktree) + .status() + .unwrap(); + assert!(status.success()); + + transaction.commit().unwrap(); + assert!(!worktree.exists()); + assert!(!fixture.ref_exists(branch_ref)); +} +``` + +- [ ] **Step 2: Run the prototype and verify RED** + +```bash +cargo test -p warp --lib project_organization::git::ref_transaction::tests::prepared_delete_transaction_spans_worktree_remove -- --nocapture +``` + +Expected: compile failure because `PreparedRefDelete`, `LockedRef`, and transaction errors do not exist. + +- [ ] **Step 3: Implement the exact line protocol and process lifecycle** + +Define the module-private diagnostic types and transaction API: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum RefTransactionStage { + Start, + Prepare, + Commit, + Abort, + Wait, + ReadStderr, +} + +#[derive(Debug, thiserror::Error)] +pub(super) enum RefTransactionError { + #[error("failed to start git update-ref transaction: {source}")] + Start { + #[source] + source: io::Error, + }, + #[error("git update-ref transaction is missing its {pipe} pipe")] + MissingPipe { pipe: &'static str }, + #[error("failed to communicate with git update-ref during {stage:?}: {source}")] + Io { + stage: RefTransactionStage, + #[source] + source: io::Error, + }, + #[error("git update-ref returned `{response}` during {stage:?}, expected `{expected}`")] + UnexpectedResponse { + stage: RefTransactionStage, + expected: &'static str, + response: String, + }, + #[error("git update-ref exited during {stage:?}: {stderr}")] + ProcessExited { + stage: RefTransactionStage, + stderr: String, + }, + #[error("failed to join git update-ref stderr reader during {stage:?}")] + StderrReaderPanicked { stage: RefTransactionStage }, +} + +pub(super) struct LockedRef<'a> { + pub(super) full_ref: &'a str, + pub(super) oid: &'a str, +} + +pub(super) struct PreparedRefDelete { + child: std::process::Child, + stdin: Option>, + stdout: std::io::BufReader, + stderr_reader: Option>>>, + completed: bool, +} +``` + +`prepare` 必须使用下面的命令和顺序: + +```rust +let mut child = command::blocking::Command::new("git") + .arg("-C") + .arg(repository) + .args(["update-ref", "--stdin"]) + .stdin(command::Stdio::piped()) + .stdout(command::Stdio::piped()) + .stderr(command::Stdio::piped()) + .spawn() + .map_err(|source| RefTransactionError::Start { source })?; +``` + +stderr pipe 立即交给独立 `std::thread::spawn` 执行 `read_to_end`,避免错误输出填满 pipe。协议只等待 Git 实际返回的 control response: + +```text +send: start +read: start: ok +send: verify # only when target exists +send: delete +send: prepare +read: prepare: ok +``` + +不得为 branch ref 再发送 `verify`;`delete ` 已执行 compare-and-delete,Git 会拒绝同一 transaction 中对同 ref 的重复更新。实现 `send_line`、`expect_response`、`finish_process`,response 使用 `trim_end_matches(['\r', '\n'])`,未知 response 或 EOF 均返回结构化错误。 + +`commit(mut self)` 发送 `commit`、等待 `commit: ok`、关闭 stdin、等待 child exit 并 join stderr reader。`abort(mut self)` 同理发送 `abort` 并等待 `abort: ok`。`Drop` 仅在 `completed == false` 时 best-effort 发送 `abort`、关闭 stdin 并 wait,不覆盖调用方已有错误。 + +- [ ] **Step 4: Add lock, abort, and target-drift tests** + +```rust +#[test] +fn prepared_transaction_blocks_branch_updates_until_abort() { + let fixture = TransactionFixture::new(); + let branch_ref = "refs/heads/feature/locked"; + let worktree = fixture.add_worktree("feature/locked"); + let branch_oid = fixture.rev_parse(branch_ref); + let changed_oid = fixture.advance_ref("refs/heads/main"); + let transaction = PreparedRefDelete::prepare( + &fixture.root, + branch_ref, + &branch_oid, + None, + ) + .unwrap(); + + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(&fixture.root) + .args(["update-ref", branch_ref, &changed_oid, &branch_oid]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("cannot lock ref")); + + transaction.abort().unwrap(); + assert!(worktree.exists()); + assert_eq!(fixture.rev_parse(branch_ref), branch_oid); +} + +#[test] +fn prepare_rejects_changed_merge_target() { + let fixture = TransactionFixture::new(); + fixture.add_worktree("feature/target-drift"); + let branch_ref = "refs/heads/feature/target-drift"; + let branch_oid = fixture.rev_parse(branch_ref); + let target_ref = "refs/heads/main"; + let stale_target_oid = fixture.rev_parse(target_ref); + fixture.advance_ref(target_ref); + + let error = PreparedRefDelete::prepare( + &fixture.root, + branch_ref, + &branch_oid, + Some(LockedRef { + full_ref: target_ref, + oid: &stale_target_oid, + }), + ) + .unwrap_err(); + assert!(matches!( + error, + RefTransactionError::ProcessExited { + stage: RefTransactionStage::Prepare, + .. + } | RefTransactionError::UnexpectedResponse { + stage: RefTransactionStage::Prepare, + .. + } + )); +} +``` + +- [ ] **Step 5: Run GREEN, format only touched Rust files, and commit** + +```bash +rustfmt --edition 2021 app/src/project_organization/git.rs app/src/project_organization/git/ref_transaction.rs app/src/project_organization/git/ref_transaction_tests.rs +cargo test -p warp --lib project_organization::git::ref_transaction::tests -- --nocapture +git add app/src/project_organization/git.rs app/src/project_organization/git/ref_transaction.rs app/src/project_organization/git/ref_transaction_tests.rs +git commit -m "feat: add prepared git ref transactions" +``` + +Expected: all transaction tests pass with non-zero test count. If the real prototype fails on a supported platform, stop and redesign; do not fall back to post-remove OID-only deletion. + +#### Task 1 Execution Evidence + +- 初始 RED 命令为 `cargo test -p warp --lib project_organization::git::ref_transaction::tests::prepared_delete_transaction_spans_worktree_remove -- --nocapture`,因事务类型尚未定义而失败,错误为 `E0432`。 +- P2 的 Drop lock-release 与 stale branch-OID 测试添加时立即 GREEN,因为实现已具备 Drop abort/wait 与 expected-old-OID CAS;没有为制造 RED 而破坏协议。 +- 最终 transaction 模块测试为 6/6 通过。 +- 质量审查后收窄无消费者的 re-export;Task 4 接入时再公开必要诊断类型。 + +### Task 2: Atomically claim worktree target directories + +**Files:** +- Modify: `app/src/project_organization/git.rs:187-220, 473-710, 1359-1490` +- Modify: `app/src/project_organization/git_tests.rs:689-1289` + +- [ ] **Step 1: Replace old branch-cleanup expectations with failing target-claim tests** + +Add test-only hooks that call the same private core used by production: + +```rust +#[cfg(test)] +pub(crate) fn create_from_remote_with_after_target_claim_hook( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, + after_target_claim: F, +) -> Result<(), GitWorkspaceError> +where + F: FnOnce(), +``` + +```rust +#[cfg(test)] +pub(crate) fn create_from_local_with_after_target_claim_hook( + repository: &Path, + local_branch: &str, + worktree_path: &Path, + after_target_claim: F, +) -> Result<(), GitWorkspaceError> +where + F: FnOnce(), +``` + +```rust +#[test] +fn remote_creation_claims_target_before_git_command() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("claimed remote target"); + let mut saw_existing_claim = false; + + create_from_remote_with_after_target_claim_hook( + &fixture.root, + "refs/remotes/origin/main", + "feature/claimed-remote", + &target, + || { + let error = std::fs::create_dir(&target).unwrap_err(); + saw_existing_claim = error.kind() == std::io::ErrorKind::AlreadyExists; + }, + ) + .unwrap(); + + assert!(saw_existing_claim); + assert_eq!(current_branch(&target), "feature/claimed-remote"); +} + +#[test] +fn local_creation_claims_target_before_git_command() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "feature/claimed-local"]); + let target = fixture.tempdir.path().join("claimed local target"); + let mut saw_existing_claim = false; + + create_from_local_with_after_target_claim_hook( + &fixture.root, + "feature/claimed-local", + &target, + || { + let error = std::fs::create_dir(&target).unwrap_err(); + saw_existing_claim = error.kind() == std::io::ErrorKind::AlreadyExists; + }, + ) + .unwrap(); + + assert!(saw_existing_claim); + assert_eq!(current_branch(&target), "feature/claimed-local"); +} + +#[test] +fn creation_rejects_claim_replaced_by_file_before_git_command() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("replaced claimed target"); + let error = create_from_remote_with_after_target_claim_hook( + &fixture.root, + "refs/remotes/origin/main", + "feature/replaced-claim", + &target, + || { + std::fs::remove_dir(&target).unwrap(); + std::fs::write(&target, "replacement").unwrap(); + }, + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::ClaimedTargetNotDirectory { .. })); + assert!(!ref_exists( + &fixture.root, + "refs/heads/feature/replaced-claim" + )); +} +``` + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p warp --lib remote_creation_claims_target_before_git_command -- --nocapture +cargo test -p warp --lib local_creation_claims_target_before_git_command -- --nocapture +cargo test -p warp --lib creation_rejects_claim_replaced_by_file_before_git_command -- --nocapture +``` + +Expected: compile failure because the target-claim hooks and `TargetDirectoryClaim` do not exist. + +- [ ] **Step 3: Implement the shared target claim and exact errors** + +Replace `validate_target_missing` in both local and remote creation with: + +```rust +struct TargetDirectoryClaim { + requested_path: PathBuf, + canonical_path: PathBuf, +} + +impl TargetDirectoryClaim { + fn acquire(path: &Path) -> Result { + std::fs::create_dir(path).map_err(|source| match source.kind() { + io::ErrorKind::AlreadyExists => GitWorkspaceError::TargetExists { + path: path.to_path_buf(), + }, + _ => GitWorkspaceError::TargetClaimFailed { + path: path.to_path_buf(), + source, + }, + })?; + let canonical_path = canonicalize(path)?; + Ok(Self { + requested_path: path.to_path_buf(), + canonical_path, + }) + } + + fn ensure_directory(&self) -> Result<(), GitWorkspaceError> { + let metadata = std::fs::symlink_metadata(&self.requested_path).map_err(|source| { + GitWorkspaceError::ClaimedTargetInspection { + path: self.requested_path.clone(), + source, + } + })?; + if metadata.file_type().is_dir() { + Ok(()) + } else { + Err(GitWorkspaceError::ClaimedTargetNotDirectory { + path: self.requested_path.clone(), + }) + } + } +} +``` + +Add the error variant: + +```rust +#[error("failed to claim worktree target `{path}`: {source}")] +TargetClaimFailed { + path: PathBuf, + #[source] + source: io::Error, +}, + +#[error("failed to inspect claimed worktree target `{path}`: {source}")] +ClaimedTargetInspection { + path: PathBuf, + #[source] + source: io::Error, +}, + +#[error("claimed worktree target `{path}` is no longer a directory")] +ClaimedTargetNotDirectory { path: PathBuf }, + +#[error("claimed worktree target `{path}` is not empty")] +ClaimedTargetNotEmpty { path: PathBuf }, + +#[error("failed to clean up claimed worktree target `{path}`: {source}")] +ClaimedTargetCleanupFailed { + path: PathBuf, + #[source] + source: io::Error, +}, +``` + +Remote creation must execute exactly: + +```rust +let args = [ + OsStr::new("worktree"), + OsStr::new("add"), + OsStr::new("--no-track"), + OsStr::new("-b"), + OsStr::new(new_branch), + claim.canonical_path.as_os_str(), + OsStr::new(remote_ref), +]; +``` + +Local creation keeps its existing branch validation, then uses the same claim and executes `git worktree add `. Do not use production `to_str()` or lossy path conversion. + +Git runners and remote success verification must receive `claim.canonical_path`, so an input relative to the calling process has the same meaning as the already-created claim when Git runs with `-C repository`. Keep `claim.requested_path` for claim inspection, cleanup, and error display. + +Both creation paths call `claim.ensure_directory()` immediately before `git worktree add` and again after a successful command. A failed local `worktree add` uses the same registration/empty-directory inspection as remote creation with `branch_may_remain: false`; a failed remote `worktree add -b` uses `branch_may_remain: true`. + +Make production and test paths share these two private cores; the runner owns only the Git command so residual handling remains common: + +```rust +fn create_from_remote_inner( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, + after_target_claim: AfterTargetClaim, + runner: Runner, +) -> Result<(), GitWorkspaceError> +where + AfterTargetClaim: FnOnce(), + Runner: FnOnce(&Path, &str, &str, &Path, &str) -> Result<(), GitWorkspaceError>, +{ + let expected_oid = validate_remote_ref(repository, remote_ref)?; + validate_new_branch(repository, new_branch)?; + let claim = TargetDirectoryClaim::acquire(worktree_path)?; + after_target_claim(); + claim.ensure_directory()?; + + match runner( + repository, + remote_ref, + new_branch, + &claim.canonical_path, + &expected_oid, + ) { + Ok(()) => { + claim.ensure_directory()?; + verify_remote_worktree_creation( + repository, + &claim.canonical_path, + new_branch, + &expected_oid, + ) + .map_err(|verification_error| GitWorkspaceError::WorktreeCreationVerificationFailed { + worktree_path: claim.requested_path, + branch: new_branch.to_string(), + expected_oid, + verification_error: Box::new(verification_error), + }) + } + Err(create_error) => worktree_creation_failure( + repository, + claim, + new_branch, + true, + create_error, + ), + } +} + +fn create_from_local_inner( + repository: &Path, + local_branch: &str, + worktree_path: &Path, + after_target_claim: AfterTargetClaim, + runner: Runner, +) -> Result<(), GitWorkspaceError> +where + AfterTargetClaim: FnOnce(), + Runner: FnOnce(&Path, &str, &Path) -> Result<(), GitWorkspaceError>, +{ + let full_ref = format!("refs/heads/{local_branch}"); + validate_ref_exists(repository, &full_ref)?; + if let Some(worktree) = list_worktrees(repository)? + .into_iter() + .find(|worktree| worktree.branch.as_deref() == Some(&full_ref)) + { + return Err(GitWorkspaceError::BranchAlreadyCheckedOut { + branch: local_branch.to_string(), + path: worktree.path, + }); + } + let claim = TargetDirectoryClaim::acquire(worktree_path)?; + after_target_claim(); + claim.ensure_directory()?; + + match runner(repository, local_branch, &claim.canonical_path) { + Ok(()) => { + claim.ensure_directory()?; + Ok(()) + } + Err(create_error) => worktree_creation_failure( + repository, + claim, + local_branch, + false, + create_error, + ), + } +} +``` + +Implement the production command runners and hook wrappers as follows: + +```rust +fn run_remote_worktree_add( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, +) -> Result<(), GitWorkspaceError> { + let args = [ + OsStr::new("worktree"), + OsStr::new("add"), + OsStr::new("--no-track"), + OsStr::new("-b"), + OsStr::new(new_branch), + worktree_path.as_os_str(), + OsStr::new(remote_ref), + ]; + git_output_with_os_args_for_operation(repository, "create worktree from remote", &args)?; + Ok(()) +} + +fn run_local_worktree_add( + repository: &Path, + local_branch: &str, + worktree_path: &Path, +) -> Result<(), GitWorkspaceError> { + let args = [ + OsStr::new("worktree"), + OsStr::new("add"), + worktree_path.as_os_str(), + OsStr::new(local_branch), + ]; + git_output_with_os_args_for_operation(repository, "create worktree from local branch", &args)?; + Ok(()) +} + +pub fn create_from_remote( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, +) -> Result<(), GitWorkspaceError> { + create_from_remote_inner( + repository, + remote_ref, + new_branch, + worktree_path, + || {}, + |repository, remote_ref, new_branch, worktree_path, _| { + run_remote_worktree_add(repository, remote_ref, new_branch, worktree_path) + }, + ) +} + +pub fn create_from_local( + repository: &Path, + local_branch: &str, + worktree_path: &Path, +) -> Result<(), GitWorkspaceError> { + create_from_local_inner( + repository, + local_branch, + worktree_path, + || {}, + run_local_worktree_add, + ) +} + +#[cfg(test)] +pub(crate) fn create_from_remote_with_after_target_claim_hook( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, + after_target_claim: F, +) -> Result<(), GitWorkspaceError> +where + F: FnOnce(), +{ + create_from_remote_inner( + repository, + remote_ref, + new_branch, + worktree_path, + after_target_claim, + |repository, remote_ref, new_branch, worktree_path, _| { + run_remote_worktree_add(repository, remote_ref, new_branch, worktree_path) + }, + ) +} + +#[cfg(test)] +pub(crate) fn create_from_local_with_after_target_claim_hook( + repository: &Path, + local_branch: &str, + worktree_path: &Path, + after_target_claim: F, +) -> Result<(), GitWorkspaceError> +where + F: FnOnce(), +{ + create_from_local_inner( + repository, + local_branch, + worktree_path, + after_target_claim, + run_local_worktree_add, + ) +} +``` + +`create_from_remote_with_runner` calls the same remote core with a no-op hook and its caller-supplied runner. `create_from_local` and `create_from_local_with_runner` follow the matching local core. Each core acquires the claim, invokes its hook, checks `ensure_directory`, runs the runner, checks `ensure_directory` after success, and routes every runner error through the common residual inspection. + +- [ ] **Step 4: Write failing remote residual and directory-cleanup tests** + +Define a test-only runner whose closure receives the expected OID and may create real Git state before returning an injected error: + +```rust +#[cfg(test)] +pub(crate) fn create_from_remote_with_runner( + repository: &Path, + remote_ref: &str, + new_branch: &str, + worktree_path: &Path, + runner: Runner, +) -> Result<(), GitWorkspaceError> +where + Runner: FnOnce(&Path, &str, &str, &Path, &str) -> Result<(), GitWorkspaceError>, +{ + create_from_remote_inner( + repository, + remote_ref, + new_branch, + worktree_path, + || {}, + runner, + ) +} +``` + +The runner arguments are, in order: repository, remote ref, short new branch name, claimed path, and expected OID. Define the reusable test error before the tests: + +```rust +fn injected_command_error(operation: &'static str) -> GitWorkspaceError { + GitWorkspaceError::CommandFailed { + operation, + args: vec!["injected".to_string()], + stderr: "injected failure".to_string(), + } +} +``` + +```rust +#[test] +fn remote_creation_failure_preserves_possible_branch_residual() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("branch residual target"); + + let error = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/residual", + &target, + |repository, _remote_ref, branch, _target, expected_oid| { + let full_ref = format!("refs/heads/{branch}"); + run_git(repository, &["update-ref", &full_ref, expected_oid]); + Err(GitWorkspaceError::CommandFailed { + operation: "injected remote worktree creation", + args: vec!["worktree".into(), "add".into()], + stderr: "injected failure".into(), + }) + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationFailed { + branch_may_remain: true, + worktree_registered: Some(false), + claimed_directory_removed: true, + .. + } + )); + assert!(ref_exists(&fixture.root, "refs/heads/feature/residual")); + assert!(!target.exists()); +} + +#[test] +fn failed_creation_keeps_nonempty_unregistered_claim() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("nonempty claimed target"); + let error = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/nonempty-residual", + &target, + |_repository, _remote_ref, _branch, target, _expected_oid| { + std::fs::write(target.join("keep.txt"), "keep").unwrap(); + Err(injected_command_error("remote creation")) + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationFailed { + worktree_registered: Some(false), + claimed_directory_removed: false, + cleanup_error: Some(_), + .. + } + )); + assert_eq!(std::fs::read_to_string(target.join("keep.txt")).unwrap(), "keep"); +} + +#[test] +fn failed_creation_never_removes_registered_claim() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("registered claimed target"); + let error = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/registered-residual", + &target, + |repository, remote_ref, branch, target, _expected_oid| { + let output = command::blocking::Command::new("git") + .arg("-C") + .arg(repository) + .args(["worktree", "add", "--no-track", "-b", branch]) + .arg(target) + .arg(remote_ref) + .output() + .unwrap(); + assert!(output.status.success()); + Err(injected_command_error("remote creation after registration")) + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationFailed { + worktree_registered: Some(true), + claimed_directory_removed: false, + .. + } + )); + assert!(target.exists()); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/registered-residual" + )); +} + +#[test] +fn local_creation_failure_removes_empty_unregistered_claim() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "feature/local-failure"]); + let target = fixture.tempdir.path().join("local failure target"); + let error = create_from_local_with_runner( + &fixture.root, + "feature/local-failure", + &target, + |_repository, _branch, _target| Err(injected_command_error("local creation")), + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationFailed { + branch_may_remain: false, + worktree_registered: Some(false), + claimed_directory_removed: true, + .. + } + )); + assert!(!target.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/local-failure")); +} +``` + +- [ ] **Step 5: Implement branch-preserving residual inspection** + +Add the local test runner and replace `cleanup_failed_remote_creation*` and all branch compare-delete cleanup with: + +```rust +#[cfg(test)] +pub(crate) fn create_from_local_with_runner( + repository: &Path, + local_branch: &str, + worktree_path: &Path, + runner: Runner, +) -> Result<(), GitWorkspaceError> +where + Runner: FnOnce(&Path, &str, &Path) -> Result<(), GitWorkspaceError>, +{ + create_from_local_inner(repository, local_branch, worktree_path, || {}, runner) +} +``` + +```rust +#[error( + "worktree creation failed for `{branch}` at `{worktree_path}`: {create_error}; branch may remain: {branch_may_remain}; registered: {worktree_registered:?}; claimed directory removed: {claimed_directory_removed}; cleanup error: {cleanup_error:?}" +)] +WorktreeCreationFailed { + worktree_path: PathBuf, + branch: String, + branch_may_remain: bool, + worktree_registered: Option, + claimed_directory_removed: bool, + #[source] + create_error: Box, + cleanup_error: Option>, +}, +``` + +Failure handling must follow this exact order: + +```rust +let registration = inspect_claimed_worktree_registration(repository, &claim.canonical_path); +let (worktree_registered, mut cleanup_error) = match registration { + Ok(registered) => (Some(registered), None), + Err(error) => (None, Some(Box::new(error))), +}; + +let claimed_directory_removed = if worktree_registered == Some(false) { + match remove_claimed_directory_if_empty(&claim.requested_path) { + Ok(removed) => removed, + Err(error) => { + cleanup_error = Some(Box::new(error)); + false + } + } +} else { + false +}; +``` + +Implement the helper and both leaf inspections exactly once: + +```rust +fn worktree_creation_failure( + repository: &Path, + claim: TargetDirectoryClaim, + branch: &str, + branch_may_remain: bool, + create_error: GitWorkspaceError, +) -> Result<(), GitWorkspaceError> { + let registration = inspect_claimed_worktree_registration(repository, &claim.canonical_path); + let (worktree_registered, mut cleanup_error) = match registration { + Ok(registered) => (Some(registered), None), + Err(error) => (None, Some(Box::new(error))), + }; + let claimed_directory_removed = if worktree_registered == Some(false) { + match remove_claimed_directory_if_empty(&claim.requested_path) { + Ok(removed) => removed, + Err(error) => { + cleanup_error = Some(Box::new(error)); + false + } + } + } else { + false + }; + Err(GitWorkspaceError::WorktreeCreationFailed { + worktree_path: claim.requested_path, + branch: branch.to_string(), + branch_may_remain, + worktree_registered, + claimed_directory_removed, + create_error: Box::new(create_error), + cleanup_error, + }) +} + +fn inspect_claimed_worktree_registration( + repository: &Path, + claimed_path: &Path, +) -> Result { + Ok(list_worktrees(repository)? + .into_iter() + .any(|worktree| worktree.path == claimed_path)) +} + +fn remove_claimed_directory_if_empty(path: &Path) -> Result { + let mut entries = std::fs::read_dir(path).map_err(|source| { + GitWorkspaceError::ClaimedTargetCleanupFailed { + path: path.to_path_buf(), + source, + } + })?; + if entries.next().is_some() { + return Err(GitWorkspaceError::ClaimedTargetNotEmpty { + path: path.to_path_buf(), + }); + } + std::fs::remove_dir(path).map_err(|source| GitWorkspaceError::ClaimedTargetCleanupFailed { + path: path.to_path_buf(), + source, + })?; + Ok(true) +} +``` + +`remove_claimed_directory_if_empty` maps a non-empty directory to `ClaimedTargetNotEmpty`; it maps `read_dir` and `remove_dir` I/O failures to `ClaimedTargetCleanupFailed`. It calls `remove_dir` only when `next().is_none()` and never calls `remove_dir_all`. Remote failure sets `branch_may_remain` to `true`, because the service deliberately does not infer whether Git created or another process recreated the new branch. Local failure sets it to `false`, because it only attaches an already validated existing branch. + +- [ ] **Step 6: Run creation tests and commit** + +Delete tests that require automatic branch cleanup: `atomic_claim_preserves_*`, `remote_creation_failure_cleans_branch_created_at_expected_oid`, `remote_creation_cleanup_*`, and `remote_creation_failure_preserves_concurrently_changed_branch`. Preserve validation, path, direct/symbolic verification, and async coverage. + +```bash +rustfmt --edition 2021 app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +cargo test -p warp --lib project_organization::git_tests -- --nocapture +git add app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +git commit -m "fix: claim worktree targets atomically" +``` + +Expected: all Git service tests pass; failures never delete a branch and only remove an empty, unregistered claimed directory. + +#### Task 2 Execution Evidence + +- Relative-path quality fix: `TargetDirectoryClaim::acquire` creates and canonicalizes the target in the caller's current directory; both production runners and remote verification now receive `claim.canonical_path`, while claim inspection and cleanup retain `claim.requested_path`. +- Regression coverage: real remote and local Git creation tests pass unique relative targets without changing the global CWD, verify the branch at the canonical target, verify no repository-relative target was created, and remove the created worktree through `git -C worktree remove `. + +### Task 3: Complete creation postcondition verification and preflight snapshots + +**Files:** +- Modify: `app/src/project_organization/git.rs:47-60, 610-655, 716-826` +- Modify: `app/src/project_organization/git_tests.rs:689-856, 1311-1542` + +- [ ] **Step 1: Write failing upstream and immutable target-OID tests** + +```rust +#[test] +fn successful_remote_creation_rejects_new_upstream() { + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("unexpected upstream target"); + + let error = create_from_remote_with_success_hook( + &fixture.root, + "refs/remotes/origin/main", + "feature/unexpected-upstream", + &target, + || { + run_git( + &fixture.root, + &[ + "branch", + "--set-upstream-to=origin/main", + "feature/unexpected-upstream", + ], + ); + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::WorktreeCreationVerificationFailed { + verification_error, + .. + } if matches!( + verification_error.as_ref(), + GitWorkspaceError::UnexpectedBranchUpstream { upstream, .. } + if upstream == "refs/remotes/origin/main" + ) + )); +} + +#[test] +fn deletion_preflight_captures_merge_target_oid() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/preflight-target"); + let preflight = deletion_preflight(&fixture.root, &worktree, true).unwrap(); + let target = preflight.merge_target.unwrap(); + + assert_eq!(target.full_ref, "refs/remotes/origin/main"); + assert_eq!(target.oid, ref_oid(&fixture.root, &target.full_ref)); +} + +#[test] +fn deletion_preflight_rejects_self_merge_target() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/self-target"); + run_git( + &fixture.root, + &[ + "config", + "branch.feature/self-target.remote", + ".", + ], + ); + run_git( + &fixture.root, + &[ + "config", + "branch.feature/self-target.merge", + "refs/heads/feature/self-target", + ], + ); + + let error = deletion_preflight(&fixture.root, &worktree, true).unwrap_err(); + assert!(matches!(error, GitWorkspaceError::InvalidMergeTarget { .. })); +} +``` + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p warp --lib successful_remote_creation_rejects_new_upstream -- --nocapture +cargo test -p warp --lib deletion_preflight_captures_merge_target_oid -- --nocapture +cargo test -p warp --lib deletion_preflight_rejects_self_merge_target -- --nocapture +``` + +Expected: missing upstream validation, `MergeTargetSnapshot`, and `InvalidMergeTarget` failures. + +- [ ] **Step 3: Add exact creation and preflight types** + +Add: + +```rust +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MergeTargetSnapshot { + pub full_ref: String, + pub oid: String, + pub is_merged: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeletionPreflight { + pub worktree_path: PathBuf, + pub branch: String, + pub branch_ref: String, + pub branch_oid: String, + pub merge_target: Option, +} +``` + +Extract the existing read-only checks into a shared private capture function, then keep the public UI preflight as a thin wrapper: + +```rust +fn capture_deletion_candidate( + repository: &Path, + worktree_path: &Path, + include_merge_target: bool, +) -> Result { + let worktree_path = canonicalize(worktree_path)?; + let mut matches = list_worktrees(repository)? + .into_iter() + .filter(|worktree| worktree.path == worktree_path); + let Some(worktree) = matches.next() else { + return Err(GitWorkspaceError::WorktreeNotFound { + path: worktree_path, + }); + }; + if matches.next().is_some() { + return Err(GitWorkspaceError::AmbiguousWorktree { + path: worktree_path, + }); + } + if worktree.is_bare || worktree.is_detached { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + } + let Some(branch_ref) = worktree.branch else { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + }; + let Some(branch) = branch_ref.strip_prefix("refs/heads/") else { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + }; + if branch.is_empty() { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + } + let branch_snapshot = direct_ref_snapshot(repository, &branch_ref)?; + if branch_snapshot.symbolic_target.is_some() { + return Err(GitWorkspaceError::WorktreeHasNoLocalBranch { + path: worktree_path, + }); + } + let Some(branch_oid) = branch_snapshot.direct_oid else { + return Err(GitWorkspaceError::BranchNotFound { full_ref: branch_ref }); + }; + let status = git_output_for_operation( + &worktree_path, + "check worktree status", + &["status", "--porcelain", "--untracked-files=all"], + )?; + if !status.stdout.is_empty() { + return Err(GitWorkspaceError::DirtyWorktree { + path: worktree_path, + }); + } + let merge_target = if include_merge_target { + let target_ref = resolve_merge_target_ref(repository, &branch_ref)?; + if target_ref == branch_ref { + return Err(GitWorkspaceError::InvalidMergeTarget { + branch_ref, + target_ref, + }); + } + let target_oid = resolve_commit_oid(repository, &target_ref)?; + Some(MergeTargetSnapshot { + is_merged: is_ancestor(repository, &branch_oid, &target_oid)?, + full_ref: target_ref, + oid: target_oid, + }) + } else { + None + }; + Ok(DeletionPreflight { + worktree_path, + branch: branch.to_string(), + branch_ref, + branch_oid, + merge_target, + }) +} + +pub fn deletion_preflight( + repository: &Path, + worktree_path: &Path, + delete_branch: bool, +) -> Result { + capture_deletion_candidate(repository, worktree_path, delete_branch) +} +``` + +For `include_merge_target=false`, set `merge_target=None` and do not query remote/upstream. For `include_merge_target=true`: + +```rust +let target_ref = resolve_merge_target_ref(repository, &full_ref)?; +if target_ref == full_ref { + return Err(GitWorkspaceError::InvalidMergeTarget { + branch_ref: full_ref, + target_ref, + }); +} +let target_oid = resolve_commit_oid(repository, &target_ref)?; +let is_merged = is_ancestor(repository, &branch_oid, &target_oid)?; +``` + +Define both helpers beside the existing `branch_upstream` and `resolve_commit_oid` helpers: + +```rust +fn resolve_merge_target_ref( + repository: &Path, + branch_ref: &str, +) -> Result { + if let Some(upstream) = branch_upstream(repository, branch_ref)? { + return Ok(upstream); + } + let remote = primary_remote(repository)?; + match default_branch(repository, &remote)? { + BranchRef::Remote { full_ref, .. } => Ok(full_ref), + BranchRef::Local { full_ref, .. } => Err(GitWorkspaceError::InvalidRemoteRef { full_ref }), + } +} + +fn is_ancestor( + repository: &Path, + branch_oid: &str, + target_oid: &str, +) -> Result { + let args = ["merge-base", "--is-ancestor", branch_oid, target_oid]; + let output = git_output_allow_failure_for_operation( + repository, + "check branch merge status", + &args, + )?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + Some(_) | None => Err(command_failed("check branch merge status", &args, &output)), + } +} +``` + +Add errors: + +```rust +#[error("merge target `{target_ref}` must differ from branch `{branch_ref}`")] +InvalidMergeTarget { + branch_ref: String, + target_ref: String, +}, + +#[error("branch `{branch}` unexpectedly tracks `{upstream}`")] +UnexpectedBranchUpstream { + branch: String, + upstream: String, +}, +``` + +`verify_remote_worktree_creation` calls `branch_upstream(repository, &expected_branch)?` after direct OID validation and returns `UnexpectedBranchUpstream` for `Some(upstream)`. Add `#[source]` to `verification_error` in `WorktreeCreationVerificationFailed`. + +- [ ] **Step 4: Update existing preflight assertions and run GREEN** + +Replace `preflight.is_merged` / string sentinel assertions with: + +```rust +let target = preflight.merge_target.as_ref().unwrap(); +assert!(target.is_merged); +assert_eq!(target.full_ref, "refs/remotes/origin/main"); +assert_eq!(target.oid, ref_oid(&fixture.root, &target.full_ref)); +``` + +Keep-branch tests assert `preflight.merge_target.is_none()`. + +```bash +rustfmt --edition 2021 app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +cargo test -p warp --lib project_organization::git_tests -- --nocapture +git add app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +git commit -m "feat: capture deletion target snapshots" +``` + +### Task 4: Move authoritative deletion validation under ref locks + +**Files:** +- Modify: `app/src/project_organization/git.rs:840-1140` +- Modify: `app/src/project_organization/git_tests.rs:1543-1951` + +- [ ] **Step 1: Write failing lock-before-authority race tests** + +Expose one test-only entrypoint around the production core: + +```rust +#[cfg(test)] +pub(crate) fn remove_workspace_with_transaction_hooks( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + after_candidate: AfterCandidate, + after_prepared: AfterPrepared, +) -> Result<(), GitWorkspaceError> +where + AfterCandidate: FnOnce(), + AfterPrepared: FnOnce(), +{ + remove_workspace_inner( + repository, + worktree_path, + branch, + delete_branch, + force_branch, + after_candidate, + after_prepared, + ) +} +``` + +```rust +#[test] +fn locked_deletion_accepts_same_oid_recreation_before_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/same-oid-current"); + let branch_ref = "refs/heads/feature/same-oid-current"; + let oid = ref_oid(&fixture.root, branch_ref); + + remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/same-oid-current", + true, + false, + || { + run_git(&fixture.root, &["update-ref", "-d", branch_ref, &oid]); + run_git(&fixture.root, &["update-ref", branch_ref, &oid]); + }, + || {}, + ) + .unwrap(); + + assert!(!worktree.exists()); + assert!(!ref_exists(&fixture.root, branch_ref)); +} + +#[test] +fn locked_deletion_rejects_different_oid_before_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/different-oid"); + let branch_ref = "refs/heads/feature/different-oid"; + let old_oid = ref_oid(&fixture.root, branch_ref); + let changed_oid = fixture_commit_oid(&fixture.root, "changed branch"); + + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/different-oid", + true, + false, + || run_git(&fixture.root, &["update-ref", branch_ref, &changed_oid, &old_oid]), + || {}, + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::RefTransaction { .. })); + assert!(worktree.exists()); + assert_eq!(ref_oid(&fixture.root, branch_ref), changed_oid); +} + +#[test] +fn locked_deletion_rejects_target_drift_before_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/target-drift"); + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/target-drift", + true, + false, + || advance_remote_main(&fixture), + || {}, + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::RefTransaction { .. })); + assert!(worktree.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/target-drift")); +} + +#[test] +fn force_deletion_does_not_require_remote_metadata() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["remote", "remove", "origin"]); + let worktree = fixture.add_linked_worktree("feature/force-without-remote"); + + remove_workspace( + &fixture.root, + &worktree, + "feature/force-without-remote", + true, + true, + ) + .unwrap(); + + assert!(!worktree.exists()); + assert!(!ref_exists( + &fixture.root, + "refs/heads/feature/force-without-remote" + )); +} +``` + +`fixture_commit_oid` uses `git commit-tree` with `HEAD` as its parent. `advance_remote_main` captures the current `refs/remotes/origin/main` OID, creates a new commit with `fixture_commit_oid`, and runs `git update-ref refs/remotes/origin/main `. Define each helper once in `git_tests.rs`. + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p warp --lib locked_deletion_ -- --nocapture +``` + +Expected: missing transaction hook/core and current post-remove compare-delete behavior fails the new assertions. + +- [ ] **Step 3: Implement candidate capture, prepare, and lock-internal validation** + +Add the wrapper error: + +```rust +#[error("failed to prepare branch deletion transaction: {source}")] +RefTransaction { + #[source] + source: RefTransactionError, +}, +``` + +Production flow must be structurally equivalent to: + +```rust +let candidate = capture_deletion_candidate( + repository, + worktree_path, + delete_branch && !force_branch, +)?; +validate_requested_branch(branch, &candidate)?; + +if !delete_branch { + return remove_registered_worktree(repository, &candidate.worktree_path); +} + +after_candidate(); +let branch_ref = candidate.branch_ref.clone(); +let locked_target = if force_branch { + None +} else { + let target = candidate.merge_target.as_ref().ok_or_else(|| { + GitWorkspaceError::MissingMergeTarget { + branch_ref: branch_ref.clone(), + } + })?; + Some(LockedRef { + full_ref: &target.full_ref, + oid: &target.oid, + }) +}; + +let mut transaction = PreparedRefDelete::prepare( + repository, + &branch_ref, + &candidate.branch_oid, + locked_target, +) +.map_err(|source| GitWorkspaceError::RefTransaction { source })?; +after_prepared(); + +let registered_path = validate_locked_deletion( + repository, + worktree_path, + branch, + &candidate.branch_oid, + candidate.merge_target.as_ref(), + force_branch, +)?; +``` + +Add the unreachable-state error rather than using an empty sentinel: + +```rust +#[error("branch deletion candidate for `{branch_ref}` has no merge target")] +MissingMergeTarget { branch_ref: String }, +``` + +The public function calls one generic private core with no-op hooks. The test entrypoint uses the same core with its closures: + +```rust +fn remove_workspace_inner( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + after_candidate: AfterCandidate, + after_prepared: AfterPrepared, +) -> Result<(), GitWorkspaceError> +where + AfterCandidate: FnOnce(), + AfterPrepared: FnOnce(), +{ + let candidate = capture_deletion_candidate( + repository, + worktree_path, + delete_branch && !force_branch, + )?; + validate_requested_branch(branch, &candidate)?; + if !delete_branch { + return remove_registered_worktree(repository, &candidate.worktree_path); + } + after_candidate(); + let locked_target = if force_branch { + None + } else { + let target = candidate.merge_target.as_ref().ok_or_else(|| { + GitWorkspaceError::MissingMergeTarget { + branch_ref: candidate.branch_ref.clone(), + } + })?; + Some(LockedRef { + full_ref: &target.full_ref, + oid: &target.oid, + }) + }; + let mut transaction = PreparedRefDelete::prepare( + repository, + &candidate.branch_ref, + &candidate.branch_oid, + locked_target, + ) + .map_err(|source| GitWorkspaceError::RefTransaction { source })?; + after_prepared(); + let registered_path = match validate_locked_deletion( + repository, + worktree_path, + branch, + &candidate.branch_oid, + candidate.merge_target.as_ref(), + force_branch, + ) { + Ok(path) => path, + Err(operation_error) => return abort_after_failed_operation(transaction, operation_error), + }; + if let Err(operation_error) = remove_registered_worktree(repository, ®istered_path) { + return abort_after_failed_operation(transaction, operation_error); + } + transaction + .commit() + .map_err(|source| GitWorkspaceError::RefTransaction { source }) +} + +fn abort_after_failed_operation( + transaction: PreparedRefDelete, + operation_error: GitWorkspaceError, +) -> Result<(), GitWorkspaceError> { + match transaction.abort() { + Ok(()) => Err(operation_error), + Err(abort_error) => Err(GitWorkspaceError::BranchDeleteAbortFailed { + operation_error: Box::new(operation_error), + abort_error, + }), + } +} +``` + +Define these concrete helpers before the core: + +```rust +fn validate_requested_branch( + requested_branch: &str, + candidate: &DeletionPreflight, +) -> Result<(), GitWorkspaceError> { + if candidate.branch == requested_branch { + Ok(()) + } else { + Err(GitWorkspaceError::WorktreeBranchMismatch { + expected: requested_branch.to_string(), + actual: candidate.branch.clone(), + }) + } +} + +fn remove_registered_worktree( + repository: &Path, + registered_path: &Path, +) -> Result<(), GitWorkspaceError> { + let args = [ + OsStr::new("worktree"), + OsStr::new("remove"), + registered_path.as_os_str(), + ]; + git_output_with_os_args_for_operation(repository, "remove worktree", &args)?; + Ok(()) +} + +fn validate_locked_deletion( + repository: &Path, + requested_path: &Path, + requested_branch: &str, + expected_branch_oid: &str, + expected_target: Option<&MergeTargetSnapshot>, + force_branch: bool, +) -> Result { + let locked = capture_deletion_candidate(repository, requested_path, !force_branch)?; + validate_requested_branch(requested_branch, &locked)?; + if locked.branch_oid != expected_branch_oid { + let actual_snapshot = direct_ref_snapshot(repository, &locked.branch_ref)?; + return Err(GitWorkspaceError::BranchChanged { + branch: locked.branch, + expected_oid: expected_branch_oid.to_string(), + actual_oid: actual_snapshot.direct_oid, + actual_symbolic_target: actual_snapshot.symbolic_target, + }); + } + if !force_branch { + let expected_target = expected_target.ok_or_else(|| { + GitWorkspaceError::MissingMergeTarget { + branch_ref: locked.branch_ref.clone(), + } + })?; + let actual_target = locked.merge_target.ok_or_else(|| { + GitWorkspaceError::MissingMergeTarget { + branch_ref: locked.branch_ref.clone(), + } + })?; + if actual_target.full_ref != expected_target.full_ref { + return Err(GitWorkspaceError::MergeTargetChanged { + expected: expected_target.full_ref.clone(), + actual: actual_target.full_ref, + }); + } + if actual_target.oid != expected_target.oid { + return Err(GitWorkspaceError::RefChanged { + full_ref: expected_target.full_ref.clone(), + expected_oid: expected_target.oid.clone(), + actual_oid: actual_target.oid, + }); + } + if !actual_target.is_merged { + return Err(GitWorkspaceError::BranchNotMerged { + branch: locked.branch, + merge_target: expected_target.full_ref.clone(), + }); + } + } + Ok(locked.worktree_path) +} +``` + +`validate_locked_deletion` must rerun, in order: + +1. canonicalize requested path and find exactly one matching `list_worktrees` record; +2. reject bare/detached/missing/non-local branch and requested branch mismatch; +3. require direct branch ref at expected OID with no symbolic target; +4. run `status --porcelain --untracked-files=all` and reject any output; +5. for non-force deletion, re-resolve target selection and require the same full ref; +6. resolve the locked target OID and require the expected OID; +7. run `merge-base --is-ancestor ` and return `BranchNotMerged` when false. + +Do not reject based on the earlier `candidate.merge_target.is_merged`; that value is advisory only. + +- [ ] **Step 4: Add prepared-state authority and abort tests** + +```rust +#[test] +fn locked_deletion_aborts_when_worktree_becomes_dirty_after_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/dirty-after-lock"); + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/dirty-after-lock", + true, + false, + || {}, + || std::fs::write(worktree.join("keep.txt"), "keep").unwrap(), + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::DirtyWorktree { .. })); + assert!(worktree.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/dirty-after-lock")); +} + +#[test] +fn locked_deletion_aborts_when_target_selection_changes_after_prepare() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/target-selection"); + run_git(&fixture.root, &["push", "origin", "main:refs/heads/other"]); + run_git(&fixture.root, &["fetch", "origin"]); + + let error = remove_workspace_with_transaction_hooks( + &fixture.root, + &worktree, + "feature/target-selection", + true, + false, + || {}, + || { + run_git( + &fixture.root, + &[ + "branch", + "--set-upstream-to=origin/other", + "feature/target-selection", + ], + ); + }, + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::MergeTargetChanged { .. })); + assert!(worktree.exists()); + assert!(ref_exists( + &fixture.root, + "refs/heads/feature/target-selection" + )); +} +``` + +Add: + +```rust +#[error("merge target changed from `{expected}` to `{actual}` while branch deletion was locked")] +MergeTargetChanged { expected: String, actual: String }, + +#[error("ref `{full_ref}` changed while branch deletion was locked: expected {expected_oid}, found {actual_oid}")] +RefChanged { + full_ref: String, + expected_oid: String, + actual_oid: String, +}, +``` + +When any lock-internal validation or `worktree remove` fails, call `transaction.abort()`. If abort succeeds, return the original operation error. If abort fails, preserve the operation error as the primary source: + +```rust +#[error("{operation_error}; aborting the branch deletion transaction also failed: {abort_error}")] +BranchDeleteAbortFailed { + #[source] + operation_error: Box, + abort_error: RefTransactionError, +}, +``` + +- [ ] **Step 5: Run GREEN and commit the locked deletion path** + +```bash +rustfmt --edition 2021 app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +cargo test -p warp --lib locked_deletion_ -- --nocapture +cargo test -p warp --lib force_deletion_does_not_require_remote_metadata -- --nocapture +cargo test -p warp --lib project_organization::git_tests -- --nocapture +git add app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +git commit -m "fix: validate worktree deletion under ref locks" +``` + +### Task 5: Preserve partial mutation and error sources + +**Files:** +- Modify: `app/src/project_organization/git.rs:62-245, 840-1140` +- Modify: `app/src/project_organization/git_tests.rs:1666-1926` +- Modify: `specs/repository-workspaces/TECH.md` + +- [ ] **Step 1: Write failing remove/commit/source-chain tests** + +Add test-only hooks around the same private deletion core: a `remove_runner` closure replacing only `git worktree remove`, and an `after_remove` closure receiving `&mut PreparedRefDelete`. Add `#[cfg(test)] pub(super) fn terminate_for_test(&mut self)` to the transaction module so commit failure can be injected after real worktree removal. + +Refactor the Task 4 core once, retaining the same production behavior: + +```rust +fn remove_workspace_inner( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + after_candidate: AfterCandidate, + after_prepared: AfterPrepared, + remove_runner: RemoveRunner, + after_remove: AfterRemove, +) -> Result<(), GitWorkspaceError> +where + AfterCandidate: FnOnce(), + AfterPrepared: FnOnce(), + RemoveRunner: FnOnce(&Path, &Path) -> Result<(), GitWorkspaceError>, + AfterRemove: FnOnce(&mut PreparedRefDelete), +``` + +After `validate_locked_deletion` succeeds, replace the Task 4 `remove_registered_worktree` call with: + +```rust +if let Err(operation_error) = remove_runner(repository, ®istered_path) { + return abort_after_failed_operation(transaction, operation_error); +} +after_remove(&mut transaction); +``` + +Production calls the core with `remove_registered_worktree` and `|_| {}`. Add the test wrappers: + +```rust +#[cfg(test)] +pub(crate) fn remove_workspace_with_remove_runner( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + remove_runner: RemoveRunner, +) -> Result<(), GitWorkspaceError> +where + RemoveRunner: FnOnce(&Path, &Path) -> Result<(), GitWorkspaceError>, +{ + remove_workspace_inner( + repository, + worktree_path, + branch, + delete_branch, + force_branch, + || {}, + || {}, + remove_runner, + |_| {}, + ) +} + +#[cfg(test)] +pub(crate) fn remove_workspace_with_after_remove_hook( + repository: &Path, + worktree_path: &Path, + branch: &str, + delete_branch: bool, + force_branch: bool, + after_remove: AfterRemove, +) -> Result<(), GitWorkspaceError> +where + AfterRemove: FnOnce(&mut PreparedRefDelete), +{ + remove_workspace_inner( + repository, + worktree_path, + branch, + delete_branch, + force_branch, + || {}, + || {}, + remove_registered_worktree, + after_remove, + ) +} +``` + +Update the Task 4 `remove_workspace_with_transaction_hooks` call to append `remove_registered_worktree` and `|_| {}` after `after_prepared`. + +```rust +#[test] +fn worktree_remove_failure_aborts_and_preserves_branch() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/remove-failure"); + let error = remove_workspace_with_remove_runner( + &fixture.root, + &worktree, + "feature/remove-failure", + true, + false, + |_repository, _path| Err(injected_command_error("remove worktree")), + ) + .unwrap_err(); + + assert!(matches!(error, GitWorkspaceError::CommandFailed { .. })); + assert!(worktree.exists()); + assert!(ref_exists(&fixture.root, "refs/heads/feature/remove-failure")); +} + +#[test] +fn commit_failure_reports_removed_worktree_partial_state() { + let fixture = GitFixture::new(); + let worktree = fixture.add_linked_worktree("feature/commit-failure"); + let error = remove_workspace_with_after_remove_hook( + &fixture.root, + &worktree, + "feature/commit-failure", + true, + false, + |transaction| transaction.terminate_for_test(), + ) + .unwrap_err(); + + assert!(matches!( + error, + GitWorkspaceError::BranchDeleteTransactionFailed { + worktree_removed: true, + .. + } + )); + assert!(!worktree.exists()); +} + +#[test] +fn wrapper_errors_expose_primary_sources() { + use std::error::Error; + + let fixture = GitFixture::new(); + let target = fixture.tempdir.path().join("source chain target"); + let creation = create_from_remote_with_runner( + &fixture.root, + "refs/remotes/origin/main", + "feature/source-chain", + &target, + |_repository, _remote_ref, _branch, _target, _expected_oid| { + Err(injected_command_error("create worktree")) + }, + ) + .unwrap_err(); + assert!(matches!(creation.source(), Some(source) if source.to_string().contains("create worktree"))); +} +``` + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p warp --lib worktree_remove_failure_aborts_and_preserves_branch -- --nocapture +cargo test -p warp --lib commit_failure_reports_removed_worktree_partial_state -- --nocapture +cargo test -p warp --lib wrapper_errors_expose_primary_sources -- --nocapture +``` + +- [ ] **Step 3: Implement commit-failure context and source annotations** + +Add: + +```rust +#[error( + "worktree `{worktree_path}` was removed, but committing deletion of `{branch_ref}` at {branch_oid} failed: {source}; branch inspection error: {inspection_error:?}" +)] +BranchDeleteTransactionFailed { + worktree_path: PathBuf, + worktree_removed: bool, + branch_ref: String, + branch_oid: String, + merge_target_ref: Option, + merge_target_oid: Option, + #[source] + source: RefTransactionError, + inspection_error: Option>, +}, +``` + +After real `worktree remove`, run the test hook and call `transaction.commit()`. On failure, inspect `direct_ref_snapshot` only for diagnostics; inspection failure is secondary and must not replace the transaction source. + +Replace the Task 4 commit mapping with: + +```rust +after_remove(&mut transaction); +if let Err(source) = transaction.commit() { + let branch_ref = candidate.branch_ref.clone(); + let branch_oid = candidate.branch_oid.clone(); + let merge_target_ref = candidate + .merge_target + .as_ref() + .map(|target| target.full_ref.clone()); + let merge_target_oid = candidate + .merge_target + .as_ref() + .map(|target| target.oid.clone()); + let (inspection_error, actual_snapshot) = match direct_ref_snapshot(repository, &branch_ref) { + Ok(snapshot) => (None, Some(snapshot)), + Err(error) => (Some(Box::new(error)), None), + }; + return Err(GitWorkspaceError::BranchDeleteTransactionFailed { + worktree_path: registered_path, + worktree_removed: true, + branch_ref, + branch_oid: branch_oid.clone(), + merge_target_ref, + merge_target_oid, + source, + inspection_error: inspection_error.or_else(|| { + actual_snapshot.and_then(|snapshot| { + (snapshot.direct_oid.is_some() || snapshot.symbolic_target.is_some()).then(|| { + Box::new(GitWorkspaceError::BranchChanged { + branch: branch.to_string(), + expected_oid: branch_oid, + actual_oid: snapshot.direct_oid, + actual_symbolic_target: snapshot.symbolic_target, + }) + }) + }) + }), + }); +} +Ok(()) +``` + +Add `#[source]` to the single primary wrapped error in `WorktreeCreationVerificationFailed`. Remove `#[source]` from secondary cleanup fields. For wrappers with operation plus cleanup failure, annotate the operation error, not cleanup. + +- [ ] **Step 4: Remove superseded ownership and compare-delete code** + +Delete these variants and helpers after all call sites are migrated: + +```text +BranchClaimFailed +BranchCleanupFailed +BranchDeleteFailed +BranchDeleteNotCompleted +WorktreeCreationBranchChanged +WorktreeCreationCleanupFailed +cleanup_failed_remote_creation* +run_branch_compare_delete +remove_workspace_with_delete_runner +remove_workspace_with_inspection_runner +``` + +Keep `BranchChanged` because creation verification and lock-internal validation still use it. Keep `DirectRefSnapshot` for diagnostics. Every test-only hook remains `#[cfg(test)] pub(crate)` or `pub(super)`. + +- [ ] **Step 5: Synchronize TECH.md** + +Update the repository Git service and deletion sections with these exact semantics: + +```text +- Local and remote worktree creation atomically claim the final target directory. +- Remote creation uses one `worktree add --no-track -b` command. +- Failed remote creation never automatically deletes a branch; it only removes an empty, unregistered claimed directory. +- Deletion preflight is advisory. Authoritative validation runs after a prepared ref transaction locks branch and merge target. +- The transaction queues target `verify` plus branch `delete `; it never queues branch `verify` and `delete` together. +- Worktree remove failure aborts the transaction. Commit failure after worktree removal returns explicit partial state. +``` + +Remove stale OID-only cleanup/delete promises. + +- [ ] **Step 6: Run focused verification and commit** + +```bash +rustfmt --edition 2021 app/src/project_organization/git.rs app/src/project_organization/git_tests.rs app/src/project_organization/git/ref_transaction.rs app/src/project_organization/git/ref_transaction_tests.rs +cargo test -p warp --lib project_organization::git::ref_transaction::tests -- --nocapture +cargo test -p warp --lib project_organization::git_tests -- --nocapture +git add app/src/project_organization specs/repository-workspaces/TECH.md +git commit -m "fix: preserve worktree deletion partial state" +``` + +### Task 6: Final verification and review + +**Files:** +- Verify: `app/src/project_organization/git.rs` +- Verify: `app/src/project_organization/git_tests.rs` +- Verify: `app/src/project_organization/git/ref_transaction.rs` +- Verify: `app/src/project_organization/git/ref_transaction_tests.rs` +- Verify: `docs/superpowers/specs/2026-07-13-worktree-git-ownership-design.md` +- Verify: `specs/repository-workspaces/TECH.md` + +- [ ] **Step 1: Verify module and API boundaries** + +```bash +rg -n 'std::process::Command|Command::new\("(?:sh|bash|zsh|cmd|powershell)' app/src/project_organization/git.rs app/src/project_organization/git +rg -n 'to_str\(|to_string_lossy' app/src/project_organization/git.rs app/src/project_organization/git +rg -n 'cleanup_failed_remote_creation|run_branch_compare_delete|BranchClaimFailed|BranchDeleteFailed|reflog' app/src/project_organization/git.rs app/src/project_organization/git_tests.rs app/src/project_organization/git +``` + +Expected: no direct process launcher, shell launcher, production lossy path conversion, obsolete ownership helper, or reflog generation implementation. `std::process::Child*` type names inside `ref_transaction.rs` are allowed; process creation must still use `command::blocking::Command`. + +- [ ] **Step 2: Run fresh focused tests** + +```bash +cargo test -p warp --lib project_organization::git::ref_transaction::tests -- --nocapture +cargo test -p warp --lib project_organization::git_tests -- --nocapture +``` + +Expected: both commands execute non-zero test counts and pass. + +- [ ] **Step 3: Run build and diff hygiene checks** + +```bash +cargo check -p warp +git diff --check 3e88797d..HEAD +git status --short +``` + +Expected: `cargo check` exits 0 with only known baseline warnings; diff check prints nothing; worktree is clean. + +- [ ] **Step 4: Run fresh specification review** + +Use `superpowers:requesting-code-review` with a fresh spec reviewer. Provide: + +```text +Review commits 3e88797d..HEAD against: +- docs/superpowers/specs/2026-07-13-worktree-git-ownership-design.md +- specs/repository-workspaces/PRODUCT.md behaviors 15, 18, 29-33, 35-36 +- specs/repository-workspaces/TECH.md Git creation/deletion sections +Check every required creation residual, prepared transaction, lock-internal validation, force-delete, partial mutation, and source-chain behavior. +``` + +If the reviewer reports findings, present all findings to the user and wait for confirmation before modifying code. + +- [ ] **Step 5: Run fresh quality review** + +After spec review passes, use a different fresh reviewer and request review of concurrency safety, child-process lifecycle, stderr draining, error sources, path handling, test determinism, and obsolete-code removal. If findings exist, present all findings to the user and wait for confirmation before fixes. + +- [ ] **Step 6: Independently rerun verification after approved review fixes** + +```bash +cargo test -p warp --lib project_organization::git::ref_transaction::tests -- --nocapture +cargo test -p warp --lib project_organization::git_tests -- --nocapture +cargo check -p warp +git diff --check 3e88797d..HEAD +git status --short +``` + +Do not claim completion unless all commands have fresh successful output and the worktree is clean. Commit approved review fixes with a scoped English message; do not create an empty commit. diff --git a/docs/superpowers/plans/2026-07-15-adopt-existing-worktree.md b/docs/superpowers/plans/2026-07-15-adopt-existing-worktree.md new file mode 100644 index 00000000000..24ec99b15b2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-adopt-existing-worktree.md @@ -0,0 +1,483 @@ +# Adopt Existing Worktree Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让用户从当前 repository 的已注册 linked worktree 中选择一个,将其安全注册为 workspace,而不创建或删除 Git worktree。 + +**Architecture:** Git 层将已有 `WorktreeInfo` 转换为可接入候选项,并在提交时重新校验 canonical path 与本地 branch。`CreateWorkspaceModal` 增加独立的 existing-worktree mode、picker 与加载错误状态。`Workspace` 并行加载 remote refs 和 worktree 候选项;adoption source 只执行只读验证,随后复用已有的 SQLite、tab 切换与 terminal 创建逻辑。 + +**Tech Stack:** Rust 2021、WarpUI `FilterableDropdown`、Git CLI `worktree list --porcelain`、Cargo tests。 + +--- + +## 文件结构 + +- 修改:`app/src/project_organization/git.rs` + - 定义 worktree candidate 映射和不检查脏状态的 adoption validation。 +- 修改:`app/src/project_organization/git_tests.rs` + - 覆盖候选过滤和提交前注册/branch 校验。 +- 修改:`app/src/project_organization/view/create_workspace_modal.rs` + - 增加 existing-worktree mode、picker、路径只读渲染与 Retry event。 +- 修改:`app/src/project_organization/view/create_workspace_modal_tests.rs` + - 覆盖候选转换、request source 与 submit disable 策略。 +- 修改:`app/src/workspace/view.rs` + - 加载已有 worktree;只读验证 adoption source;避免持久化失败时删除用户已有 worktree。 + +### Task 1: 建立已有 worktree 候选与提交前校验 + +**Files:** +- Modify: `app/src/project_organization/git_tests.rs:480-555,2100-2210` +- Modify: `app/src/project_organization/git.rs:37-61,486-500,720-800` + +- [ ] **Step 1: 编写候选映射与验证的失败测试** + +在 `git_tests.rs` imports 中加入 `existing_worktree_options`、`validate_existing_worktree` 和 `WorktreeInfo`。新增: + +```rust +#[test] +fn existing_worktree_options_exclude_primary_detached_and_prunable_worktrees() { + let repository_root = PathBuf::from("/tmp/repository"); + let options = existing_worktree_options( + &repository_root, + [ + WorktreeInfo { + path: repository_root.clone(), + head: Some("a".to_string()), + branch: Some("refs/heads/main".to_string()), + is_bare: false, + is_detached: false, + is_locked: false, + locked_reason: None, + is_prunable: false, + prunable_reason: None, + }, + WorktreeInfo { + path: PathBuf::from("/tmp/repository-feature"), + head: Some("b".to_string()), + branch: Some("refs/heads/feature/existing".to_string()), + is_bare: false, + is_detached: false, + is_locked: false, + locked_reason: None, + is_prunable: false, + prunable_reason: None, + }, + WorktreeInfo { + path: PathBuf::from("/tmp/repository-detached"), + head: Some("c".to_string()), + branch: None, + is_bare: false, + is_detached: true, + is_locked: false, + locked_reason: None, + is_prunable: false, + prunable_reason: None, + }, + WorktreeInfo { + path: PathBuf::from("/tmp/repository-prunable"), + head: Some("d".to_string()), + branch: Some("refs/heads/feature/prunable".to_string()), + is_bare: false, + is_detached: false, + is_locked: false, + locked_reason: None, + is_prunable: true, + prunable_reason: Some("missing".to_string()), + }, + ], + ); + + assert_eq!( + options, + vec![ExistingWorktreeOption::new( + PathBuf::from("/tmp/repository-feature"), + "feature/existing", + )], + ); +} + +#[test] +fn validates_registered_existing_worktree_without_rejecting_dirty_contents() { + let fixture = GitFixture::new(); + let worktree_path = fixture.add_linked_worktree("feature/adopt"); + std::fs::write(worktree_path.join("untracked.txt"), "dirty").unwrap(); + + assert_eq!( + validate_existing_worktree(&fixture.root, &worktree_path, "feature/adopt").unwrap(), + worktree_path.canonicalize().unwrap(), + ); +} +``` + +- [ ] **Step 2: 运行测试并验证 RED** + +Run: + +```bash +cargo test -p warp existing_worktree_options_exclude_primary_detached_and_prunable_worktrees --lib -- --nocapture +cargo test -p warp validates_registered_existing_worktree_without_rejecting_dirty_contents --lib -- --nocapture +``` + +Expected: 编译失败,因为 `ExistingWorktreeOption`、`existing_worktree_options` 和 `validate_existing_worktree` 不存在。 + +- [ ] **Step 3: 实现候选映射与只读 validation** + +在 `git.rs` 添加: + +```rust +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExistingWorktreeOption { + pub path: PathBuf, + pub branch_name: String, +} + +impl ExistingWorktreeOption { + pub fn new(path: PathBuf, branch_name: impl Into) -> Self { + Self { + path, + branch_name: branch_name.into(), + } + } +} + +pub fn existing_worktree_options( + repository_root: &Path, + worktrees: impl IntoIterator, +) -> Vec { + let mut options = worktrees + .into_iter() + .filter_map(|worktree| { + let branch_name = worktree.branch?.strip_prefix("refs/heads/")?.to_string(); + (!worktree.is_bare + && !worktree.is_detached + && !worktree.is_prunable + && worktree.path != repository_root + && !branch_name.is_empty()) + .then_some(ExistingWorktreeOption::new(worktree.path, branch_name)) + }) + .collect::>(); + options.sort_by(|left, right| { + (&left.branch_name, &left.path).cmp(&(&right.branch_name, &right.path)) + }); + options +} +``` + +新增 `validate_existing_worktree(repository, worktree_path, local_branch) -> Result`: + +1. `canonicalize(worktree_path)`。 +2. 在 `GitWorkspaceError` 添加: + +```rust +#[error("repository primary worktree `{path}` cannot be registered as a workspace")] +PrimaryWorktreeCannotBeWorkspace { path: PathBuf }, +``` + +若 canonical path 等于 `canonicalize(repository)`,返回该 error。 +3. 重新 `list_worktrees(repository)`,要求路径恰好出现一次,否则返回已有 `WorktreeNotFound` 或 `AmbiguousWorktree`。 +4. 要求该项非 bare、非 detached,且 `branch == Some(format!("refs/heads/{local_branch}"))`;否则返回已有 `WorktreeBranchMismatch`。 +5. 调用已有 `validate_ref_exists(repository, &expected_branch)`,返回 canonical path。 + +不要调用 `deletion_preflight`,因为 adoption 必须允许 dirty worktree。增加: + +```rust +pub async fn validate_existing_worktree_async( + repository: PathBuf, + worktree_path: PathBuf, + local_branch: String, +) -> Result { + spawn_git_task("validate existing worktree", move || { + validate_existing_worktree(&repository, &worktree_path, &local_branch) + }) + .await +} +``` + +- [ ] **Step 4: 运行测试并验证 GREEN** + +Run: + +```bash +cargo test -p warp existing_worktree_options_exclude_primary_detached_and_prunable_worktrees --lib -- --nocapture +cargo test -p warp validates_registered_existing_worktree_without_rejecting_dirty_contents --lib -- --nocapture +``` + +Expected: 两项通过。候选只保留 linked local branch worktree,dirty linked worktree 可通过只读 adoption validation。 + +- [ ] **Step 5: 提交 Git 层改动** + +```bash +git add app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +git commit -m "feat: validate existing worktrees" +``` + +### Task 2: 在创建弹窗选择已有 worktree + +**Files:** +- Modify: `app/src/project_organization/view/create_workspace_modal_tests.rs:1-190` +- Modify: `app/src/project_organization/view/create_workspace_modal.rs:20-920` + +- [ ] **Step 1: 编写 source 和 submit 策略的失败测试** + +在 modal tests imports 中加入 `ExistingWorktreeOption`。新增: + +```rust +#[test] +fn existing_worktree_form_builds_a_workspace_creation_request() { + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let mut form = CreateWorkspaceForm::new(); + form.set_mode(CreateWorkspaceMode::ExistingWorktree); + form.set_existing_worktree_branch("feature/adopt".to_string()); + + let request = form + .build_request( + repository_id, + workspace_id, + "feature/adopt".to_string(), + PathBuf::from("/tmp/repository-adopt"), + ) + .unwrap(); + + assert!(matches!( + request.source, + CreateWorkspaceSource::ExistingWorktree { local_branch } + if local_branch == "feature/adopt" + )); +} + +#[test] +fn existing_worktree_submit_is_disabled_until_a_selection_is_available() { + assert!(submit_is_disabled( + CreateWorkspaceMode::ExistingWorktree, + false, + false, + false, + )); + assert!(!submit_is_disabled( + CreateWorkspaceMode::ExistingWorktree, + false, + false, + true, + )); +} +``` + +- [ ] **Step 2: 运行测试并验证 RED** + +Run: + +```bash +cargo test -p warp existing_worktree_form_builds_a_workspace_creation_request --lib -- --nocapture +cargo test -p warp existing_worktree_submit_is_disabled_until_a_selection_is_available --lib -- --nocapture +``` + +Expected: 因 `ExistingWorktree` mode/source、form setter 或新的 submit 策略签名不存在而失败。 + +- [ ] **Step 3: 增加 mode、picker 与只读 path 渲染** + +导入 `ExistingWorktreeOption`,然后: + +1. 添加 `CreateWorkspaceMode::ExistingWorktree` 和 `CreateWorkspaceSource::ExistingWorktree { local_branch: String }`。 +2. 在 `CreateWorkspaceForm` 添加 `existing_worktree_branch: Option`、`set_existing_worktree_branch`,并让 mode 切换清理不兼容 selection;`can_submit` 对这个 mode 要求非空且不以 `refs/` 开头;`build_request` 生成 new source。 +3. 在 action 加 `SelectExistingWorktree(ExistingWorktreeOption)` 与 `RetryExistingWorktrees`;event 加 `RetryExistingWorktrees { repository_id, workspace_id }`,由 `CreateWorkspaceTarget` production helper 构造。 +4. 添加 `existing_worktree_picker`、`existing_worktree_mode_button`、`retry_existing_worktree_button`、`existing_worktree_options`、`selected_existing_worktree`、`existing_worktree_fetch_error`。所有 picker 继续使用 `set_top_bar_max_width(480.)` 和 `set_menu_width(480., ctx)`。 +5. `configure` 调用 `begin_existing_worktree_fetch`,用禁用的 `Fetching existing worktrees...` 占位项重置 picker/error/selection。 +6. `set_existing_worktrees` 使用 `existing_worktree_options(repository_root, worktrees)` 构造 `DropdownItem`,成功后启用 picker、缓存第一项但只在 existing mode 激活时应用它;`set_existing_worktree_fetch_error` 禁用 picker并保存 error。 +7. `select_existing_worktree` 设置 form branch、保存完整 option、将 `Workspace name` reset 为 branch name。不要写入 `worktree_path_editor`。 +8. `set_mode` 的 existing 分支应用缓存的当前 option;`try_submit` 从 option 直接取得 canonical `PathBuf`,remote/local 才继续读取 `worktree_path_editor`。existing mode 没有 option 时显示 `Select an existing worktree before creating a workspace.`。 +9. 把 `submit_is_disabled` 扩展为四个参数 `(mode, has_remote_fetch_error, has_existing_worktree_fetch_error, has_existing_worktree_selection)`;remote 保持已有行为,existing mode 在加载、错误或无选择时禁用,local 保持可用。所有 selection/error/mode transition 后调用 `sync_submit_button_disabled_state`。 +10. 渲染第三个 mode button。existing mode section 渲染 picker、其 error/Retry,并把 Worktree path section 替换为受 480px 约束的只读 `Text`,值为选项 path 的 `to_string_lossy()`;remote/local 继续显示现有 editor。不要用 `Clipped` 包裹 picker。 + +同时把现有 `remote_fetch_error_disables_submit_only_in_remote_mode` 测试的两次调用补齐为四个参数,existing fetch error 与 selection 参数都传 `false`,确保 remote 既有禁用行为仍被断言。 + +- [ ] **Step 4: 运行测试并验证 GREEN** + +Run: + +```bash +cargo test -p warp existing_worktree_form_builds_a_workspace_creation_request --lib -- --nocapture +cargo test -p warp existing_worktree_submit_is_disabled_until_a_selection_is_available --lib -- --nocapture +cargo test -p warp create_workspace_modal --lib -- --nocapture +``` + +Expected: 新 source 能构建请求;existing mode 仅在有合法 selection 时启用 Create;现有 modal tests 全部通过。 + +- [ ] **Step 5: 提交 modal 改动** + +```bash +git add app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs +git commit -m "feat: select existing worktrees" +``` + +### Task 3: 协调加载并安全接入已有 worktree + +**Files:** +- Modify: `app/src/workspace/view.rs:75-85,5630-5770,5950-6060` +- Modify: `app/src/workspace/view_test.rs` + +- [ ] **Step 1: 编写 adoption cleanup 策略的失败测试** + +在现有同模块测试文件 `app/src/workspace/view_test.rs` 中新增纯 helper 的测试: + +```rust +#[test] +fn existing_worktree_source_never_requests_git_cleanup() { + assert!(!source_creates_worktree(&CreateWorkspaceSource::ExistingWorktree { + local_branch: "feature/adopt".to_string(), + })); + assert!(source_creates_worktree(&CreateWorkspaceSource::ExistingLocalBranch { + local_branch: "feature/create".to_string(), + })); +} +``` + +- [ ] **Step 2: 运行测试并验证 RED** + +Run: + +```bash +cargo test -p warp existing_worktree_source_never_requests_git_cleanup --lib -- --nocapture +``` + +Expected: 因 `source_creates_worktree` 或新 source variant 不存在而失败。 + +- [ ] **Step 3: 加载候选、Retry 与 adoption validation** + +在 `workspace/view.rs` imports 中增加 `list_worktrees_async` 与 `validate_existing_worktree_async`。新增: + +```rust +fn fetch_existing_worktrees( + &mut self, + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, + repository_path: PathBuf, + ctx: &mut ViewContext, +) { + self.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if body.matches_target(repository_id, workspace_id) { + body.begin_existing_worktree_fetch(ctx); + } + }); + }); + ctx.spawn(list_worktrees_async(repository_path), move |workspace, result, ctx| { + workspace.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if !body.matches_target(repository_id, workspace_id) { + return; + } + match result { + Ok(worktrees) => body.set_existing_worktrees(worktrees, ctx), + Err(error) => body.set_existing_worktree_fetch_error( + format!("Failed to list existing worktrees: {error}"), + ctx, + ), + } + }); + }); + }); +} +``` + +`open_create_workspace_modal` 在 existing modal 打开后并行调用这个 helper 与 `fetch_create_workspace_branch_refs`。`handle_create_workspace_modal_body_event` 对 RetryExistingWorktrees 查找 repository 后重新调用 helper。所有 callback 保留已有 `(repository_id, workspace_id)` guard。 + +新增纯 helper: + +```rust +fn source_creates_worktree(source: &CreateWorkspaceSource) -> bool { + !matches!(source, CreateWorkspaceSource::ExistingWorktree { .. }) +} +``` + +在 `create_repository_workspace`: + +1. 新 source 从 `local_branch` 生成 record branch,`delete_branch_on_cleanup` 保持 `false`。 +2. Git operation 对 new source 调用: + +```rust +CreateWorkspaceSource::ExistingWorktree { local_branch } => { + validate_existing_worktree_async(repository_path, worktree_path, local_branch) + .await + .map(|_| ()) +} +``` + +不调用 create functions。 +3. 在 spawn 前计算: + +```rust +let should_cleanup_on_persistence_failure = source_creates_worktree(&request.source); +``` + +持久化失败时,只有这个值为 `true` 才调用 `remove_workspace_async`;否则直接显示 `Failed to save workspace: {error}`,不删除或修改用户 worktree。 +4. validation 成功后继续既有 record 持久化、`switch_repository_workspace` 和 terminal tab 创建。 + +- [ ] **Step 4: 运行测试并验证 GREEN** + +Run: + +```bash +cargo test -p warp existing_worktree_source_never_requests_git_cleanup --lib -- --nocapture +cargo test -p warp create_workspace_modal --lib -- --nocapture +cargo test -p warp validates_registered_existing_worktree_without_rejecting_dirty_contents --lib -- --nocapture +cargo check -p warp +``` + +Expected: adoption source 从不触发 cleanup;modal 与 Git validation 回归测试通过;crate 编译成功。 + +- [ ] **Step 5: 提交 Workspace 协调改动** + +```bash +git add app/src/workspace/view.rs app/src/workspace/view_test.rs +git commit -m "feat: adopt existing worktrees" +``` + +### Task 4: 复核与人工验证构建 + +**Files:** +- Modify: `app/src/project_organization/git.rs` +- Modify: `app/src/project_organization/git_tests.rs` +- Modify: `app/src/project_organization/view/create_workspace_modal.rs` +- Modify: `app/src/project_organization/view/create_workspace_modal_tests.rs` +- Modify: `app/src/workspace/view.rs` +- Modify: `app/src/workspace/view_test.rs` + +- [ ] **Step 1: 运行格式、diff 和相关测试** + +Run: + +```bash +cargo test -p warp existing_worktree_options_exclude_primary_detached_and_prunable_worktrees --lib -- --nocapture +cargo test -p warp validates_registered_existing_worktree_without_rejecting_dirty_contents --lib -- --nocapture +cargo test -p warp create_workspace_modal --lib -- --nocapture +cargo test -p warp existing_worktree_source_never_requests_git_cleanup --lib -- --nocapture +rustfmt --check app/src/project_organization/git.rs app/src/project_organization/git_tests.rs app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs +git diff --check +cargo check -p warp +``` + +Expected: 全部 exit code 为 0。若 `app/src/workspace/view.rs` 全文件 format check 被既有无关差异阻断,报告首个差异,不格式化整份文件。 + +- [ ] **Step 2: 生成 macOS 人工验证 bundle** + +Run: + +```bash +./script/run --dont-open +codesign --verify --deep --strict --verbose=2 target/debug/bundle/osx/Zap.app +``` + +Expected: `Zap.app` 生成且签名有效。手工验证:在带至少一个 linked worktree 的 repository 打开 Create workspace;切换到 existing mode;主目录和 detached worktree 不出现;选择 linked worktree 后 name 是 branch、path 只读;Create 后 Git worktree 数量不变且 terminal 位于已选路径;将该 worktree 切换 branch 或删除后再创建,操作失败且不会产生 SQLite record;worktree 列表失败时 remote/local mode 仍可用且 Retry 可恢复。 + +- [ ] **Step 3: 提交最终验证改动** + +```bash +git status --short +``` + +Expected: 不存在未提交的 implementation 文件;若只剩已提交规格/计划文档则无需额外 commit。建议最终 commit message: + +```text +feat: adopt existing worktrees as workspaces +``` diff --git a/docs/superpowers/plans/2026-07-15-create-workspace-branch-picker.md b/docs/superpowers/plans/2026-07-15-create-workspace-branch-picker.md new file mode 100644 index 00000000000..783697a76ef --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-create-workspace-branch-picker.md @@ -0,0 +1,534 @@ +# Create Workspace Branch Picker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让创建 workspace 弹窗安全地选择 remote 分支,并由选择结果生成受约束显示的默认字段。 + +**Architecture:** `CreateWorkspaceModal` 把 `BranchRef::Remote` 映射为保留完整 refname 的结构化选项,并用已有 `FilterableDropdown` 承载 remote/local 选择。modal 从当前选择的 branch name 同步派生各默认字段;`Workspace` 负责 fetch、失败后的本地 refs 回退和 Retry 调度。编辑器及错误文本被限制在 modal 内容宽度内,选择器只限制顶栏及菜单宽度以避免裁剪其弹出菜单。 + +**Tech Stack:** Rust 2021、WarpUI `FilterableDropdown`、`EditorView`、`ActionButton`、`BranchRef`、`cargo test`、Cargo workspace。 + +--- + +## 文件结构 + +- 修改:`app/src/project_organization/view/create_workspace_modal.rs` + - 定义结构化 remote 选项、默认值派生、远端加载状态和 picker action。 + - 用不可编辑的 `FilterableDropdown` 替换 branch text editor。 + - 约束长编辑器内容与错误文本,保留 picker 下拉菜单的可见性。 +- 修改:`app/src/project_organization/view/create_workspace_modal_tests.rs` + - 覆盖标签消歧、完整 ref 保留、默认值、覆盖语义和 Retry event 映射。 +- 修改:`app/src/workspace/view.rs` + - 统一首次打开和 Retry 的 remote ref 获取;fetch 失败时读取本地 refs,保持 local 模式可用。 + +### Task 1: 建立结构化 remote 分支与默认路径 + +**Files:** +- Modify: `app/src/project_organization/view/create_workspace_modal_tests.rs:1-110` +- Modify: `app/src/project_organization/view/create_workspace_modal.rs:1-150` + +- [ ] **Step 1: 编写 remote 标签与默认路径的失败测试** + +替换现有 `branch_ref_options_preserve_remote_refnames_and_local_branch_names`,并增加下列测试。测试 imports 加入 `PathBuf`、`RemoteBranchOption` 与 `default_worktree_path`。 + +```rust +#[test] +fn remote_branch_options_hide_ref_prefix_and_disambiguate_duplicate_names() { + let (remote_options, local_branches) = branch_ref_options([ + BranchRef::Remote { + remote: "origin".to_string(), + name: "main".to_string(), + full_ref: "refs/remotes/origin/main".to_string(), + }, + BranchRef::Remote { + remote: "upstream".to_string(), + name: "main".to_string(), + full_ref: "refs/remotes/upstream/main".to_string(), + }, + BranchRef::Remote { + remote: "origin".to_string(), + name: "feature/tree".to_string(), + full_ref: "refs/remotes/origin/feature/tree".to_string(), + }, + BranchRef::Local { + name: "feature/local".to_string(), + full_ref: "refs/heads/feature/local".to_string(), + }, + ]); + + assert_eq!( + remote_options, + vec![ + RemoteBranchOption::new( + "refs/remotes/origin/feature/tree", + "origin", + "feature/tree", + "feature/tree", + ), + RemoteBranchOption::new( + "refs/remotes/origin/main", + "origin", + "main", + "origin/main", + ), + RemoteBranchOption::new( + "refs/remotes/upstream/main", + "upstream", + "main", + "upstream/main", + ), + ] + ); + assert_eq!(local_branches, vec!["feature/local"]); +} + +#[test] +fn default_worktree_path_uses_repository_and_branch_names() { + assert_eq!( + default_worktree_path( + PathBuf::from("/Users/example"), + "dip-agent", + "feature/project-tree", + ), + PathBuf::from("/Users/example/.warp/worktrees/dip-agent/feature-project-tree"), + ); +} +``` + +- [ ] **Step 2: 运行测试并验证 RED** + +Run: + +```bash +cargo test -p warp remote_branch_options_hide_ref_prefix_and_disambiguate_duplicate_names --lib -- --nocapture +cargo test -p warp default_worktree_path_uses_repository_and_branch_names --lib -- --nocapture +``` + +Expected: 两项均因 `RemoteBranchOption` 或 `default_worktree_path` 尚不存在而失败。 + +- [ ] **Step 3: 实现 remote 选项与路径函数** + +在 `create_workspace_modal.rs` 增加: + +```rust +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoteBranchOption { + full_ref: String, + remote: String, + branch_name: String, + display_label: String, +} + +impl RemoteBranchOption { + fn new( + full_ref: impl Into, + remote: impl Into, + branch_name: impl Into, + display_label: impl Into, + ) -> Self { + Self { + full_ref: full_ref.into(), + remote: remote.into(), + branch_name: branch_name.into(), + display_label: display_label.into(), + } + } +} + +pub fn default_worktree_path(home: PathBuf, repository_name: &str, branch_name: &str) -> PathBuf { + home.join(".warp") + .join("worktrees") + .join(workspace_dir_name(repository_name, "")) + .join(workspace_dir_name(branch_name, "")) +} +``` + +把 `branch_ref_options` 的返回类型改为 `(Vec, Vec)`。先收集 remote 的 `full_ref`、`remote` 与 `name`,以 `branch_name` 计数;计数为 1 时 `display_label` 是裸分支名,否则是 `format!("{remote}/{branch_name}")`。按 `(display_label, full_ref)` 排序,且绝不改写 `full_ref`。导入 `workspace_dir_name`: + +```rust +use crate::project_organization::git::{workspace_dir_name, BranchRef}; +``` + +- [ ] **Step 4: 运行测试并验证 GREEN** + +Run: + +```bash +cargo test -p warp remote_branch_options_hide_ref_prefix_and_disambiguate_duplicate_names --lib -- --nocapture +cargo test -p warp default_worktree_path_uses_repository_and_branch_names --lib -- --nocapture +``` + +Expected: 两项通过,普通 remote ref 显示裸 branch,同名项显示 remote 前缀,完整 ref 和路径派生确定。 + +- [ ] **Step 5: 检查任务范围的 diff** + +```bash +git diff --check -- app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs +git diff -- app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs +``` + +Expected: 新增内容仅是结构化 remote 选项与确定性路径派生。由于这些文件是工作区中既有的未跟踪功能实现,不在本任务中提交整份文件。 + +### Task 2: 用 picker 选择分支并同步派生字段 + +**Files:** +- Modify: `app/src/project_organization/view/create_workspace_modal_tests.rs:1-180` +- Modify: `app/src/project_organization/view/create_workspace_modal.rs:145-510` + +- [ ] **Step 1: 编写选择覆盖语义的失败测试** + +在测试文件加入: + +```rust +#[test] +fn selecting_remote_branch_overwrites_all_derived_workspace_fields() { + let mut defaults = CreateWorkspaceDefaults::new( + PathBuf::from("/Users/example"), + "dip-agent".to_string(), + ); + defaults.apply_branch("feature/one"); + defaults.new_branch = "custom".to_string(); + defaults.workspace_name = "custom workspace".to_string(); + defaults.worktree_path = PathBuf::from("/tmp/custom"); + + defaults.apply_branch("feature/two"); + + assert_eq!(defaults.new_branch, "feature/two"); + assert_eq!(defaults.workspace_name, "feature/two"); + assert_eq!( + defaults.worktree_path, + PathBuf::from("/Users/example/.warp/worktrees/dip-agent/feature-two"), + ); +} +``` + +测试 imports 加入 `CreateWorkspaceDefaults`。 + +- [ ] **Step 2: 运行测试并验证 RED** + +Run: + +```bash +cargo test -p warp selecting_remote_branch_overwrites_all_derived_workspace_fields --lib -- --nocapture +``` + +Expected: 因 `CreateWorkspaceDefaults` 不存在而失败。 + +- [ ] **Step 3: 加入纯默认值状态与 picker action** + +在 modal 文件增加下列值对象,并使用它作为唯一的默认值派生位置: + +```rust +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateWorkspaceDefaults { + home: PathBuf, + repository_name: String, + new_branch: String, + workspace_name: String, + worktree_path: PathBuf, +} + +impl CreateWorkspaceDefaults { + pub fn new(home: PathBuf, repository_name: String) -> Self { + Self { + home, + repository_name, + new_branch: String::new(), + workspace_name: String::new(), + worktree_path: PathBuf::new(), + } + } + + pub fn apply_branch(&mut self, branch_name: &str) { + self.new_branch = branch_name.to_string(); + self.workspace_name = branch_name.to_string(); + self.worktree_path = default_worktree_path( + self.home.clone(), + &self.repository_name, + branch_name, + ); + } +} +``` + +把 `branch_editor`、`remote_refs` 替换为: + +```rust +remote_branch_picker: ViewHandle>, +local_branch_picker: ViewHandle>, +remote_branch_options: Vec, +local_branches: Vec, +selected_remote_branch: Option, +selected_local_branch: Option, +remote_fetch_error: Option, +defaults: Option, +``` + +给 action 加 `NoOp`、`SelectRemoteBranch(RemoteBranchOption)`、`SelectLocalBranch(String)` 和 `RetryBranchRefs`,并为 action 派生 `Clone, Debug, Eq, PartialEq`。在 `new` 中创建两个 `FilterableDropdown`,均调用: + +```rust +dropdown.set_top_bar_max_width(480.); +dropdown.set_menu_width(480., ctx); +dropdown.set_disabled(ctx); +``` + +`configure` 接收 `home: PathBuf`、`repository_name: String`,创建 `CreateWorkspaceDefaults` 并清空两个选项/选择。remote picker 设置一个禁用的 `Fetching remote branches...` 占位项(以 `NoOp` action 承载);local picker 清空并禁用。不要再预生成带 workspace UUID 的 worktree path。 + +`set_branch_refs` 构造两组 `DropdownItem`,成功时启用两个 picker。保存 remote/local 的首项为已选值,把首个 remote 的 `full_ref` 写入 form,并且仅在当前是 remote mode 时调用 `apply_remote_branch`。`apply_remote_branch` 与 `apply_local_branch` 必须: + +1. 设置 form 的实际 remote ref 或 local branch。 +2. 调用 `defaults.apply_branch`。 +3. 把 `workspace_name` 和 `worktree_path` 写入 editor;remote 分支还把 `new_branch` 写入 editor。 + +所以任意新选择都会覆盖用户此前修改。`set_mode` 改为应用存储的当前 remote/local 选择,而不从显示文字解析 Git ref。`try_submit` 直接使用 `form` 中的结构化选择,不再从可编辑 branch editor 读取 ref。 + +- [ ] **Step 4: 受约束地渲染 picker、编辑器和错误文本** + +导入: + +```rust +use crate::view_components::{DropdownItem, FilterableDropdown}; +use warpui::elements::{Clipped, ConstrainedBox, Shrinkable}; +``` + +remote/local 分支 section 使用对应的 `ChildView>`,不放进 `Clipped`,以免裁剪其定位的下拉菜单;两个 picker 已由 `set_top_bar_max_width(480.)` 和 `set_menu_width(480., ctx)` 约束。 + +对 `new_branch_editor`、`display_name_editor`、`worktree_path_editor` 和错误 `Text` 使用: + +```rust +Shrinkable::new( + 1.0, + ConstrainedBox::new(Clipped::new(child).finish()) + .with_max_width(480.) + .finish(), +) +.finish() +``` + +remote 分支错误显示在分支 section 下方。远端模式在 picker 尚未成功选择分支时,`try_submit` 显示 `"Select a remote branch before creating a workspace."` 并不提交。 + +- [ ] **Step 5: 运行测试并验证 GREEN** + +Run: + +```bash +cargo test -p warp selecting_remote_branch_overwrites_all_derived_workspace_fields --lib -- --nocapture +cargo test -p warp create_workspace_modal --lib -- --nocapture +``` + +Expected: 选择 remote 分支覆盖三个派生字段,完整 remote ref 仍传入创建请求,local 分支不能接受 remote ref。 + +- [ ] **Step 6: 检查 picker 和布局 diff** + +```bash +git diff --check -- app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs +``` + +Expected: 新增内容只替换 branch text editor、同步默认值并约束长内容,不提交整份既有未跟踪文件。 + +### Task 3: 协调 fetch、失败回退与 Retry + +**Files:** +- Modify: `app/src/project_organization/view/create_workspace_modal_tests.rs:1-230` +- Modify: `app/src/project_organization/view/create_workspace_modal.rs:145-520` +- Modify: `app/src/workspace/view.rs:75-90,5630-5710` + +- [ ] **Step 1: 为 Retry repository 映射编写失败测试** + +为 production helper 而非 test-only constructor 编写测试: + +```rust +#[test] +fn retry_event_targets_the_configured_repository() { + let target = CreateWorkspaceTarget { + repository_id: RepositoryId(uuid::Uuid::from_u128(1)), + workspace_id: RepositoryWorkspaceId(uuid::Uuid::from_u128(2)), + }; + + assert_eq!( + target.retry_branch_refs_event(), + CreateWorkspaceModalEvent::RetryBranchRefs { + repository_id: RepositoryId(uuid::Uuid::from_u128(1)), + workspace_id: RepositoryWorkspaceId(uuid::Uuid::from_u128(2)), + }, + ); +} +``` + +测试 imports 加入 `CreateWorkspaceModalEvent` 与 `CreateWorkspaceTarget`;event 需派生 `Eq, PartialEq`。 + +- [ ] **Step 2: 运行测试并验证 RED** + +Run: + +```bash +cargo test -p warp retry_event_targets_the_configured_repository --lib -- --nocapture +``` + +Expected: 因 `retry_branch_refs_event` 与 event variant 尚不存在而失败。 + +- [ ] **Step 3: 实现 modal 的加载状态与 Retry event** + +在 `CreateWorkspaceModalEvent` 加入: + +```rust +RetryBranchRefs { + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, +}, +``` + +为 `CreateWorkspaceTarget` 添加生产 helper: + +```rust +fn retry_branch_refs_event(self) -> CreateWorkspaceModalEvent { + CreateWorkspaceModalEvent::RetryBranchRefs { + repository_id: self.repository_id, + workspace_id: self.workspace_id, + } +} +``` + +`handle_action` 在 `RetryBranchRefs` 分支中仅当 `target` 存在时发出该 helper 的 event。`begin_branch_fetch` 清除 remote error、禁用 remote picker 并显示 loading 占位项;若 local picker 已有 fallback 结果则保持它可用。`set_branch_fetch_error` 保存错误并禁用 remote picker;`set_local_branch_refs` 只提取/填充 local 分支并启用 local picker。增加采用现有 `SecondaryTheme` 的 `retry_remote_button`,其 click 派发 `RetryBranchRefs` action。 + +`try_submit` 在 remote mode 且 `remote_fetch_error.is_some()` 时拒绝提交并显示该错误,local mode 不受该状态影响。 + +- [ ] **Step 4: 提取 Workspace fetch 协调逻辑并添加 local 回退** + +在 `workspace/view.rs` imports 中加入 `list_branch_refs_async`。新增: + +```rust +fn fetch_create_workspace_branch_refs( + &mut self, + repository_id: RepositoryId, + workspace_id: RepositoryWorkspaceId, + repository_path: PathBuf, + ctx: &mut ViewContext, +) { + self.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| body.begin_branch_fetch(ctx)); + }); + + ctx.spawn(fetch_and_list_refs_async(repository_path.clone()), move |workspace, result, ctx| { + match result { + Ok(refs) => workspace.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if body.matches_target(repository_id, workspace_id) { + body.set_branch_refs(refs, ctx); + } + }); + }), + Err(fetch_error) => { + let error_message = format!("Failed to fetch repository refs: {fetch_error}"); + ctx.spawn(list_branch_refs_async(repository_path), move |workspace, local_result, ctx| { + workspace.create_workspace_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |body, ctx| { + if !body.matches_target(repository_id, workspace_id) { + return; + } + if let Ok(refs) = local_result { + body.set_local_branch_refs(refs, ctx); + } + body.set_branch_fetch_error(error_message, ctx); + }); + }); + }); + } + } + }); +} +``` + +`open_create_workspace_modal` 使用 `dirs::home_dir().expect("home directory should be available")`、`repository.display_name.clone()` 调用新的 `configure`,打开 modal 后调用上述 helper。不要再生成随机 UUID path。 + +在 `handle_create_workspace_modal_body_event` 处理: + +```rust +CreateWorkspaceModalEvent::RetryBranchRefs { + repository_id, + workspace_id, +} => { + let Some(repository) = ProjectOrganizationModel::handle(ctx) + .as_ref(ctx) + .repository(*repository_id) + .cloned() + else { + return; + }; + self.fetch_create_workspace_branch_refs(*repository_id, *workspace_id, repository.path, ctx); +} +``` + +`matches_target(repository_id, workspace_id)` 同时核对当前 target 的两项 ID;这个 guard 必须覆盖首次 fetch、local fallback 和 Retry,避免关闭、重新配置或同一 repository 的新 workspace 打开后被慢结果覆盖。 + +- [ ] **Step 5: 运行测试并验证 GREEN** + +Run: + +```bash +cargo test -p warp retry_event_targets_the_configured_repository --lib -- --nocapture +cargo test -p warp create_workspace_modal --lib -- --nocapture +cargo check -p warp +``` + +Expected: Retry event 仅对应当前 repository,fetch 失败时 remote 禁用且 local 可从已有 refs 选择,crate 编译成功。 + +- [ ] **Step 6: 检查 fetch 协调 diff** + +```bash +git diff --check -- app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs app/src/workspace/view.rs +``` + +Expected: 新增内容只包含 fetch 协调、local refs 回退和 Retry;不提交含用户既有改动的文件。 + +### Task 4: 复核与人工验证构建 + +**Files:** +- Modify: `app/src/project_organization/view/create_workspace_modal.rs` +- Modify: `app/src/project_organization/view/create_workspace_modal_tests.rs` +- Modify: `app/src/workspace/view.rs` + +- [ ] **Step 1: 运行所有相关测试和格式检查** + +Run: + +```bash +cargo test -p warp create_workspace_modal --lib -- --nocapture +cargo test -p warp remote_branch_options --lib -- --nocapture +cargo fmt --check +cargo check -p warp +``` + +Expected: 全部 exit code 为 0。当前环境没有 `cargo-nextest` 时,这些 `cargo test` 是针对性回归验证。 + +- [ ] **Step 2: 复查精确 diff** + +Run: + +```bash +git diff --check -- app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs app/src/workspace/view.rs +git diff -- app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs app/src/workspace/view.rs +``` + +确认改动仅包括 picker、默认字段同步、fetch 回退/重试和长内容约束,未混入用户已有的 repository workspace 改动。 + +- [ ] **Step 3: 生成 macOS 人工验证 bundle** + +Run: + +```bash +./script/run --dont-open +codesign --verify --deep --strict --verbose=2 target/debug/bundle/osx/Zap.app +``` + +Expected: `target/debug/bundle/osx/Zap.app` 存在且签名校验通过。手工验证:打开包含长 remote 分支和长路径的 repository;确认普通 picker 项显示裸 branch,同名项显示 remote 前缀;选择项会同步三个默认字段;fetch 失败后 Retry 可用且 local mode 可继续;所有长内容均保持在 modal 宽度内。 + +- [ ] **Step 4: 给出建议 commit message** + +在交付时提供: + +```text +feat: improve workspace branch selection +``` + +不在本任务中创建 commit,以避免把用户已有的未跟踪 repository workspace 实现混入提交。 diff --git a/docs/superpowers/plans/2026-07-15-repository-workspace-add-button.md b/docs/superpowers/plans/2026-07-15-repository-workspace-add-button.md new file mode 100644 index 00000000000..55230254c69 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-repository-workspace-add-button.md @@ -0,0 +1,154 @@ +# Repository Workspace Add Button Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 repository 行内的创建 workspace 按钮在重绘后仍保留完整的点击与 tooltip 状态。 + +**Architecture:** `ProjectTreePanel` 在 `refresh_tree` 结束时,以当前树中的 repository/workspace ID 同步四组 `MouseStateHandle` 缓存。渲染路径只读取已初始化的稳定句柄,避免在渲染中创建状态。回归测试在 `LeftMouseDown` 与 `LeftMouseUp` 之间强制重建 scene,覆盖真实 UI 重绘时序。 + +**Tech Stack:** Rust 2021、WarpUI `Hoverable`/`MouseStateHandle`、`cargo test`、Cargo workspace。 + +--- + +## 文件结构 + +- 修改:`app/src/project_organization/view/project_tree.rs` + - 在树状态刷新时同步动态行的鼠标状态句柄。 + - 渲染 repository/workspace 行时只读取缓存句柄。 +- 修改:`app/src/project_organization/view/project_tree_tests.rs` + - 在现有行内“+”UI 事件测试中插入一次 scene 重建,复现重绘后的鼠标释放。 + +### Task 1: 持久化动态行交互状态 + +**Files:** +- Modify: `app/src/project_organization/view/project_tree_tests.rs:205-265` +- Modify: `app/src/project_organization/view/project_tree.rs:1-6, 216-430, 472-510` + +- [ ] **Step 1: 修改失败回归测试,模拟按下后的重绘** + +在 `create_workspace_button_does_not_toggle_its_repository` 中,保留现有 `LeftMouseDown`,但在它和 `LeftMouseUp` 之间插入下列 scene 重建代码: + +```rust + presenter.borrow_mut().invalidate( + WindowInvalidation { + updated: [root_view_id, project_tree.id()].into_iter().collect(), + ..Default::default() + }, + ctx, + ); + presenter + .borrow_mut() + .build_scene(vec2f(320., 160.), 1., None, ctx); +``` + +`presenter` 必须保持为 `Rc>`,因此把现有的 `let presenter = Rc::new(RefCell::new(presenter));` 放在 `LeftMouseDown` 之前。`LeftMouseUp` 继续使用同一个 `presenter.clone()`。 + +- [ ] **Step 2: 运行测试并验证 RED** + +Run: + +```bash +cargo test -p warp create_workspace_button_does_not_toggle_its_repository --lib -- --nocapture +``` + +Expected: FAIL,`events` 为空而断言要求一个 `ProjectTreeEvent::CreateWorkspaceRequested`。当前 `unwrap_or_default()` 会在 scene 重建时替换鼠标按下状态的句柄。 + +- [ ] **Step 3: 在树刷新阶段同步句柄缓存** + +在 `project_tree.rs` 顶部改为导入 `HashSet` 和 `Hash`: + +```rust +use std::{ + collections::{HashMap, HashSet}, + hash::Hash, +}; +``` + +在 `repository_add_workspace_position_id` 后添加私有 helper: + +```rust +fn synchronize_mouse_states( + mouse_states: &mut HashMap, + ids: &HashSet, +) where + Id: Copy + Eq + Hash, +{ + mouse_states.retain(|id, _| ids.contains(id)); + for id in ids { + mouse_states.entry(*id).or_default(); + } +} +``` + +在 `refresh_tree` 中 `self.state` 重建并恢复选择状态之后、`ctx.notify()` 之前,收集当前 ID 并同步全部四组缓存: + +```rust + let repository_ids = self + .state + .repositories() + .iter() + .map(|repository| repository.repository_id) + .collect::>(); + let workspace_ids = self + .state + .repositories() + .iter() + .flat_map(|repository| repository.workspaces.iter()) + .map(|workspace| workspace.workspace_id) + .collect::>(); + + synchronize_mouse_states(&mut self.repository_mouse_states, &repository_ids); + synchronize_mouse_states( + &mut self.repository_add_workspace_mouse_states, + &repository_ids, + ); + synchronize_mouse_states(&mut self.workspace_mouse_states, &workspace_ids); + synchronize_mouse_states(&mut self.workspace_delete_mouse_states, &workspace_ids); +``` + +把四个渲染位置的 `get(...).cloned().unwrap_or_default()` 改为 `expect(...).clone()`,分别使用下列错误信息: + +```rust +"repository add-workspace mouse state should be initialized during tree refresh" +"repository row mouse state should be initialized during tree refresh" +"workspace delete mouse state should be initialized during tree refresh" +"workspace row mouse state should be initialized during tree refresh" +``` + +这样状态缺失会立即暴露,而不会在 render 路径静默创建一个无法跨重绘保存的 fallback 句柄。 + +- [ ] **Step 4: 运行针对性测试并验证 GREEN** + +Run: + +```bash +cargo test -p warp create_workspace_button_does_not_toggle_its_repository --lib -- --nocapture +``` + +Expected: PASS,日志显示 `ProjectTreeAction::CreateWorkspace` 被派发,测试断言收到唯一的 `CreateWorkspaceRequested` 事件,repository 保持展开。 + +- [ ] **Step 5: 运行 crate 级编译验证** + +Run: + +```bash +cargo check -p warp +``` + +Expected: exit code 0。现有未修改模块的 warning 可记录,但本次改动不得引入编译错误。 + +- [ ] **Step 6: 复查 diff 并提交** + +Run: + +```bash +git diff --check -- app/src/project_organization/view/project_tree.rs app/src/project_organization/view/project_tree_tests.rs +git diff -- app/src/project_organization/view/project_tree.rs app/src/project_organization/view/project_tree_tests.rs +``` + +确认只包含稳定鼠标状态缓存和回归测试后,提交本次代码: + +```bash +git add app/src/project_organization/view/project_tree.rs app/src/project_organization/view/project_tree_tests.rs +git commit -m "fix: preserve repository workspace row interactions" +``` diff --git a/docs/superpowers/plans/2026-07-16-workspace-selection-state.md b/docs/superpowers/plans/2026-07-16-workspace-selection-state.md new file mode 100644 index 00000000000..b631815c8a0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-workspace-selection-state.md @@ -0,0 +1,138 @@ +# Workspace Selection State Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the selected repository workspace immediately identifiable in the project tree with a surface background, accent outline, and left accent stripe. + +**Architecture:** Keep selection state in `ProjectTreeState::selected_workspace_id`. `ProjectTreePanel::render_workspace_row` will derive the selected visual treatment from that state and wrap the existing row element with existing WarpUI `Container`/`Border` primitives. The delete-button hover behavior and row click action remain unchanged. + +**Tech Stack:** Rust, WarpUI elements, `Appearance` theme colors, Cargo unit tests. + +--- + +### Task 1: Add a testable selection-style decision + +**Files:** +- Modify: `app/src/project_organization/view/project_tree.rs:219-230` +- Modify: `app/src/project_organization/view/project_tree.rs:467-563` +- Test: `app/src/project_organization/view/project_tree_tests.rs:25-220` + +- [ ] **Step 1: Write the failing test** + +Add a small pure helper that maps the current workspace id and row workspace id to a boolean selected state, then test both matching and non-matching ids. Keep the existing state-selection tests unchanged. + +```rust +fn workspace_row_is_selected( + selected_workspace_id: Option, + workspace_id: RepositoryWorkspaceId, +) -> bool { + selected_workspace_id == Some(workspace_id) +} +``` + +```rust +#[test] +fn workspace_row_selection_matches_only_the_active_workspace() { + let selected = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let other = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + + assert!(workspace_row_is_selected(Some(selected), selected)); + assert!(!workspace_row_is_selected(Some(selected), other)); + assert!(!workspace_row_is_selected(None, selected)); +} +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: + +```bash +cargo test -p warp workspace_row_selection_matches_only_the_active_workspace --lib +``` + +Expected: compilation failure because `workspace_row_is_selected` is not defined yet. + +- [ ] **Step 3: Implement the helper and use it in row rendering** + +Define the helper near the existing project-tree pure helpers and replace the inline comparison in `render_workspace_row`: + +```rust +let selected = workspace_row_is_selected( + self.state.selected_workspace_id(), + workspace.workspace_id, +); +``` + +- [ ] **Step 4: Add the selected visual treatment** + +Import `Border` from `warpui::elements`. Keep the existing content, delete button, placeholder, hover state, and click handlers. Apply the selected style to the row container only when `selected` is true: + +```rust +let mut row_container = Container::new(row) + .with_padding_left(36.) + .with_vertical_padding(4.); + +if selected { + row_container = row_container + .with_background(theme.surface_2()) + .with_border(Border::all(1.).with_border_fill(theme.accent())) + .with_margin_top(-1.) + .with_margin_bottom(-1.) + .with_margin_left(-1.) + .with_margin_right(-1.); +} +``` + +Add the left accent stripe as a stretched first child inside a selected-only flex wrapper. The stripe's negative left margin cancels its fixed width, so text and the row's available width do not shift: + +```rust +let row = if selected { + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child( + Container::new( + ConstrainedBox::new(Empty::new().finish()) + .with_width(3.) + .finish(), + ) + .with_margin_left(-3.) + .with_background(theme.accent()) + .finish(), + ) + .with_child(Shrinkable::new(1.0, row_container.finish()).finish()) + .finish() +} else { + row_container.finish() +}; +``` + +Use `row` as the child returned by `Hoverable::new`; do not change the existing `delete`/`delete_placeholder` selection inside the hover closure. + +- [ ] **Step 5: Run the focused tests to verify they pass** + +Run: + +```bash +cargo test -p warp project_tree --lib +``` + +Expected: all project-tree tests pass, including the new selection decision test and the existing delete-hover test. + +- [ ] **Step 6: Run repository verification** + +Run: + +```bash +cargo check -p warp +git diff --check +``` + +Expected: both commands exit with status 0. Existing unrelated compiler warnings may remain. + +- [ ] **Step 7: Commit the implementation** + +```bash +git add app/src/project_organization/view/project_tree.rs app/src/project_organization/view/project_tree_tests.rs +git commit -m "fix: strengthen workspace selection state" +``` diff --git a/docs/superpowers/plans/2026-07-19-local-repository-workspace.md b/docs/superpowers/plans/2026-07-19-local-repository-workspace.md new file mode 100644 index 00000000000..42036f3c844 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-local-repository-workspace.md @@ -0,0 +1,293 @@ +# Local Repository Workspace Implementation Plan + +> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking. + +Goal: Make the repository primary worktree available as a local workspace, create it automatically when a repository is added through the repository picker, and expose the same operation in the existing Use existing worktree workspace modal. + +Architecture: Git validation returns the primary worktree current local branch and marks primary options explicitly. The project-organization model persists a new repository and its initial workspace through one SQLite transaction. The existing-worktree modal reuses the normal validation flow and skips git worktree add for the repository root. Workspace activation and the initial repository-root terminal use one shared helper. + +Tech Stack: Rust, Cargo workspace, Git CLI wrapper, WarpUI, Diesel/SQLite persistence, cargo nextest. + +--- + +## File map + +- Modify app/src/project_organization/git.rs and git_tests.rs for primary metadata, validation, and detached-HEAD behavior. +- Modify app/src/project_organization/view/create_workspace_modal.rs and its tests for primary option display, local default naming, and warnings. +- Modify app/src/persistence/mod.rs, sqlite.rs, and sqlite_tests.rs for atomic repository-plus-workspace persistence. +- Modify app/src/project_organization/model.rs and model_tests.rs for initial workspace construction, memory commit, and events. +- Modify app/src/workspace/view.rs for repository-add wiring and shared activation. +- Modify app/src/workspace/view_test.rs only if the existing harness can isolate activation without unrelated setup. + +## Task 1: Extend Git validation to understand the primary worktree + +Files: app/src/project_organization/git.rs, app/src/project_organization/git_tests.rs + +- [ ] Step 1: Write failing tests. + +Add tests asserting that the primary root is included and marked, primary adoption returns its canonical path, validated repositories expose primary_branch == "main", and detached primary worktrees return GitWorkspaceError::PrimaryWorktreeDetached. Keep linked detached/prunable entries excluded. + + #[test] + fn validates_the_primary_worktree_for_workspace_adoption() { + let fixture = GitFixture::new(); + assert_eq!( + validate_existing_worktree(&fixture.root, &fixture.root, "main").unwrap(), + fixture.root.canonicalize().unwrap(), + ); + } + + #[test] + fn rejects_detached_primary_worktree() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["checkout", "--detach", "HEAD"]); + assert!(matches!( + validate_repository(&fixture.root).unwrap_err(), + GitWorkspaceError::PrimaryWorktreeDetached { path } + if path == fixture.root.canonicalize().unwrap() + )); + } + +- [ ] Step 2: Verify the tests fail for the missing behavior. + + cargo nextest run -p warp -E 'test(validates_the_primary_worktree_for_workspace_adoption) or test(rejects_detached_primary_worktree)' + +Expected: failure because primary worktrees are currently filtered/rejected and ValidatedRepository has no primary branch. + +- [ ] Step 3: Implement the Git changes. + +Add primary_branch: String to ValidatedRepository and is_primary: bool to ExistingWorktreeOption. Keep ExistingWorktreeOption::new for linked worktrees and add a primary constructor: + + pub fn primary(path: PathBuf, branch_name: impl Into) -> Self { + Self { + path, + branch_name: branch_name.into(), + is_primary: true, + } + } + +In validate_repository, inspect list_worktrees(&root), find the root entry, reject a missing, detached, or empty branch with PrimaryWorktreeDetached { path }, and store the refs/heads/ short name. In existing_worktree_options, include the root entry as ExistingWorktreeOption::primary(...); continue excluding bare, detached, prunable, malformed, and empty-branch entries. Sort primary first, then branch name and path. In validate_existing_worktree, remove the unconditional PrimaryWorktreeCannotBeWorkspace rejection while retaining registration, prunable, branch, and ref checks. + +- [ ] Step 4: Run tests, format, and commit. + + cargo fmt -- app/src/project_organization/git.rs app/src/project_organization/git_tests.rs + cargo nextest run -p warp -E 'test(existing_worktree_options) or test(validates_registered_existing_worktree) or test(validate_repository)' + git add app/src/project_organization/git.rs app/src/project_organization/git_tests.rs + git commit -m "feat: recognize repository primary worktrees" + +Expected: all selected Git tests pass. + +## Task 2: Add the primary worktree to the existing-worktree modal + +Files: app/src/project_organization/view/create_workspace_modal.rs and app/src/project_organization/view/create_workspace_modal_tests.rs + +- [ ] Step 1: Write a failing modal test. + +Add pure helpers and test the required label/default behavior: + + #[test] + fn primary_existing_worktree_uses_local_label_and_name() { + let option = ExistingWorktreeOption::primary(PathBuf::from("/repo"), "main"); + assert_eq!(existing_worktree_display_label(&option), "main (local)"); + assert_eq!(existing_worktree_default_name(&option), "local"); + } + +Keep the existing request test asserting that the actual branch remains feature/adopt and the selected path is passed through unchanged. + +- [ ] Step 2: Verify the modal test fails. + + cargo nextest run -p warp -E 'test(primary_existing_worktree_uses_local_label_and_name)' + +Expected: compilation failure because the primary marker and helpers do not exist. + +- [ ] Step 3: Implement modal behavior. + +Add focused helpers: + + fn existing_worktree_display_label(worktree: &ExistingWorktreeOption) -> String { + if worktree.is_primary { + format!("{} (local)", worktree.branch_name) + } else { + worktree.branch_name.clone() + } + } + + fn existing_worktree_default_name(worktree: &ExistingWorktreeOption) -> &str { + if worktree.is_primary { + "local" + } else { + &worktree.branch_name + } + } + +Use the label when building dropdown items. In select_existing_worktree, keep the real branch in CreateWorkspaceForm but reset the name editor from existing_worktree_default_name. Add primary_worktree_error: Option; clear it on configure, close, and retry, and set it when the repository-root WorktreeInfo is detached. Render the warning in the existing-worktree section while still allowing valid linked worktrees to be selected. Do not use existing_worktree_fetch_error for this warning because a detached primary must not disable unrelated valid options. Leave CreateWorkspaceSource::ExistingWorktree { local_branch } unchanged; its selected path already flows into CreateWorkspaceRequest. + +- [ ] Step 4: Run tests and commit. + + cargo fmt -- app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs + cargo nextest run -p warp -E 'test(primary_existing_worktree) or test(existing_worktree_form_builds_a_workspace_creation_request) or test(existing_worktree_submit_is_disabled_until_a_selection_is_available)' + git add app/src/project_organization/view/create_workspace_modal.rs app/src/project_organization/view/create_workspace_modal_tests.rs + git commit -m "feat: expose primary worktree in workspace modal" + +Expected: primary displays as main (local), defaults to local, and retains main as its branch. + +## Task 3: Persist repository and initial local workspace atomically + +Files: app/src/persistence/mod.rs, app/src/persistence/sqlite.rs, app/src/persistence/sqlite_tests.rs, app/src/project_organization/model.rs, app/src/project_organization/model_tests.rs + +- [ ] Step 1: Write failing persistence tests. + +Add UpsertRepositoryWithWorkspace tests. The success case must read both rows after acknowledgement. The failure case must create a path conflict in the workspace row, execute the paired operation, assert a database error, and assert that the new repository row was rolled back. + + #[test] + fn failed_initial_workspace_upsert_rolls_back_the_new_repository() { + // Insert a conflicting workspace, execute the paired upsert, then query SQLite. + // The new repository must not exist after the workspace write fails. + } + +- [ ] Step 2: Verify persistence tests fail. + + cargo nextest run -p warp -E 'test(repository_and_initial_workspace_are_persisted_as_one_transaction) or test(failed_initial_workspace_upsert_rolls_back_the_new_repository)' + +Expected: compilation failure because the paired operation is missing. + +- [ ] Step 3: Implement the SQLite operation. + +Add this enum variant in RepositoryPersistenceOperation: + + UpsertRepositoryWithWorkspace { + repository: model::Repository, + workspace: model::RepositoryWorkspace, + }, + +Handle it with the existing Diesel connection: + + connection.immediate_transaction(|connection| { + save_repository(connection, repository)?; + save_repository_workspace(connection, workspace)?; + Ok::<_, anyhow::Error>(()) + }) + +Keep existing single-row variants unchanged and preserve their error context. + +- [ ] Step 4: Write failing model tests. + +Using the current acknowledged_persistence harness, test a new method returning both IDs. Assert one paired persistence operation, repository/workspace memory entries, display_name == "local", branch == "main", root worktree_path, and RepositoryAdded then WorkspaceAdded events. Add a persistence-failure test asserting no memory entries/events, and retain duplicate branch/path tests for existing repositories. + + let (repository_id, workspace_id) = model.update(&mut app, |model, ctx| { + model.add_local_repository_with_initial_workspace( + &repository_path, + None, + "main", + ctx, + ) + }).unwrap(); + +- [ ] Step 5: Verify model tests fail. + + cargo nextest run -p warp -E 'test(adding_repository_with_initial_workspace) or test(repository_with_initial_workspace_does_not_change_memory_when_persistence_fails)' + +Expected: compilation failure because the model method is missing. + +- [ ] Step 6: Implement the model method. + +Add: + + pub fn add_local_repository_with_initial_workspace( + &mut self, + path: impl AsRef, + remote_url: Option, + primary_branch: impl Into, + ctx: &mut ModelContext, + ) -> Result<(RepositoryId, RepositoryWorkspaceId), ProjectOrganizationError> + +Reuse current canonical path/display-name validation. Construct a RepositoryWorkspace with name local, the supplied actual branch, repository root path, and a fresh ID. Validate repository and workspace uniqueness against the pending repository ID without mutating in-memory maps. Execute UpsertRepositoryWithWorkspace; only after success call commit_repository, commit_workspace, emit both events, and return both IDs. Leave low-level add_local_repository and touch_repository_path APIs unchanged because they do not receive validated branch metadata. + +- [ ] Step 7: Run tests, format, and commit. + + cargo fmt -- app/src/persistence/mod.rs app/src/persistence/sqlite.rs app/src/persistence/sqlite_tests.rs app/src/project_organization/model.rs app/src/project_organization/model_tests.rs + cargo nextest run -p warp -E 'test(project_organization) or test(repository_persistence)' + git add app/src/persistence/mod.rs app/src/persistence/sqlite.rs app/src/persistence/sqlite_tests.rs app/src/project_organization/model.rs app/src/project_organization/model_tests.rs + git commit -m "feat: persist initial local repository workspace" + +Expected: paired persistence is atomic and the model never commits partial state. + +## Task 4: Wire repository addition and shared workspace activation + +File: app/src/workspace/view.rs + +- [ ] Step 1: Extract the successful activation behavior. + +Create a private helper used by both flows: + + fn activate_repository_workspace( + &mut self, + workspace_id: RepositoryWorkspaceId, + initial_directory: PathBuf, + ctx: &mut ViewContext, + ) { + self.switch_repository_workspace(Some(workspace_id), ctx); + self.add_tab_with_pane_layout( + PanesLayout::SingleTerminal(Box::new( + NewTerminalOptions::default() + .with_initial_directory(initial_directory), + )), + Arc::new(HashMap::new()), + None, + ctx, + ); + } + +If app/src/workspace/view_test.rs can isolate this behavior, add an assertion for the active workspace ID and terminal tab initial directory before implementation. Otherwise rely on the manual assertions in Task 5 and avoid mock-heavy test setup. + +- [ ] Step 2: Wire the repository picker. + +In validate_and_add_repository, pass repository.primary_branch to add_local_repository_with_initial_workspace. On success, activate the returned workspace with repository.root. Detached-primary and persistence errors must use the existing toast paths and must not activate a workspace. + +- [ ] Step 3: Reuse activation for ordinary workspace creation. + +Replace the successful tail of create_repository_workspace with activate_repository_workspace(request.workspace_id, request.worktree_path, ctx). Keep source_creates_worktree unchanged: ExistingWorktree, including the primary root, never triggers cleanup; remote/local branch creation still does. + +- [ ] Step 4: Compile, test, format, and commit. + + cargo fmt -- app/src/workspace/view.rs app/src/workspace/view_test.rs + cargo nextest run -p warp -E 'test(project_organization) or test(repository_workspace) or test(workspace)' + cargo check -p warp + git add app/src/workspace/view.rs app/src/workspace/view_test.rs + git commit -m "feat: activate local workspace after repository add" + +Expected: ordinary workspace creation remains green and repository addition now creates/activates local. + +## Task 5: Full verification and macOS app bundle + +- [ ] Step 1: Run focused regression tests and compile check. + + cargo nextest run -p warp -E 'test(project_organization) or test(repository_workspace) or test(sqlite)' + cargo check -p warp + +- [ ] Step 2: Build the macOS app. + + ./script/bundle --debug --selfsign --nouniversal --channel local + +Expected: a fresh Zap.app exists under target, normally target/debug/bundle/osx/Zap.app. + +- [ ] Step 3: Verify the artifact. + + test -d target/debug/bundle/osx/Zap.app + /usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' target/debug/bundle/osx/Zap.app/Contents/Info.plist + codesign --verify --deep --strict --verbose=2 target/debug/bundle/osx/Zap.app + +- [ ] Step 4: Manually validate acceptance criteria. + +1. Add a normal checkout repository: repository and local appear, the app switches to local, and a terminal opens at the repository root. +2. For an existing repository, open the current create-workspace modal, choose Use existing worktree, select main (local), and verify name local, actual branch main, root path, and no new linked worktree directory. +3. Repeat the operation and verify a clear duplicate error with no state change. +4. Detach the primary worktree and verify the modal shows a clear warning; valid linked worktrees remain selectable. + +- [ ] Step 5: Review the final diff and status. + + git diff --check HEAD~4..HEAD + git status --short + git log --oneline -6 + +Expected: no whitespace errors and a clean worktree after the implementation commits. + diff --git a/docs/superpowers/plans/2026-07-19-repository-workspace-path.md b/docs/superpowers/plans/2026-07-19-repository-workspace-path.md new file mode 100644 index 00000000000..968dd2324fb --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-repository-workspace-path.md @@ -0,0 +1,154 @@ +# Repository Workspace Nested Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 repository workspace 在默认的多级 worktree 路径父目录不存在时也能成功创建。 + +**Architecture:** 保持 `TargetDirectoryClaim` 对最终目标目录使用 `create_dir` 的独占 claim 语义,只在 claim 前递归创建非空父目录。Git worktree 创建、验证、持久化和失败清理流程不变;回归测试覆盖 remote branch 和 local branch 两条创建路径。 + +**Tech Stack:** Rust 2021, Cargo, `std::fs`, Git CLI, Warp `warp` app crate 单元测试。 + +--- + +### Task 1: 为缺失父目录的 remote/local worktree 创建增加失败测试 + +**Files:** +- Modify: `app/src/project_organization/git_tests.rs:870-990` + +- [ ] **Step 1: 写 remote nested path 回归测试** + +在现有 `creates_new_branch_from_remote_ref_without_tracking` 测试之后增加: + +```rust +#[test] +fn creates_remote_worktree_for_nested_claimed_target() { + let fixture = GitFixture::new(); + let worktree_path = fixture + .tempdir + .path() + .join("missing-parent") + .join("remote worktree"); + + create_from_remote( + &fixture.root, + "refs/remotes/origin/main", + "feature/nested-remote", + &worktree_path, + ) + .unwrap(); + + assert_eq!(current_branch(&worktree_path), "feature/nested-remote"); + assert!(worktree_path.is_dir()); +} +``` + +- [ ] **Step 2: 写 local nested path 回归测试** + +在现有 `creates_local_worktree_for_relative_claimed_target` 测试之后增加: + +```rust +#[test] +fn creates_local_worktree_for_nested_claimed_target() { + let fixture = GitFixture::new(); + run_git(&fixture.root, &["branch", "feature/nested-local"]); + let worktree_path = fixture + .tempdir + .path() + .join("missing-parent") + .join("local worktree"); + + create_from_local(&fixture.root, "feature/nested-local", &worktree_path).unwrap(); + + assert_eq!(current_branch(&worktree_path), "feature/nested-local"); + assert!(worktree_path.is_dir()); +} +``` + +- [ ] **Step 3: 运行新增测试确认当前实现失败** + +Run: + +```bash +cargo test -p warp --lib nested_claimed_target +``` + +Expected: 测试失败,错误链包含 `TargetClaimFailed` 或底层 `No such file or directory`,因为 `TargetDirectoryClaim::acquire` 当前只调用 `create_dir`。 + +### Task 2: 在 claim 前创建缺失的父目录 + +**Files:** +- Modify: `app/src/project_organization/git.rs:1618-1634` + +- [ ] **Step 1: 在 `TargetDirectoryClaim::acquire` 中准备父目录** + +在最终目标目录的 `create_dir` 前加入父目录创建;跳过空的相对路径父组件,保持现有相对路径测试兼容: + +```rust +if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) +{ + std::fs::create_dir_all(parent).map_err(|source| GitWorkspaceError::TargetClaimFailed { + path: path.to_path_buf(), + source, + })?; +} +``` + +保留后续现有的 `create_dir(path)`、`AlreadyExists` 映射、canonicalize 和 `TargetDirectoryClaim` 构造代码不变。父目录不在 claim 结构中,后续失败清理仍只删除最终空目标目录。 + +- [ ] **Step 2: 运行两条回归测试确认通过** + +Run: + +```bash +cargo test -p warp --lib nested_claimed_target +``` + +Expected: 两条测试通过,Git branch 与 worktree 路径均正确。 + +### Task 3: 回归验证并检查变更范围 + +**Files:** +- Verify: `app/src/project_organization/git.rs` +- Verify: `app/src/project_organization/git_tests.rs` + +- [ ] **Step 1: 运行整个 project organization Git 测试模块** + +Run: + +```bash +cargo test -p warp --lib project_organization::git_tests +``` + +Expected: 该模块测试全部通过。 + +- [ ] **Step 2: 运行格式和编译检查** + +Run: + +```bash +cargo fmt --all -- --check +cargo check -p warp +``` + +Expected: 格式检查退出码为 0,`cargo check -p warp` 退出码为 0。 + +- [ ] **Step 3: 检查最终 diff** + +Run: + +```bash +git diff --check +git status --short +git diff -- app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +``` + +Expected: 只有父目录准备逻辑和两个嵌套路径测试发生变更,不包含无关格式化或行为修改。 + +- [ ] **Step 4: 提交实现** + +```bash +git add app/src/project_organization/git.rs app/src/project_organization/git_tests.rs +git commit -m "fix: create parent directories for repository workspaces" +``` diff --git a/docs/superpowers/plans/2026-07-20-workspace-terminal-activity.md b/docs/superpowers/plans/2026-07-20-workspace-terminal-activity.md new file mode 100644 index 00000000000..3e2ae4a8fd3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-workspace-terminal-activity.md @@ -0,0 +1,807 @@ +# Workspace Terminal Activity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the existing repository workspace tab-count UI with a C3-style numeric badge, add a distinct selected-workspace glow, and animate the badge only when that workspace has a long-running terminal. + +**Architecture:** Keep tab ownership in `RepositoryWorkspaceTabSets`, keep terminal running detection in `Workspace`, and keep presentation in `ProjectTreePanel`. `ProjectTreePanel` receives `tab_counts`, `active_workspace_id`, and `running_workspace_ids` from `Workspace::sync_project_tree`; it does not traverse terminal sessions. + +**Tech Stack:** Rust, WarpUI elements (`Container`, `Border`, `DropShadow`, `Flex`, `Hoverable`), existing `BrailleSpinner`, existing `RunningSessionSummary`, existing repository workspace tab state. + +--- + +## File Structure + +- Modify `app/src/workspace/repository_workspace_tabs.rs` + - Add a generic helper to map active/inactive repository workspace tabs into workspace IDs by predicate. +- Modify `app/src/workspace/repository_workspace_tabs_tests.rs` + - Add unit coverage for active/inactive matching and unclassified tabs. +- Modify `app/src/project_organization/view/project_tree.rs` + - Replace the old textual `"N tabs"` badge with a numeric activity badge. + - Add running-workspace state input and per-workspace spinner state handles. + - Add selected workspace frame/glow rendering separate from running badge animation. +- Modify `app/src/project_organization/view/project_tree_tests.rs` + - Update tab-count label tests. + - Add visual-state helper tests and render smoke coverage. +- Modify `app/src/workspace/view/left_panel.rs` + - Add a forwarding setter for running repository workspace IDs. +- Modify `app/src/workspace/view.rs` + - Compute running repository workspace IDs using all active and inactive repository workspace tabs. + - Sync running IDs to `LeftPanelView`. + - Refresh the project tree on `TerminalViewStateChanged`. + +--- + +### Task 1: Add Repository Workspace Tab Predicate Helper + +**Files:** +- Modify: `app/src/workspace/repository_workspace_tabs.rs` +- Modify: `app/src/workspace/repository_workspace_tabs_tests.rs` + +- [ ] **Step 1: Write failing tests for matching active and inactive workspace tabs** + +Add these tests to `app/src/workspace/repository_workspace_tabs_tests.rs`: + +```rust +#[test] +fn workspace_ids_matching_includes_active_and_inactive_workspaces() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let workspace_b = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let workspace_c = RepositoryWorkspaceId(uuid::Uuid::from_u128(3)); + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive( + Some(workspace_b), + RepositoryWorkspaceTabState::new(vec![20_u64, 21], 0), + ); + sets.insert_inactive( + Some(workspace_c), + RepositoryWorkspaceTabState::new(vec![30_u64], 0), + ); + + let active_tabs = vec![10_u64, 11]; + let matches = sets.workspace_ids_matching(&active_tabs, |tab| *tab == 11 || *tab == 20); + + assert!(matches.contains(&workspace_a)); + assert!(matches.contains(&workspace_b)); + assert!(!matches.contains(&workspace_c)); +} + +#[test] +fn workspace_ids_matching_ignores_unclassified_tabs() { + let workspace_a = RepositoryWorkspaceId(uuid::Uuid::from_u128(1)); + let mut sets = RepositoryWorkspaceTabSets::new(Some(workspace_a)); + sets.insert_inactive(None, RepositoryWorkspaceTabState::new(vec![20_u64], 0)); + + let active_tabs = vec![10_u64]; + let matches = sets.workspace_ids_matching(&active_tabs, |tab| *tab == 20); + + assert!(matches.is_empty()); +} +``` + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```bash +cargo test -p warp --lib workspace::repository_workspace_tabs_tests::workspace_ids_matching +``` + +Expected: fails because `workspace_ids_matching` does not exist. + +- [ ] **Step 3: Implement `workspace_ids_matching`** + +Update the import and impl in `app/src/workspace/repository_workspace_tabs.rs`: + +```rust +use std::collections::{HashMap, HashSet}; +``` + +Add this method inside `impl RepositoryWorkspaceTabSets`: + +```rust + pub(crate) fn workspace_ids_matching( + &self, + active_tabs: &[T], + mut matches_tab: impl FnMut(&T) -> bool, + ) -> HashSet { + let mut workspace_ids = HashSet::new(); + + if let Some(workspace_id) = self.active_workspace_id { + if active_tabs.iter().any(&mut matches_tab) { + workspace_ids.insert(workspace_id); + } + } + + for (workspace_id, state) in &self.inactive { + let Some(workspace_id) = workspace_id else { + continue; + }; + if state.tabs.iter().any(&mut matches_tab) { + workspace_ids.insert(*workspace_id); + } + } + + workspace_ids + } +``` + +- [ ] **Step 4: Run the focused tests and verify they pass** + +Run: + +```bash +cargo test -p warp --lib workspace::repository_workspace_tabs_tests::workspace_ids_matching +``` + +Expected: both tests pass. + +- [ ] **Step 5: Commit Task 1** + +```bash +git add app/src/workspace/repository_workspace_tabs.rs app/src/workspace/repository_workspace_tabs_tests.rs +git commit -m "feat: find repository workspaces matching tabs" +``` + +--- + +### Task 2: Add Project Tree Activity State Inputs + +**Files:** +- Modify: `app/src/project_organization/view/project_tree.rs` +- Modify: `app/src/project_organization/view/project_tree_tests.rs` + +- [ ] **Step 1: Write failing tests for badge labels and visual state** + +Update the import list in `app/src/project_organization/view/project_tree_tests.rs`: + +```rust +use super::{ + repository_add_workspace_position_id, resolved_project_organization_tab_layout, + should_show_workspace_delete_button, synchronize_mouse_states, tab_count_badge_label, + workspace_count_label, workspace_row_is_selected, ProjectTreeEvent, ProjectTreePanel, + ProjectTreeState, RepositoryTreeNode, TabLayout, WorkspaceTreeNode, WorkspaceVisualState, +}; +``` + +Replace the tab-count assertions in `project_tree_count_labels_use_correct_singular_and_plural_forms`: + +```rust +#[test] +fn project_tree_count_labels_use_correct_singular_and_plural_forms() { + assert_eq!(workspace_count_label(0), "0 workspaces"); + assert_eq!(workspace_count_label(1), "1 workspace"); + assert_eq!(workspace_count_label(2), "2 workspaces"); +} + +#[test] +fn tab_count_badge_label_is_numeric_and_caps_large_counts() { + assert_eq!(tab_count_badge_label(0), "0"); + assert_eq!(tab_count_badge_label(1), "1"); + assert_eq!(tab_count_badge_label(99), "99"); + assert_eq!(tab_count_badge_label(100), "99+"); +} +``` + +Add this visual-state test: + +```rust +#[test] +fn workspace_visual_state_keeps_selection_and_running_separate() { + let selected_static = WorkspaceVisualState::new(true, false); + assert!(selected_static.should_render_selection_frame()); + assert!(!selected_static.should_render_running_spinner()); + + let running_unselected = WorkspaceVisualState::new(false, true); + assert!(!running_unselected.should_render_selection_frame()); + assert!(running_unselected.should_render_running_spinner()); + + let selected_running = WorkspaceVisualState::new(true, true); + assert!(selected_running.should_render_selection_frame()); + assert!(selected_running.should_render_running_spinner()); +} +``` + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```bash +cargo test -p warp --lib project_organization::view::project_tree_tests +``` + +Expected: fails because `tab_count_badge_label` and `WorkspaceVisualState` do not exist. + +- [ ] **Step 3: Add state fields and pure helpers** + +Update imports in `app/src/project_organization/view/project_tree.rs`: + +```rust +use pathfinder_geometry::vector::vec2f; +use warp_core::ui::color::coloru_with_opacity; +use warpui::{ + elements::{ + Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, + Element, Empty, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, + ParentElement, Radius, SavePosition, Shrinkable, Text, + }, + platform::Cursor, + text_layout::ClipConfig, + ui_components::components::UiComponent, + AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, + ViewHandle, +}; +``` + +Add the spinner import: + +```rust +use crate::ui_components::{ + buttons::icon_button, + icons, + spinner::{BrailleSpinner, SpinnerStateHandle}, +}; +``` + +Replace `tab_count_label` with: + +```rust +fn tab_count_badge_label(tab_count: usize) -> String { + if tab_count > 99 { + "99+".to_string() + } else { + tab_count.to_string() + } +} +``` + +Add this helper type near `workspace_row_is_selected`: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceVisualState { + is_selected: bool, + has_running_terminal: bool, +} + +impl WorkspaceVisualState { + pub(crate) fn new(is_selected: bool, has_running_terminal: bool) -> Self { + Self { + is_selected, + has_running_terminal, + } + } + + pub(crate) fn should_render_selection_frame(&self) -> bool { + self.is_selected + } + + pub(crate) fn should_render_running_spinner(&self) -> bool { + self.has_running_terminal + } +} +``` + +Add fields to `ProjectTreePanel`: + +```rust + running_workspace_ids: HashSet, + workspace_spinner_states: HashMap, +``` + +Initialize them in `ProjectTreePanel::new`: + +```rust + running_workspace_ids: HashSet::new(), + workspace_spinner_states: HashMap::new(), +``` + +In `refresh_tree`, after `synchronize_mouse_states(&mut self.workspace_delete_mouse_states, &workspace_ids);`, add: + +```rust + self.running_workspace_ids + .retain(|workspace_id| workspace_ids.contains(workspace_id)); + self.workspace_spinner_states + .retain(|workspace_id, _| workspace_ids.contains(workspace_id)); + for workspace_id in &workspace_ids { + self.workspace_spinner_states + .entry(*workspace_id) + .or_default(); + } +``` + +Add a setter to `impl ProjectTreePanel`: + +```rust + pub fn set_running_workspaces( + &mut self, + running_workspace_ids: HashSet, + ctx: &mut ViewContext, + ) { + if self.running_workspace_ids == running_workspace_ids { + return; + } + self.running_workspace_ids = running_workspace_ids; + ctx.notify(); + } +``` + +- [ ] **Step 4: Run the focused tests and verify they pass** + +Run: + +```bash +cargo test -p warp --lib project_organization::view::project_tree_tests +``` + +Expected: both tests pass. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add app/src/project_organization/view/project_tree.rs app/src/project_organization/view/project_tree_tests.rs +git commit -m "feat: add workspace activity visual state" +``` + +--- + +### Task 3: Replace Workspace Row Tab Count UI + +**Files:** +- Modify: `app/src/project_organization/view/project_tree.rs` +- Modify: `app/src/project_organization/view/project_tree_tests.rs` + +- [ ] **Step 1: Add render smoke coverage** + +Add this test to `app/src/project_organization/view/project_tree_tests.rs` after `project_tree_renders_workspace_rows_with_finite_flex_constraints`: + +```rust +#[test] +fn project_tree_renders_running_selected_workspace_activity_badge() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let tempdir = tempfile::tempdir().expect("temporary directory should be created"); + let repository_path = tempdir.path().join("dip-agent"); + let worktree_path = tempdir.path().join("feature-worktree"); + std::fs::create_dir(&repository_path).expect("repository directory should be created"); + std::fs::create_dir(&worktree_path).expect("worktree directory should be created"); + let repository_id = RepositoryId(uuid::Uuid::from_u128(1)); + let workspace_id = RepositoryWorkspaceId(uuid::Uuid::from_u128(2)); + let timestamp = chrono::DateTime::from_timestamp(0, 0) + .expect("timestamp should be valid") + .naive_utc(); + app.add_singleton_model(|ctx| { + ProjectOrganizationModel::try_new( + vec![PersistedRepository { + id: repository_id.to_string(), + display_name: "dip-agent".to_string(), + path: repository_path.to_string_lossy().to_string(), + remote_url: None, + source: "local".to_string(), + created_at: timestamp, + last_opened_at: timestamp, + }], + vec![PersistedRepositoryWorkspace { + id: workspace_id.to_string(), + repository_id: repository_id.to_string(), + display_name: "feature-workspace".to_string(), + branch: "feature/workspace".to_string(), + worktree_path: worktree_path.to_string_lossy().to_string(), + created_at: timestamp, + last_opened_at: timestamp, + }], + RepositoryPersistence::new(None), + ctx, + ) + .expect("project organization model should initialize") + }); + + let (window_id, host) = + app.add_window(WindowStyle::NotStealFocus, ProjectTreeTestHost::new); + let project_tree = host.read(&app, |host, _| host.project_tree.clone()); + project_tree.update(&mut app, |project_tree, ctx| { + project_tree.set_tab_counts(HashMap::from([(workspace_id, 3)]), ctx); + project_tree.set_active_workspace(Some(workspace_id), ctx); + project_tree.set_running_workspaces(HashSet::from([workspace_id]), ctx); + }); + let root_view_id = app + .root_view_id(window_id) + .expect("window should have a root view"); + let mut presenter = Presenter::new(window_id); + + app.update(|ctx| { + presenter.invalidate( + WindowInvalidation { + updated: [root_view_id, project_tree.id()].into_iter().collect(), + ..Default::default() + }, + ctx, + ); + presenter.build_scene(vec2f(360., 240.), 1., None, ctx); + }); + }); +} +``` + +- [ ] **Step 2: Run the render smoke test** + +Run: + +```bash +cargo test -p warp --lib project_organization::view::project_tree_tests::project_tree_renders_running_selected_workspace_activity_badge +``` + +Expected after Task 2: passes as smoke coverage for a selected workspace with running state before the visual replacement. Keep this test unchanged so Task 3 verifies the new badge and selected-frame rendering still lays out. + +- [ ] **Step 3: Add badge and selected-frame rendering helpers** + +Add this free helper near `tab_count_badge_label`: + +```rust +fn apply_workspace_selection_frame( + row_container: Container, + visual_state: WorkspaceVisualState, + selected_border_color: pathfinder_color::ColorU, + selected_shadow_color: pathfinder_color::ColorU, +) -> Container { + if !visual_state.should_render_selection_frame() { + return row_container; + } + + row_container + .with_border(Border::all(1.).with_border_fill(selected_border_color)) + .with_drop_shadow( + DropShadow::new_with_standard_offset_and_spread(selected_shadow_color) + .with_offset(vec2f(0., 0.)), + ) +} +``` + +Add this method inside `impl ProjectTreePanel` before `render_workspace_row`: + +```rust + fn render_workspace_activity_badge( + &self, + tab_count: usize, + visual_state: WorkspaceVisualState, + workspace_id: RepositoryWorkspaceId, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let metadata_color = theme.sub_text_color(theme.background()); + let running_color: pathfinder_color::ColorU = theme.terminal_colors().normal.green.into(); + let badge_background = if visual_state.should_render_running_spinner() { + coloru_with_opacity(running_color, 14).into() + } else { + theme.surface_2() + }; + let border_fill = if visual_state.should_render_running_spinner() { + coloru_with_opacity(running_color, 42).into() + } else { + theme.surface_3() + }; + + let mut content = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(4.); + + if visual_state.should_render_running_spinner() { + let spinner_state = self + .workspace_spinner_states + .get(&workspace_id) + .expect("workspace spinner state should be initialized during tree refresh") + .clone(); + content.add_child( + ConstrainedBox::new( + Box::new(BrailleSpinner::new( + appearance.ui_font_family(), + appearance.ui_font_footnote(), + running_color, + spinner_state, + )), + ) + .with_width(10.) + .with_height(12.) + .finish(), + ); + } + + content.add_child( + Text::new_inline( + tab_count_badge_label(tab_count), + appearance.ui_font_family(), + appearance.ui_font_footnote(), + ) + .with_color(metadata_color.into()) + .finish(), + ); + + let mut badge = Container::new(content.finish()) + .with_horizontal_padding(6.) + .with_vertical_padding(2.) + .with_background(badge_background) + .with_border(Border::all(1.).with_border_fill(border_fill)) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(12.))); + if visual_state.should_render_running_spinner() { + badge = badge.with_drop_shadow( + DropShadow::new_with_standard_offset_and_spread(coloru_with_opacity( + running_color, + 30, + )) + .with_offset(vec2f(0., 0.)), + ); + } + badge.finish() + } + +``` + +- [ ] **Step 4: Replace old tab-count container usage in `render_workspace_row`** + +In `render_workspace_row`, remove: + +```rust + let tab_count_background = if selected { + selection_accent.with_opacity(20) + } else { + theme.surface_2() + }; + let tab_count = Container::new( + Text::new_inline( + tab_count_label(workspace.tab_count), + appearance.ui_font_family(), + appearance.ui_font_footnote(), + ) + .with_color(metadata_color.into()) + .finish(), + ) + .with_horizontal_padding(6.) + .with_vertical_padding(2.) + .with_background(tab_count_background) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .finish(); +``` + +Add: + +```rust + let visual_state = WorkspaceVisualState::new( + selected, + self.running_workspace_ids.contains(&workspace.workspace_id), + ); + let tab_count = self.render_workspace_activity_badge( + workspace.tab_count, + visual_state, + workspace_id, + appearance, + ); + let selected_color: pathfinder_color::ColorU = + theme.terminal_colors().normal.blue.into(); + let selected_border_color = coloru_with_opacity(selected_color, 58); + let selected_shadow_color = coloru_with_opacity(selected_color, 34); +``` + +Then, inside the `Hoverable` closure, after constructing `row_container`, replace the selected container styling block with: + +```rust + if selected { + row_container = + row_container.with_background(selection_accent.with_opacity(10)); + } else if mouse_state.is_hovered() { + row_container = row_container.with_background(theme.surface_overlay_2()); + } else { + row_container = row_container.with_background(theme.surface_overlay_1()); + } + row_container = apply_workspace_selection_frame( + row_container, + visual_state, + selected_border_color, + selected_shadow_color, + ); +``` + +- [ ] **Step 5: Run focused project tree tests** + +Run: + +```bash +cargo test -p warp --lib project_organization::view::project_tree_tests +``` + +Expected: all project tree tests pass. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add app/src/project_organization/view/project_tree.rs app/src/project_organization/view/project_tree_tests.rs +git commit -m "feat: render workspace activity badge" +``` + +--- + +### Task 4: Sync Running Repository Workspace IDs + +**Files:** +- Modify: `app/src/workspace/view/left_panel.rs` +- Modify: `app/src/workspace/view.rs` + +- [ ] **Step 1: Add `LeftPanelView` forwarding setter** + +In `app/src/workspace/view/left_panel.rs`, add this method after `set_project_tree_active_workspace`: + +```rust + pub fn set_project_tree_running_workspaces( + &mut self, + running_workspace_ids: HashSet, + ctx: &mut ViewContext, + ) { + self.project_tree_view.update(ctx, |tree, ctx| { + tree.set_running_workspaces(running_workspace_ids, ctx); + }); + } +``` + +Keep the existing `HashMap` and `HashSet` import at the top of `left_panel.rs`: + +```rust +use std::collections::{HashMap, HashSet}; +``` + +- [ ] **Step 2: Import `RunningSessionSummary`** + +In `app/src/workspace/view.rs`, change: + +```rust +use crate::session_management::{SessionNavigationData, SessionSource}; +``` + +to: + +```rust +use crate::session_management::{RunningSessionSummary, SessionNavigationData, SessionSource}; +``` + +- [ ] **Step 3: Add helper for per-tab long-running detection** + +Add this method near `all_repository_workspace_tabs` in `impl Workspace`: + +```rust + fn tab_has_long_running_terminal(&self, tab: &TabData, ctx: &AppContext) -> bool { + let pane_group = tab.pane_group.as_ref(ctx); + let sessions = pane_group + .pane_sessions(tab.pane_group.id(), tab.pane_group.window_id(ctx), ctx) + .collect_vec(); + !RunningSessionSummary::new(&sessions) + .long_running_cmds + .is_empty() + } +``` + +Add this method after it: + +```rust + fn repository_workspace_ids_with_long_running_terminal( + &self, + ctx: &AppContext, + ) -> HashSet { + self.repository_workspace_tabs + .workspace_ids_matching(&self.tabs, |tab| self.tab_has_long_running_terminal(tab, ctx)) + } +``` + +Ensure `HashSet` is imported in `workspace/view.rs`; the file already uses `HashMap`, so the import should become: + +```rust +use std::collections::{HashMap, HashSet}; +``` + +- [ ] **Step 4: Sync running IDs to project tree** + +Update `sync_project_tree`: + +```rust + fn sync_project_tree(&mut self, ctx: &mut ViewContext) { + if !FeatureFlag::RepositoryWorkspaces.is_enabled() { + return; + } + + let tab_counts = self.repository_workspace_tabs.tab_counts(&self.tabs); + let active_workspace_id = self.active_repository_workspace_id(); + let running_workspace_ids = self.repository_workspace_ids_with_long_running_terminal(ctx); + self.left_panel_view.update(ctx, |left_panel, ctx| { + left_panel.set_project_tree_tab_counts(tab_counts, ctx); + left_panel.set_project_tree_active_workspace(active_workspace_id, ctx); + left_panel.set_project_tree_running_workspaces(running_workspace_ids, ctx); + }); + } +``` + +- [ ] **Step 5: Refresh project tree on terminal state changes** + +In the `pane_group::Event::TerminalViewStateChanged` match arm in `app/src/workspace/view.rs`, change: + +```rust + pane_group::Event::TerminalViewStateChanged => { + self.update_active_session(ctx); + ctx.notify(); + } +``` + +to: + +```rust + pane_group::Event::TerminalViewStateChanged => { + self.update_active_session(ctx); + self.sync_project_tree(ctx); + ctx.notify(); + } +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +cargo test -p warp --lib workspace::repository_workspace_tabs_tests project_organization::view::project_tree_tests +``` + +Expected: all focused tests pass. + +- [ ] **Step 7: Commit Task 4** + +```bash +git add app/src/workspace/view/left_panel.rs app/src/workspace/view.rs +git commit -m "feat: sync running workspace activity" +``` + +--- + +### Task 5: Final Verification + +**Files:** +- Verify only; no planned source edits. + +- [ ] **Step 1: Run focused unit tests** + +Run: + +```bash +cargo test -p warp --lib workspace::repository_workspace_tabs_tests project_organization::view::project_tree_tests +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run cargo check** + +Run: + +```bash +cargo check +``` + +Expected: completes successfully. + +- [ ] **Step 3: Inspect diff for scope** + +Run: + +```bash +git diff --stat HEAD~4..HEAD +git diff HEAD~4..HEAD -- app/src/project_organization/view/project_tree.rs app/src/workspace/view.rs app/src/workspace/view/left_panel.rs app/src/workspace/repository_workspace_tabs.rs +``` + +Expected: +- Only repository workspace activity UI/state files changed. +- No terminal PTY output path changes. +- No string parsing or fallback logic for workspace identity. +- New badge number is `tab_count`, not long-running terminal count. + +- [ ] **Step 4: Confirm no extra source edits were needed** + +Run: + +```bash +git status --short +``` + +Expected: no uncommitted source changes remain after the Task 1-4 commits. diff --git a/docs/superpowers/specs/2026-07-12-project-organization-persistence-ack-design.md b/docs/superpowers/specs/2026-07-12-project-organization-persistence-ack-design.md new file mode 100644 index 00000000000..91883a9ba83 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-project-organization-persistence-ack-design.md @@ -0,0 +1,126 @@ +# Project Organization Persistence Acknowledgement Design + +## 背景 + +`ProjectOrganizationModel` 当前通过异步 `ModelEvent` 向 SQLite writer 发送 repository/workspace CRUD。发送成功只表示事件进入队列,不表示 SQLite 已提交。writer 暂停、线程退出或数据库写入失败时,模型仍可能更新内存并发送 UI event,导致当前进程显示成功、重启后数据丢失。 + +持久化路径还存在恢复后的唯一性问题。启动时不可访问的 persisted path 必须原样保留,以便后续 health reconciliation;路径恢复后,多个历史 alias 可能收敛到同一 canonical path。现有单值查找无法可靠区分唯一匹配和歧义。 + +## 目标 + +- repository/workspace CRUD 只有在 SQLite writer 确认提交后才更新内存和发送领域事件。 +- writer 不可用、暂停、线程断开或 SQLite 写入失败时返回结构化错误,且不产生内存半状态。 +- 不改造其他既有 `ModelEvent` 的异步语义。 +- repository/workspace path lookup 统一返回零个、一个或多个 canonical 匹配。 +- 多匹配必须失败并暴露冲突记录,不能依赖 `HashMap` 遍历顺序。 +- 为 Task 4 的 worktree 创建和补偿流程提供可确认的单次数据库 mutation 边界。 + +## 非目标 + +- 不为全部 persistence 事件增加 acknowledgement。 +- 不在本次改动中实现跨多个 repository/workspace row 的通用事务编排。 +- 不实现 pending/failed persistence UI、重试队列或 Task 8 的 health reconciliation。 +- 不调整 repository/workspace 树和 modal 视觉设计。 + +## 方案选择 + +采用领域专用 acknowledged request,复用现有 SQLite writer 线程。 + +未采用的方案: + +- 通用 `ModelEvent` acknowledgement:需要修改大量无关事件和调用点,超出当前范围。 +- 独立 SQLite 写连接:会复制连接生命周期、暂停/重建和事务协调逻辑。 + +## 持久化请求 + +新增显式 operation 类型: + +```rust +enum RepositoryPersistenceOperation { + UpsertRepository { repository: model::Repository }, + DeleteRepository { repository_id: String }, + UpsertRepositoryWorkspace { workspace: model::RepositoryWorkspace }, + DeleteRepositoryWorkspace { workspace_id: String }, +} +``` + +`ModelEvent` 增加领域请求变体,携带 operation 和一次性响应通道。旧的四个 fire-and-forget repository/workspace 变体被移除,避免同一领域存在两种写入语义。 + +模型发送请求后同步等待 writer 响应。repository/workspace 单行 CRUD 是短时本地 SQLite 操作;本设计不用于 clone、fetch 或其他长时操作。 + +## Writer 行为 + +SQLite writer 对领域请求执行以下流程: + +1. writer 已暂停:不执行 SQL,立即返回 paused 错误。 +2. writer 正常:执行对应 CRUD。 +3. SQL 成功提交:返回成功。 +4. SQL 或 Diesel 失败:返回包含操作上下文的错误,同时保留现有日志/遥测。 +5. caller 已断开响应通道:记录错误,但 writer 继续服务后续请求。 + +sender 不存在、请求发送失败或响应通道断开均由模型映射为 `ProjectOrganizationError::Persistence`。生产环境不允许把缺少 sender 当作成功。测试通过受控 writer/fake responder 显式返回成功或失败。 + +## 模型提交顺序 + +所有写操作统一遵循: + +1. 完成只读验证和 canonical identity 检查。 +2. 构造 persistence operation。 +3. 等待 SQLite acknowledgement。 +4. 更新内存主表和辅助索引。 +5. 发送 `ProjectOrganizationEvent`。 + +acknowledgement 失败时,第 4、5 步不得发生。删除和更新同样遵循该顺序。 + +## Canonical Path Resolver + +repository 和 workspace 分别提供统一 resolver。输入路径先严格 canonicalize;随后对全部已加载记录计算当前可用的 canonical identity: + +- persisted path 当前可 canonicalize:使用 canonical path比较。 +- persisted path 当前不可访问:仅保留原 key,不把它映射到其他位置。 +- 匹配 0 条:`None`。 +- 匹配 1 条:`Unique(id)`。 +- 匹配多条:`Ambiguous(ids)`。 + +`add_local_repository`、`touch_repository_path`、repository path update、workspace insert/update 都使用 resolver。更新自身时从候选中排除自身 ID。 + +新增结构化错误: + +- ambiguous repository canonical path,包含 canonical path 和冲突 repository IDs。 +- ambiguous workspace canonical path,包含 canonical path 和冲突 workspace IDs。 + +任何歧义都必须失败,不能自动选择、合并或删除记录。 + +## 错误处理 + +- persistence unavailable/paused/disconnected/SQL failure:操作返回错误,模型和 UI event 不变化。 +- duplicate canonical path:返回已有唯一记录的 duplicate 错误。 +- ambiguous canonical path:返回全部冲突 IDs,交由后续 reconciliation/UI 处理。 +- persisted missing path:继续保留原始 path,不在启动时失败。 +- persisted UUID/source/parent reference 损坏:继续 fail-fast。 + +## 测试策略 + +### Path identity + +- 启动时两个缺失 alias,运行中路径恢复并收敛到同一 repository:add/touch 返回 ambiguity。 +- workspace 使用相同场景验证 insert/update ambiguity。 +- 唯一恢复 alias 能被 add/touch 识别为已有记录。 +- persisted missing path 仍保留;可访问 alias 仍归一化。 +- update 改 path/branch 后旧索引失效,新索引生效。 +- 成功删除后 repository/workspace/path/branch 索引全部清理。 + +### Acknowledgement + +- 实际 SQLite writer 确认 upsert/delete 后,数据库可从独立连接读取结果。 +- SQLite unique/FK 错误通过 acknowledgement 返回调用者。 +- writer paused 时请求失败且数据库不变化。 +- sender 缺失、sender 断开、response 断开均返回 persistence error。 +- ack 失败时模型记录、辅助索引和 `ProjectOrganizationEvent` 均不变化。 +- ack 成功时模型和领域事件只发生一次。 + +## 兼容与后续任务 + +- 其他 `ModelEvent` 保持现有 fire-and-forget 行为。 +- Task 4 创建 workspace 时可使用 acknowledged workspace upsert 作为数据库提交边界;后续步骤失败时使用 acknowledged delete 做补偿。 +- Task 8 负责把 missing/ambiguous external state 转换为 health 状态和用户可操作 UI。 diff --git a/docs/superpowers/specs/2026-07-13-worktree-git-ownership-design.md b/docs/superpowers/specs/2026-07-13-worktree-git-ownership-design.md new file mode 100644 index 00000000000..409d2080769 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-worktree-git-ownership-design.md @@ -0,0 +1,215 @@ +# Worktree Git Ownership 与安全删除设计 + +## 背景 + +Repository Workspaces 的 Task 5 已实现 worktree 创建、删除 preflight 和分支删除,但质量审查发现以下并发安全问题: + +1. 仅比较 ref 名称和 commit OID 无法识别同 OID 的 delete/recreate ABA,创建失败 cleanup 或 workspace 删除可能误删其他操作重新创建的 branch。 +2. 安全删除只保存 merge target 名称。upstream 或默认分支在 preflight 后后退时,旧的 merged 结论会失效。 +3. 目标路径的 `symlink_metadata` 检查与 `git worktree add` 之间仍有 TOCTOU;Git 会接受并接管并发创建的空目录。 +4. Remote worktree 创建成功后的验证没有检查 upstream 仍为空。 +5. 部分包装错误没有通过 `std::error::Error::source()` 暴露主底层错误。 + +本设计优先保证不误删用户数据。创建失败时无法证明 branch ownership,就保留残留并返回结构化错误;删除时把权威校验移动到 ref transaction 持锁之后,消除校验完成后的 ref 漂移窗口。 + +## 目标 + +- 关闭权威删除校验与 branch mutation 之间的同 OID ABA 窗口。 +- 在权威校验和 mutation 期间锁定 branch ref 与 merge target ref。 +- 原子 claim worktree 目标目录,避免接管并发创建的空目录。 +- 创建成功后验证 worktree 注册状态、attached branch、branch OID 和 upstream 后置条件。 +- 对无法自动补偿的残留状态提供完整、可操作、可追踪的结构化错误。 +- 保持所有 Git 参数独立传递,Path 使用 `OsStr`,blocking Git 继续由 async wrapper 调度到后台线程。 + +## 非目标 + +- 不在 Git 服务中实现数据库、Workspace UI 或页签补偿事务。 +- 不尝试让多个 Git/文件系统操作成为真正的跨资源原子事务。 +- 不在无法证明 ownership 时猜测 branch 是否由本次操作创建。 +- 不追踪跨 UI preflight 的历史 branch generation;同名、同 OID branch 若在 prepared transaction 获取锁前被重建,按当前 branch 重新校验。 +- 不为外部进程直接篡改 `.git` 内部文件提供安全保证;支持范围是标准 Git 命令产生的状态变化。 + +## 方案比较 + +### 方案 A:残留优先创建 + 锁内权威删除校验 + +创建失败时不自动删除无法证明 ownership 的 branch。删除时先 prepare `git update-ref --stdin` transaction,锁定 branch 和 merge target,再重新执行 worktree、branch、dirty 和 merge 权威校验,随后跨越 `git worktree remove` 提交 branch delete。 + +优点:权威校验完成后相关 refs 不再漂移,不需要不可移植的文件 identity 或持久化 ownership marker。缺点:锁前发生的同名同 OID 重建按当前 branch 处理;实现必须处理 Git transaction 协议与部分 mutation。 + +### 方案 B:使用完整 reflog SHA-256 作为 generation token + +在 preflight 和 mutation 之间比较完整 branch reflog 的 SHA-256。 + +优点:实现表面简单。缺点:实证表明 Git 删除 branch 时会删除 reflog;用相同 OID、committer 和时间重建 branch 可生成字节完全相同的 reflog,因此该 token 可重放,不能证明 generation。 + +### 方案 C:永不自动删除 branch + +创建失败和 workspace 删除都只移除 worktree,始终保留 branch。 + +优点:数据安全边界最简单。缺点:实质取消“同时删除本地分支”,产品行为退化。 + +采用方案 A。 + +## 创建流程 + +### 目标目录 claim + +1. 完成 repository、remote ref、branch name 和 branch 不存在等只读校验。 +2. 使用 `std::fs::create_dir(worktree_path)` 原子 claim 目标路径。 +3. `AlreadyExists` 返回 `TargetExists`;其他 I/O 错误返回结构化 claim 错误。 +4. claim 成功后记录该目录由本次调用创建。 + +并发方在 claim 后调用 `create_dir` 会得到 `AlreadyExists`。本设计不把“同一用户主动删除并替换已 claim 目录”视为普通并发场景;创建完成后仍会重新验证 canonical registered path,发现身份或注册状态异常时返回残留错误。 + +### Git 创建 + +目标目录已由本次调用 claim 后,执行: + +```text +git -C worktree add --no-track -b +``` + +branch 由同一个 Git 命令创建,不再在命令前创建最终 local ref。若命令因并发 branch、Git I/O 或其他原因失败,Git 服务不自动删除可能存在的 branch,因为 ref 名称和 OID 无法证明其 generation ownership。 + +### 创建后验证 + +Git 命令成功后验证: + +1. `list_worktrees` 中仅有一个 canonical registered path 与目标一致。 +2. worktree 为 attached、非 bare,并指向 `refs/heads/`。 +3. local branch 为 direct ref,OID 等于 remote ref 创建前解析的 expected OID。 +4. local branch upstream 为空。 + +任一验证失败时返回 `WorktreeCreationVerificationFailed`。错误包含 worktree path、branch、expected OID、实际 direct/symbolic 状态和 upstream。该路径不删除无法证明 ownership 的 branch 或 worktree,而是明确报告残留,交由后续模型补偿与启动一致性检查处理。 + +### 创建失败目录清理 + +Git 命令失败后: + +1. 检查目标是否已注册为 worktree;已注册时不删除目录。 +2. 未注册且目录仍为空时,删除本次 claim 的目录。 +3. 目录不为空、类型变化或检查失败时保留目录,并在错误中记录 cleanup failure。 +4. 无论目录是否清理,均不自动删除可能残留的 local branch。 + +`WorktreeCreationFailed` 必须保留原始 Git stderr/IO 错误,并显式提供 `branch_may_remain`、`worktree_registered`、`claimed_directory_removed` 等残留状态。 + +## 删除 preflight + +`DeletionPreflight` 继续提供 UI 确认所需的只读快照: + +- canonical registered worktree path; +- branch full ref 与 branch OID; +- merge target full ref 与 merge target OID; +- dirty、attached 和 merge 结论。 + +该快照只用于展示和确认,不是 mutation 的 ownership 证明。`remove_workspace` 不接收或信任较早的 `DeletionPreflight`;它在本次删除调用中读取 transaction 候选值,并在 transaction prepared 后重新执行权威校验。 + +### Merge target + +- 有 upstream 时保存 upstream full ref 与解析后的 OID。 +- 无 upstream 时保存 primary remote default branch full ref 与 OID。 +- `merge-base --is-ancestor ` 使用固定 OID,而不是后续可变 ref 名称。 +- merge target full ref 与待删 branch full ref 相同时,在启动 transaction 前返回结构化 invalid-target 错误;不向同一个 ref 排队 `verify` 和 `delete`。 + +## 持锁删除事务 + +branch 删除使用 `git update-ref --stdin` 子进程。命令 stdin/stdout/stderr 均通过 `crates/command` 管理。 + +### Transaction 准备 + +普通安全删除向 transaction 发送: + +```text +start +verify +delete +prepare +``` + +force delete 只发送带 expected old OID 的 `delete`,不验证 merge target。`delete ` 本身同时执行 branch compare-and-delete;不能再为同一 branch ref 添加 `verify`,因为 Git 会以 `multiple updates for ref` 拒绝 transaction。 + +`prepare` 成功后 Git 持有相关 ref locks,pending delete 尚未提交。实现必须解析每个协议响应;未知、缺失或失败响应均 abort 并返回结构化 transaction 错误。 + +### 锁内验证与 mutation + +1. 重新读取 `list_worktrees`,要求 canonical registered path 唯一、非 bare、非 detached,并仍 attached 到请求的 local branch。 +2. 重新读取 branch direct ref,要求 OID 等于 transaction 中的 expected OID,且不是 symbolic ref。 +3. 重新检查 worktree status,存在 tracked 或 untracked change 时 abort。 +4. 非 force 删除重新解析 upstream 或 primary remote default branch,要求 target full ref 仍等于 transaction 锁定的 target ref。 +5. 使用锁定的 branch OID 和 target OID 重新执行 `merge-base --is-ancestor`;未合入时 abort。 +6. 全部校验通过后执行 `git worktree remove `。 +7. worktree remove 失败时发送 `abort`,branch 保留。 +8. worktree remove 成功后发送 `commit`,提交 branch delete。 + +在读取候选值与 `prepare` 之间,branch 改到不同 OID 或 merge target 漂移会使 transaction prepare 失败。同名、同 OID branch 若在 `prepare` 前被重建,按选定语义作为当前 branch 进入锁内权威校验;`prepare` 之后的 ref mutation 被 Git ref lock 阻止。 + +真实 Git prototype 已验证:只对 merge target 使用 `verify`、对 branch 使用带 expected OID 的 `delete` 时,prepared transaction 可以跨越 `git worktree remove` 并成功 commit。 + +### 部分 mutation + +worktree remove 成功但 transaction commit、响应读取或子进程等待失败时,返回 `BranchDeleteTransactionFailed`,明确包含: + +- canonical worktree path; +- `worktree_removed: true`; +- branch/merge-target ref 与 expected OID; +- transaction 阶段; +- Git stderr/IO source; +- branch 当前状态检查结果或检查错误。 + +不得把该状态包装成普通 command error。 + +## Target 目录身份与清理 + +目标目录通过原子 `create_dir` claim。创建命令前和创建后均验证该路径仍为目录;Git 成功后以 canonical registered path 作为最终身份。 + +失败清理只删除仍为空且未注册的 claimed 目录。目录中出现任何内容时不递归删除,避免清理并发方或用户写入的数据。 + +## 错误链 + +- 单一主底层错误字段使用 `#[source]`。 +- 同时存在 operation error 和 cleanup/inspection error 时,operation error 为主 source;次级错误保留在字段和 Display 中。 +- transaction protocol、abort、commit、wait 和 post-failure inspection 分别保留阶段信息。 +- 所有用户可见错误保留 Git stderr 中的关键原因。 + +## 测试设计 + +### 创建 + +- claim 前目标不存在,claim 后并发 `create_dir` 返回 `AlreadyExists`。 +- 并发创建空目标目录不能被 Git 静默接管。 +- Git 创建失败时可能残留 branch,错误明确报告且不自动删除 branch。 +- 成功后 hook 设置 upstream,verification 返回结构化残留错误。 +- registered path、attached branch、direct OID 和 upstream none 的正常成功路径。 +- claimed 目录仅在未注册且为空时清理;非空目录保留。 + +### 删除 + +- 同 OID delete/recreate 若发生在 transaction prepare 前,按当前 branch 完成锁内权威校验;prepare 后的 ref mutation 被锁拒绝。 +- branch 改到不同 OID 或 merge target 在候选读取后漂移,transaction `delete`/`verify`/`prepare` 失败且无 mutation。 +- transaction prepared 后 worktree path、attached branch、dirty 状态或 target selection 改变,锁内权威校验失败并 abort。 +- prepared transaction 下 worktree remove 成功,commit 后 branch 消失。 +- worktree remove 失败触发 abort,branch 保留。 +- commit/IO/协议失败明确报告部分 mutation。 +- force delete 跳过 merge target verify 和 merge 判断,但不跳过 branch OID、worktree identity、attached branch 和 dirty 校验。 + +### 错误链 + +- 对主包装错误调用 `source()` 能获得底层 CommandFailed/CommandIo/transaction error。 +- 次级 cleanup/inspection 错误仍出现在 Display 和结构化字段中。 + +## 风险与约束 + +- 锁前发生的同名、同 OID branch 重建不会作为历史 identity change 单独拒绝;它必须通过锁内全部当前状态校验。 +- ref transaction 不锁 worktree metadata 或 repository config,因此所有依赖这些状态的判断都在 prepared 后重做;最终 `git worktree remove` 仍作为 dirty/locked 等 Git 约束的最后防线。 +- 当前 Git prototype 已通过,但 transaction protocol 与 `worktree remove` 的组合仍需在支持平台的测试环境验证。 +- `git.rs` 已较大。实现计划应把 transaction protocol 拆为职责单一的私有单元;不新增 reflog 模块,不做与 Task 5 无关的重构。 + +## 规格同步 + +实现时需要同步更新 `specs/repository-workspaces/TECH.md`: + +- 创建失败无法证明 branch ownership 时保留 branch 并报告残留; +- 目标目录通过原子 claim; +- 删除使用 prepared ref transaction,并在持锁后执行权威校验; +- merge target 以 ref + OID 固定,在 transaction 中验证并在锁内重新确认 target selection。 diff --git a/docs/superpowers/specs/2026-07-15-adopt-existing-worktree-design.md b/docs/superpowers/specs/2026-07-15-adopt-existing-worktree-design.md new file mode 100644 index 00000000000..b0fbfe25f3e --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-adopt-existing-worktree-design.md @@ -0,0 +1,81 @@ +# 接入已有 Worktree 设计 + +## 背景 + +当前创建 workspace 弹窗只能基于 remote 分支或本地分支创建新的 Git worktree。用户已经在本地创建的 linked worktree 不能直接出现在 repository workspace 列表中,必须重复创建或手动迁移。 + +## 目标 + +- 在创建 workspace 弹窗中新增“Use existing worktree”模式。 +- 仅列出当前 repository 通过 `git worktree list` 注册的 linked worktree。 +- 排除 repository 主目录和 detached HEAD worktree。 +- 选择 worktree 后,使用其 canonical path 作为只读 Worktree path。 +- 从 `refs/heads/` 解析 branch short name,作为可编辑 Workspace name 的默认值。 +- 创建时不执行 `git worktree add`,只校验选中的 worktree 仍归属于当前 repository 后持久化 workspace。 + +## 非目标 + +- 不支持文件选择器或任意目录。 +- 不支持接入 detached HEAD worktree。 +- 不允许在“Use existing worktree”模式修改已选 worktree path。 +- 不修改 remote 分支和本地分支创建新 worktree 的现有语义。 + +## 交互 + +创建弹窗保留现有“From remote branch”和“Use local branch”模式,并新增第三个 `SecondaryTheme` 按钮“Use existing worktree”。 + +进入或打开弹窗时,`Workspace` 异步调用现有 `list_worktrees_async(repository.path)`。modal 将结果转换为 `ExistingWorktreeOption`: + +- `path`:已 canonicalize 的 linked worktree path。 +- `branch_name`:去除 `refs/heads/` 前缀后的本地 branch name。 +- `display_label`:branch name。 + +转换时剔除与 repository root 相同的主 worktree、`branch` 缺失的 detached worktree、以及不符合 `refs/heads/` 格式的 worktree。下拉加载时禁用;成功后按 `(branch_name, path)` 排序并启用;失败时该模式禁用并显示可重试错误。选择第一项不自动创建,但在用户切换到 existing mode 时自动选中第一项并回填字段。 + +用户选择一个候选项后: + +- `Workspace name` 设置为 `branch_name`,用户仍可编辑。 +- Worktree path 显示 canonical path,不能编辑。 +- Create 按钮只在成功选择一个候选项后可用。 + +长 branch/path/error 文本继续使用现有 modal 的 480px 约束;picker 保持不被 `Clipped` 包围,避免截断 overlay。 + +## 数据与 Git 边界 + +`CreateWorkspaceSource` 新增: + +```rust +ExistingWorktree { + local_branch: String, +} +``` + +`CreateWorkspaceRequest.worktree_path` 保存选择的 canonical path。提交前,`Workspace` 调用新增的只读异步验证函数,重新执行 `list_worktrees`,要求同一 canonical path 仍存在且仍检出 `refs/heads/`。验证失败不持久化,向用户显示操作错误。 + +验证通过后,复用现有持久化、tab 切换与新终端页签创建流程;该 source 不调用 `create_from_remote_async` 或 `create_from_local_async`,也不执行任何 Git mutation。 + +首次加载、Retry 和所有 list worktree 回调与现有 remote refs 一样携带 `repository_id` 和 `workspace_id`,仅当 modal target 同时匹配时更新 UI,避免关闭或重新打开后写入陈旧结果。 + +## 错误处理 + +- worktree 列表加载失败:existing mode 禁用,错误位于 existing section,提供 Retry;remote/local 两种模式不受影响。 +- 选择在提交前失效:验证失败,保持 modal 打开且不创建数据库记录或 terminal tab。 +- 空列表:existing mode 无候选且不可提交,其他模式保持可用。 + +## 测试策略 + +- option 映射:排除主 worktree、detached/异常 branch,保留 canonical linked path,按 branch/path 稳定排序。 +- 默认值:选择已有 worktree 时 Workspace name 使用 branch,path 不可被 form 改写。 +- request:已有 worktree source 携带 local branch 和已选 path。 +- Git 验证:有效注册 path/branch 通过;路径不存在、branch 已变更、主 worktree 和 detached worktree 被拒绝。 +- Workspace 异步协调:陈旧 repository/workspace ID 的成功、错误与 Retry 结果均被忽略。 +- 回归:remote/local 创建路径和已有 picker 行为继续通过。 + +## 验收标准 + +1. 用户能从下拉列表选择当前 repository 的 linked worktree。 +2. 主目录与 detached worktree 不出现。 +3. 选择后 path 固定为已注册路径,Workspace name 默认是 branch name。 +4. 创建不会调用 Git worktree 创建命令,验证后仅注册 workspace 并打开 terminal。 +5. 列表错误不会阻断 remote/local 模式,Retry 可重新加载。 +6. 所有长内容在 modal 内保持受约束显示。 diff --git a/docs/superpowers/specs/2026-07-15-create-workspace-branch-picker-design.md b/docs/superpowers/specs/2026-07-15-create-workspace-branch-picker-design.md new file mode 100644 index 00000000000..dba971fe21c --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-create-workspace-branch-picker-design.md @@ -0,0 +1,86 @@ +# Create Workspace Branch Picker Design + +## 背景 + +当前创建 workspace 弹窗将 `refs/remotes/origin/main` 等完整 remote ref 作为可编辑文本展示。长 remote ref 和长 worktree path 会撑破弹窗左侧边界;用户也容易手动输入无效 ref。弹窗打开时已经异步获取 repository refs,但结果仅回填到文本编辑器。 + +当前默认 worktree path 在分支选择前使用随机 workspace ID 生成,`New local branch` 和 `Workspace name` 没有初始值。 + +## 目标 + +- remote 分支通过可搜索的下拉选择,不允许手动编辑完整 remote ref。 +- remote 分支默认只显示 branch name;只有多个 remote 含相同 branch name 时显示 `remote/branch` 消歧。 +- 所选项在内部保留完整 refname,创建 Git worktree 时继续传递完整 `refs/remotes//`。 +- 成功加载 remote refs 后自动选择首项。 +- 每次选择 remote 分支时,强制同步覆盖: + - `New local branch` + - `Workspace name` + - `Worktree path` +- 三项默认值分别为 branch name、branch name、`{home}/.warp/worktrees/{repository name}/{branch name}`。 +- 切换或选择 local branch 时,也以所选 local branch 同步 workspace name 和 worktree path。 +- remote 获取失败时,local branch 模式保持可用;remote 模式显示错误、禁止创建,并提供 Retry。 +- 长 branch name、worktree path 和错误文本必须受 modal 可用宽度约束,不得横向溢出或把内容推到左边界之外。 + +## 非目标 + +- 不修改 `fetch_and_list_refs_async`、Git ref 校验、worktree 创建或 SQLite 持久化协议。 +- 不新增全局通用 branch picker;当前仅复用既有 `FilterableDropdown`。 +- 不保留用户在切换 remote 分支前手动修改的三个派生字段,选择新分支总是覆盖它们。 +- 不改变 remote 分支排序策略以外的 Git 行为。 + +## 方案 + +### 结构化 remote 选项 + +`CreateWorkspaceModal` 将 remote refs 转换为内部选项,包含: + +- `full_ref`:提交给 `CreateWorkspaceSource::RemoteBranch` 的完整 ref。 +- `remote`:remote 名称。 +- `branch_name`:去除 `refs/remotes//` 后的分支名。 +- `display_label`:正常为 `branch_name`;当同一个 `branch_name` 出现在多个 remote 时为 `remote/branch_name`。 + +下拉 action 携带完整选项或稳定索引,显示文字不参与 Git ref 解析。分支名重复检测以 `branch_name` 分组,而非格式化字符串分组。 + +下拉使用现有 `FilterableDropdown`:加载中显示不可选的 loading 占位项;成功后填充可搜索选项并自动选中首项;remote 获取失败时禁用 remote 创建并显示 Retry 按钮。 + +### 默认字段同步 + +弹窗配置时接收 repository display name 与 worktree 根目录所需信息,不再预先使用随机 workspace ID 伪造最终路径。 + +remote/local 分支选中后调用同一派生逻辑: + +1. 读取选中的 branch name。 +2. 将 branch name 写入 remote 模式的 `New local branch`;local 模式的实际分支由所选 local branch 保持。 +3. 将 `Workspace name` 设为 branch name。 +4. 以 `workspace_dir_name` 生成 `{home}/.warp/worktrees/{repository name}/{branch name}` 并写入 `Worktree path`。 + +每次选择都覆盖用户此前对这三个编辑器的修改。提交时 remote 来源取结构化选项的 `full_ref`,local 来源取选择的 local branch。 + +### 加载与重试 + +异步 fetch 仍由 `Workspace` 协调。`CreateWorkspaceModal` 新增重试事件,`Workspace` 使用当前 repository path 再次调用 `fetch_and_list_refs_async`。modal 持有独立的 remote 加载状态与错误文本;不把 fetch 错误混入提交校验错误。 + +加载失败时: + +- remote picker 不能打开或提交。 +- Create 按钮在 remote 模式禁用或提交时返回清晰错误。 +- 切换到 local branch 模式后,可继续选择并创建。 +- Retry 重新进入 loading 状态并在成功后自动选择首个 remote 分支。 + +### 布局约束 + +所有 editor section 以 `Shrinkable` + `ConstrainedBox` + `Clipped` 包装 `ChildView`,让单行内容在固定 modal 可用宽度内裁剪/水平滚动。remote picker 的顶栏和菜单使用与 modal 内容一致的最大宽度。错误文本也置于受约束容器中,避免错误消息造成横向布局溢出。 + +## 测试策略 + +- remote option 映射:完整 ref 保持不变,普通项仅显示 branch name,同名跨 remote 项显示 `remote/branch`。 +- 分支选择:remote 选择同步 new local branch、workspace name 和 worktree path;后续选择覆盖手动编辑值。local 选择同步 workspace name/path。 +- 路径派生:使用注入或显式传入的 home/repository/branch,断言无随机 workspace ID。 +- 加载状态:remote 加载失败时 remote 不能提交且发出重试事件;成功时自动选中首项。 +- UI 布局:长内容通过受约束 editor/picker 路径渲染,测试固定宽度的 scene 不产生超出 modal 的输入宽度。 + +## 风险与兼容 + +- 多 remote 同名分支不再依赖展示文本解析,避免选错 ref。 +- 非规范 remote ref 继续由现有 Git 层校验;UI 不绕过校验。 +- 空 remote ref 列表保留 remote 模式的不可提交状态,用户仍可使用 local branch 模式或关闭弹窗。 diff --git a/docs/superpowers/specs/2026-07-15-repository-workspace-add-button-design.md b/docs/superpowers/specs/2026-07-15-repository-workspace-add-button-design.md new file mode 100644 index 00000000000..5901678d810 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-repository-workspace-add-button-design.md @@ -0,0 +1,46 @@ +# Repository Workspace Add Button Design + +## 背景 + +Repository workspace 左侧树中,repository 行末的“+”图标会显示手形光标,但点击后不会打开创建 workspace 对话框,且 `Create workspace` tooltip 不会稳定显示。顶部 `Add repository` 按钮正常工作。 + +`ProjectTreePanel` 为 repository 行、workspace 行及其行内按钮分别维护 `HashMap`。当前渲染代码只用 `get(...).unwrap_or_default()` 读取这些 map,从未插入新句柄。因此每一次重绘都会为交互元素创建新的状态句柄。鼠标按下触发重绘后,鼠标释放面对的是新句柄,无法组成一次完整点击;tooltip 的 hover 状态也会在重绘后丢失。 + +## 目标 + +- repository 行末的“+”在一次正常点击后打开创建 workspace 对话框。 +- “+”的 `Create workspace` tooltip 使用稳定的 hover 状态。 +- repository 行、workspace 行及 workspace 删除按钮使用同一正确的状态缓存机制,避免保留相同缺陷。 +- 删除已不存在 repository/workspace 的状态句柄,避免缓存无限增长。 + +## 非目标 + +- 不修改创建 workspace 的 Git、持久化或对话框表单逻辑。 +- 不修改 repository workspace 的视觉设计、Feature Flag 或顶部 `Add repository` 流程。 +- 不引入新的子 View 或内部可变 render 状态。 + +## 方案 + +在 `ProjectTreePanel::refresh_tree` 完成树状态重建后,根据当前 repository/workspace ID 同步四组 `MouseStateHandle` 缓存: + +1. 清理不再出现在树中的 repository/workspace ID。 +2. 为当前的 repository 行、repository 行内创建按钮、workspace 行和 workspace 删除按钮通过 `entry(id).or_default()` 初始化稳定句柄。 +3. 渲染函数只从已同步缓存读取句柄;若同步逻辑被破坏则快速失败,而不在 render 路径临时创建状态。 + +缓存写入放在状态刷新阶段,而非 render 阶段。这样 render 保持只读,且每次重绘都复用同一 `Arc>`,符合其他动态列表的既有模式。 + +## 测试策略 + +新增或调整 `project_tree_tests.rs` 的 UI 事件测试: + +1. 渲染带有 repository 行的 `ProjectTreePanel`。 +2. 在行末“+”的位置发送 `LeftMouseDown`。 +3. 强制重新构建 scene,模拟真实 UI 在按下后发生的重绘。 +4. 在相同位置发送 `LeftMouseUp`。 +5. 断言仅收到对应 repository 的 `CreateWorkspaceRequested` 事件,并确认 repository 的展开状态未变化。 + +该测试在修复前必须失败,因为重绘会替换按下状态的句柄;修复后通过。 + +## 风险与验证 + +修改仅限 `ProjectTreePanel` 的交互状态生命周期,不改变 action、event 或 modal 链路。验证包括该回归测试和 `cargo check -p warp`;环境未安装 `cargo-nextest` 时,针对性测试使用 `cargo test -p warp --lib` 运行。 diff --git a/docs/superpowers/specs/2026-07-16-workspace-selection-state-design.md b/docs/superpowers/specs/2026-07-16-workspace-selection-state-design.md new file mode 100644 index 00000000000..7c6b9fd6b30 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-workspace-selection-state-design.md @@ -0,0 +1,28 @@ +# Workspace 选中态设计 + +## 目标 + +让项目树中的当前 workspace 在不依赖文字颜色的情况下快速可识别,并保持现有主题、悬停删除按钮和行布局行为不变。 + +## 选中态 + +选中的 workspace 行同时使用三种视觉信号: + +- 使用主题的 `surface_2` 作为整行背景。 +- 使用主题的 `accent` 绘制 1px 边框。 +- 使用主题的 `accent` 在行左侧绘制 3px 强调条。 + +workspace 名称继续使用 `accent` 文字颜色,分支名称和 tab 数量继续使用次级文字颜色。未选中行不增加新的背景、边框或强调条。 + +## 交互与布局 + +- 选中态只由 `ProjectTreeState::selected_workspace_id` 决定。 +- 鼠标悬停时仍显示 workspace 删除按钮,未悬停时保留原有尺寸占位。 +- 选中态不会改变行的宽高、文字换行或点击区域。 +- repository 行和未分类 workspace 行不受本次视觉调整影响。 + +## 验证 + +- 增加或更新项目树选中态测试,确认选中 workspace 的视觉状态判断正确。 +- 运行项目树相关单元测试。 +- 运行 `cargo check -p warp` 和 `git diff --check`。 diff --git a/docs/superpowers/specs/2026-07-19-local-repository-workspace-design.md b/docs/superpowers/specs/2026-07-19-local-repository-workspace-design.md new file mode 100644 index 00000000000..9d6bde11d1f --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-local-repository-workspace-design.md @@ -0,0 +1,99 @@ +# Repository Local Workspace 设计 + +## 背景 + +Project Organization 当前可以保存 repository 和基于 Git worktree 的 workspace,但 repository 的主 worktree 没有统一进入 workspace 模型。用户新增 repository 后需要额外完成一次 workspace 创建;已有 repository 也缺少将主 worktree加入 workspace 的入口。 + +本功能为每个 repository 提供一个名为 `local` 的 workspace,并复用现有 workspace 创建弹窗完成已有 repository 的添加操作。 + +## 目标 + +1. 新增 repository 时自动创建名为 `local` 的 workspace。 +2. 已有 repository 可以通过现有“创建 workspace”弹窗,将主 worktree 选择为 workspace。 +3. `local` workspace 的显示名固定为 `local`,branch 字段记录主 worktree 当前实际分支,例如 `main` 或 `develop`。 +4. 创建或添加成功后立即切换到该 workspace,并打开以 repository 根目录为初始目录的终端页签。 +5. 重复操作不会创建重复 workspace,并返回明确错误。 +6. 主 worktree 处于 detached HEAD 时拒绝操作,不创建 workspace,并显示明确错误。 + +## 非目标 + +- 不在项目树中增加独立的“添加 local workspace”按钮。 +- 不改变现有普通 worktree workspace 的创建流程或命名规则。 +- 不为 detached HEAD 自动生成临时分支。 +- 不改变 Git repository 的分支、worktree 或文件内容。 + +## 用户界面 + +### 新增 repository + +新增 repository 的成功流程直接创建 repository 和 `local` workspace。成功后当前 repository 被选中,应用切换到 `local` workspace,并打开 repository 根目录终端。 + +如果主 worktree 无法解析到当前分支(detached HEAD),新增操作失败,repository 和 workspace 都不应被持久化。 + +### 已有 repository + +已有 repository 继续使用当前的 workspace 创建入口和弹窗。弹窗的“Use existing worktree”页需要把主 worktree作为一个可选项展示: + +- 列表项使用主 worktree 的实际 branch 名,并标识其为主 worktree,例如 `main (local)`。 +- 选择主 worktree 后,workspace name 默认填充为 `local`。 +- 选择主 worktree 后,提交请求使用 repository 根目录作为 `worktree_path`,而不是执行 `git worktree add`。 +- branch 字段使用选择时读取到的实际 branch 名。 +- 主 worktree 为 detached HEAD 时不作为可提交的有效选项;弹窗在 existing worktree 区域显示明确的错误提示,说明主 worktree 当前没有分支,不能创建 `local` workspace,不能静默地把它当作普通 worktree。 + +普通已有 worktree 的展示、选择、名称编辑和创建行为保持不变。 + +## 领域模型与 Git 行为 + +`ValidatedRepository` 增加主 worktree 当前 branch 信息。repository 校验阶段读取主 worktree状态;无法读取 branch 或检测到 detached HEAD 时返回领域错误。 + +`ExistingWorktreeOption` 增加能区分主 worktree 的信息。`existing_worktree_options` 不再无条件排除 repository 根目录,但仍排除 prunable 或无法验证的 worktree。主 worktree使用 repository 根目录作为 path,并保留真实 branch 名;detached 主 worktree由弹窗的 existing worktree 状态错误单独呈现,不能提交。 + +创建普通 worktree workspace 时继续执行 `git worktree add`。创建主 worktree workspace 时不执行 `git worktree add`,只验证它仍然是当前 repository 的主 worktree,然后将其路径保存为 workspace path。 + +## 数据流与持久化 + +### 新增 repository + +1. 校验 repository 根目录、Git 状态、remote/default branch 和主 worktree当前 branch。 +2. 构造 repository 记录及 `local` workspace 记录。 +3. 在同一个 SQLite transaction 中持久化两条记录;任一条失败都回滚整个操作。 +4. 提交内存状态变更,并发布 repository/workspace 添加事件。 +5. UI 使用返回的 workspace ID 完成切换和初始终端创建。 + +`local` workspace 的 path 为 repository 根目录,branch 为主 worktree当前 branch,workspace name 为 `local`。 + +### 已有 repository + +1. 弹窗加载 repository 的 existing worktree options,其中包含主 worktree。 +2. 用户选择主 worktree并提交。 +3. model 校验 repository、path 和 branch 的唯一性。 +4. 对主 worktree跳过 Git worktree 创建,直接持久化 workspace。 +5. 复用现有 workspace 创建成功后的切换和初始终端逻辑。 + +workspace 唯一性沿用现有模型约束:同一 repository 下不能重复保存相同 worktree path 或相同 branch。若主 worktree已经对应 workspace,操作失败并保持原有数据不变。 + +## 错误处理 + +错误必须向用户暴露真实原因,不增加静默 fallback: + +- 主 worktree detached HEAD:提示主 worktree 当前没有分支,无法创建 `local` workspace。 +- 主 worktree不存在、不可验证或 Git 状态无效:返回现有 repository/worktree 校验错误。 +- `local` workspace已存在或 path/branch冲突:提示 workspace 已存在或冲突,不执行重复持久化。 +- 新增 repository 的 repository/workspace 原子保存失败:回滚数据库事务,并向用户显示保存失败原因。 + +## 验证范围 + +需要增加或更新以下测试: + +- Git:主 worktree被列入 existing worktree options;显示真实当前 branch;detached 主 worktree返回明确错误。 +- workspace modal:主 worktree可见;选择后默认名称为 `local`;请求使用 repository 根目录 path。 +- model/persistence:新增 repository 自动生成 `local` workspace;repository 与 workspace 原子保存;重复 local workspace被拒绝且不产生部分数据。 +- workspace UI:成功后切换到返回的 workspace,并在 repository 根目录打开初始终端;失败时不切换、不创建终端。 + +## 验收标准 + +1. 新增一个正常 checkout 的 repository 后,列表中同时出现 repository 和 `local` workspace,当前页面切换到该 workspace,并出现 repository 根目录终端。 +2. 对已有 repository 打开创建 workspace 弹窗,在“Use existing worktree”页能看到并选择主 worktree;提交后得到 `local` workspace,且不生成额外 Git worktree。 +3. 再次执行相同操作不会创建第二个 `local` workspace。 +4. 对 detached HEAD 的主 worktree执行新增或添加操作时,用户看到明确错误,数据库和 UI 状态不发生部分变更。 +5. 现有普通 worktree workspace 创建流程与测试保持通过。 diff --git a/docs/superpowers/specs/2026-07-19-repository-workspace-path-design.md b/docs/superpowers/specs/2026-07-19-repository-workspace-path-design.md new file mode 100644 index 00000000000..0212593bda1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-repository-workspace-path-design.md @@ -0,0 +1,42 @@ +# Repository Workspace 路径创建修复设计 + +## 背景 + +创建 repository workspace 时,创建窗口默认生成多级路径: + +```text +~/.warp/worktrees// +``` + +`TargetDirectoryClaim::acquire` 使用 `std::fs::create_dir` 直接创建最终目录。当 `.warp`、`worktrees` 或 repository 目录尚不存在时,系统返回 `No such file or directory`,导致点击确认后 workspace 创建失败。 + +## 目标 + +- 允许默认的多级 workspace 路径在父目录不存在时正常创建。 +- 保留最终目标目录的原子占位语义:目标已存在时仍返回 `TargetExists`。 +- 不改变现有 Git worktree 创建、验证、清理和 UI 流程。 + +## 方案 + +在 `TargetDirectoryClaim::acquire` 中先确保目标的父目录存在,再使用 `create_dir` 创建最终目标目录: + +1. 对 `path.parent()` 调用 `create_dir_all`。 +2. 对 `path` 调用现有的 `create_dir`,继续区分 `AlreadyExists` 和其他 claim 错误。 +3. 成功后继续 canonicalize 目标路径并返回 claim。 + +父目录创建属于路径准备,不改变目标目录的 claim 竞争行为。已有的失败清理只负责删除由 claim 创建的最终空目录,因此不扩展为删除父目录,避免误删可能由其他流程共享的目录。 + +## 错误处理 + +- 父目录创建失败时直接返回 `TargetClaimFailed`,错误路径为原始目标路径,保留底层 `io::Error`。 +- 最终目录已存在时仍返回 `TargetExists`。 +- 最终目录创建成功后,后续 Git 操作和现有清理逻辑保持不变。 + +## 测试 + +在 `app/src/project_organization/git_tests.rs` 增加回归测试,使用临时目录下不存在的嵌套目标路径,分别验证: + +- 从 remote ref 创建 worktree 会创建所有父目录并成功完成。 +- 从 local branch 创建 worktree 会创建所有父目录并成功完成。 + +测试同时验证目标目录存在且 Git worktree 已注册,覆盖用户点击确认时使用的默认路径形态。 diff --git a/docs/superpowers/specs/2026-07-20-workspace-terminal-activity-design.md b/docs/superpowers/specs/2026-07-20-workspace-terminal-activity-design.md new file mode 100644 index 00000000000..4670f9465b6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-workspace-terminal-activity-design.md @@ -0,0 +1,218 @@ +# Workspace Terminal Activity Indicator 设计 + +## 背景 + +用户希望 terminal tab 的运行状态能反馈到 workspace 层级。例如某个 terminal 正在执行长期任务时,workspace 名称旁展示一个现代化、极客风的运行状态,帮助用户在多个窗口之间快速知道当前 workspace 下有任务在跑。 + +当前 workspace 名称旁已有 tab 数量统计,但视觉表现较粗糙。本设计保留“数字表示 workspace tab 数量”的语义,替换旧 UI;terminal long-running 状态只控制动画和发光是否启用。 + +现有代码已经具备 terminal 长任务状态链路: + +- `TerminalViewState::LongRunning` 表示 terminal 当前有长任务。 +- `TerminalView::is_long_running()` 能读取当前 terminal 是否长任务中。 +- `TerminalViewStateChanged` 会从 `TerminalView` 上抛到 `PaneGroup`,再触发 `Workspace` 刷新。 +- `SessionNavigationData::all_sessions(ctx)` 与 `RunningSessionSummary` 已用于跨窗口 running session 汇总。 + +本设计复用这些能力,不新增 PTY 输出监听,不轮询 terminal 输出。 + +## 目标 + +- 在 workspace 名称旁用新的 C3 Neon Capsule 展示 workspace tab 数量。 +- 移除 workspace 名称旁现有 tab 数量统计的旧 UI,保留其“tab count”数值语义。 +- 为选中状态的 workspace 外围增加现代化、极客风的光影效果。 +- 当当前 workspace/团队下任意窗口存在 long-running terminal 时,启用 spinner 动画、边框高亮和弱辉光。 +- 使用 C3 Neon Capsule 视觉方向:圆角胶囊、弱青绿色辉光、细旋转状态环、数量 badge。 +- 只做纯展示,不提供点击、hover tooltip 或导航交互。 +- 空闲时继续展示 tab 数字,但不显示 spinner 动画、不启用高亮辉光。 +- 保持现有 tab indicator 语义不变:tab indicator 仍表示单 tab 状态,workspace chip 只表示 workspace 聚合状态。 + +## 非目标 + +- 不统计“最近几秒正在输出内容”的 PTY 活跃度。 +- 不改变 long-running command 的判定阈值。 +- 不引入新的 command lifecycle 状态。 +- 不新增 workspace 级任务管理面板或 running process 列表入口。 +- 不替换现有 tab、vertical tabs、pane header 的状态指示。 +- 不让数字表示 long-running terminal 数量。 + +## 用户确认的设计选择 + +- 位置:workspace 名称/标题 chip 旁。 +- 触发规则:使用现有 long-running command 状态。 +- 数字统计范围:沿用当前 workspace 的 tab count。 +- 动画统计范围:当前 workspace/团队下所有窗口的 long-running terminals。 +- 交互:纯展示,不点击、不 hover。 +- 视觉:C3 Neon Capsule,极客风但保持克制。 +- 数字语义:表示 workspace 中的 tab 数量,不表示 long-running terminal 数量。 +- Running 状态:只控制 spinner/辉光动画,空闲时数字仍显示。 +- 选中状态:workspace 容器外围增加独立光影,不复用 running 的 spinner/绿色脉冲。 +- 旧 UI:移除 workspace 名称旁现有 tab count 的丑旧渲染,替换为新的 C3 数字 badge。 + +## UI 设计 + +新的 tab count 状态出现在 workspace 名称旁: + +```text +Team Lab ⟳ 3 +``` + +渲染细节: + +- 胶囊高度约 24px,匹配标题栏控件高度。 +- 数字 badge 固定最小宽度,避免 1 位到 2 位数字造成明显跳动;该数字只表示 workspace tab 数量。 +- tab 数量 `1..=99` 显示精确值,超过 99 显示 `99+`。 +- 沿用现有 workspace tab count 的可见性规则,但旧 tab count UI 必须被新 badge 替换;不允许两个数字同时出现。 +- running 状态激活时:胶囊使用半透明深色底、低透明度青绿色边框、弱辉光,并显示细环 spinner 动画。 +- 空闲状态时:保留同一个数字 badge,去掉 spinner 动画,边框和背景降噪,不显示辉光。 +- 发光效果必须弱化,避免长期运行任务时干扰注意力。 + +选中状态光影: + +- 选中 workspace 的整体容器外围增加静态电蓝/冷白光影,表现为细描边、内侧高光和非常弱的外发光。 +- 选中光影必须落在 workspace 容器层,不落在数字 badge 层;数字 badge 仍只负责 tab count 与 running 动画。 +- 选中效果不旋转、不闪烁、不使用绿色脉冲,避免与 long-running 状态混淆。 +- long-running 效果使用 badge 内 spinner 和青绿色弱辉光;selected 效果使用容器外围静态电蓝/冷白边缘光。 +- 当 workspace 同时 selected 且 has running terminal 时,两层效果同时存在:容器外围显示 selected 光影,数字 badge 显示 spinner/青绿色 running 状态。 +- 未选中但 has running terminal 的 workspace 不显示 selected 外围光影,只显示数字 badge 的 running 动画。 + +若标题栏空间不足: + +- 保留 workspace 名称的现有截断策略。 +- 活动状态保持固定尺寸约束。 +- 不挤压右侧固定 controls;必要时只截断 workspace 名称,不截断数字 badge。 + +## 数据流 + +tab count 数据: + +1. Workspace 复用现有 workspace tab 数量统计来源。 +2. 标题栏重新 render 时读取当前 workspace 的 tab count。 +3. tab count 通过新的 C3 badge 渲染;数字不读取 running terminal 数量。 + +selected 状态: + +1. Workspace 渲染时读取现有 active/selected workspace 状态。 +2. 当前 workspace 被选中时,对 workspace 容器启用 selected 光影样式。 +3. selected 光影不改变 tab count 数字,也不改变 running presence 计算。 + +running 动画状态: + +1. Terminal 命令进入 long-running 状态。 +2. `TerminalView` 设置 `TerminalViewState::LongRunning` 并发出 `TerminalViewStateChanged`。 +3. `TerminalPane` 将事件上抛为 `pane_group::Event::TerminalViewStateChanged`。 +4. `Workspace` 已有事件处理会调用 `update_active_session(ctx)` 与 `ctx.notify()`。 +5. Workspace 标题栏重新 render 时计算当前 workspace 是否存在 long-running terminal。 +6. 存在 long-running terminal 时启用 spinner/辉光;否则只显示静态 tab count badge。 + +命令完成后: + +1. `TerminalView` 回到 `TerminalViewState::Normal` 或 `Errored`。 +2. 同一事件链触发 workspace render。 +3. 动画状态关闭;tab count 数字保持显示。 + +## 聚合规则 + +聚合分为两个独立语义:tab count 与 running presence。 + +tab count: + +- 数字应复用当前 workspace 名称旁已有 tab 数量统计的数据来源。 +- 如果当前旧 UI 统计的是 repository workspace 的 active/inactive tabs,则新 UI 沿用同一统计范围。 +- 不重新用 terminal session 数量推导 tab count。 + +running presence: + +- 入口应优先复用 `SessionNavigationData::all_sessions(ctx)` 与 `RunningSessionSummary`。 +- 只输出布尔值 `has_running_terminal`,不把 running session 数量暴露给 UI 数字。 + +running presence 计入规则: + +- 只统计 `CommandContext::RunningCommand` 和 `CommandContext::RunningAIBlock`。 +- 不统计 read-only terminal。 +- 不统计 shared session viewer。 +- 同一 workspace/团队下跨窗口统计。 + +workspace 归属过滤: + +- 以 `UserWorkspaces::current_workspace_uid()` 作为当前 workspace/团队归属基准。 +- 如果现有 `SessionNavigationData` 无法直接表达 workspace uid,则新增明确的 workspace 归属字段或筛选入口。 +- 不通过窗口标题、workspace 名称字符串、tab title 或 command text 做推断。 +- 如果实现时无法通过结构化字段确定 session 的 workspace 归属,则停止实现并回到设计评审;不允许用“默认不计入”或字符串推断掩盖数据模型缺口。 + +## 组件边界 + +建议新增两个小边界: + +- `WorkspaceTerminalActivitySummary`:负责汇总 `tab_count` 与 `has_running_terminal`。 +- `WorkspaceVisualState`:负责描述 `is_selected` 与 `has_running_terminal` 组合后的视觉状态。 +- `render_workspace_activity_chip(...)`:负责 tab count badge UI 渲染,不持有业务状态。 +- `render_workspace_selection_frame(...)` 或等价 helper:负责 selected workspace 外围光影,不持有 running 业务状态。 + +职责约束: + +- 聚合逻辑不进入渲染 helper。 +- UI helper 不直接遍历 terminal sessions。 +- 不在 terminal 输出路径中更新 workspace 状态。 +- 数字必须复用旧 tab count 的数值含义;旧 tab count 的视觉渲染路径应被移除或替换。 +- `has_running_terminal` 只能控制动画/高亮,不影响数字值。 +- `is_selected` 只能控制 workspace 容器外围光影,不影响数字值,也不启停 spinner。 +- 不新增全局 mutable cache,除非实现时发现 render 阶段重复遍历造成明确性能问题。 + +## 边界情况 + +- workspace 有 3 个 tabs 且没有 long-running terminal:显示静态 `3`,不显示 spinner/辉光。 +- workspace 有 3 个 tabs 且存在 1 个或多个 long-running terminals:仍显示 `3`,并启用 spinner/辉光。 +- workspace 被选中但没有 long-running terminal:显示 selected 外围光影和静态 tab count badge。 +- workspace 被选中且存在 long-running terminal:selected 外围光影与 badge running 动画同时显示,颜色和动效保持区分。 +- workspace 未选中但存在 long-running terminal:不显示 selected 外围光影,只显示 badge running 动画。 +- 多窗口同一 workspace 有 running sessions:只影响动画状态,不改变 tab count 数字。 +- 多窗口不同 workspace 的 session:只计入当前 workspace/团队归属一致的 running presence。 +- read-only shared session viewer:不计入。 +- tab 数量超过 99:显示 `99+`。 +- terminal 从 long-running 变为 errored:关闭 workspace activity 动画;tab indicator 仍可展示错误状态。 +- vertical tabs 模式和 horizontal tabs 模式都应使用同一聚合规则。 + +## 测试计划 + +单元测试: + +- workspace tab count 为 3 时,summary 的 `tab_count` 为 3。 +- 存在 long-running session 时,summary 的 `has_running_terminal` 为 true,但 `tab_count` 不变。 +- read-only session 不会让 `has_running_terminal` 变为 true。 +- shared session viewer 不会让 `has_running_terminal` 变为 true。 +- `99+` 显示格式正确。 + +视图测试: + +- 没有 long-running terminal 时渲染静态 tab count badge。 +- 有 long-running terminal 时渲染同一个 tab count badge,并启用 spinner/辉光。 +- selected workspace 渲染外围光影。 +- selected workspace 的光影不启用 spinner。 +- selected 且 has running terminal 时,同时渲染外围 selected 光影和 badge running 动画。 +- tab count badge 显示 tab count,而不是 running terminal count。 +- 旧 tab count UI 不再渲染,避免同一位置出现两个数字。 +- horizontal tabs 与 vertical tabs 模式下渲染入口一致。 + +验证命令: + +- 运行相关 Rust 单测。 +- 至少运行 `cargo check`。 + +## 风险 + +- 当前 workspace/团队归属如果是全局 singleton,而不是每个窗口独立状态,跨窗口统计应明确表示“当前选中 workspace/团队”的全局运行态。 +- C3 视觉存在感较强,实现时必须使用低透明度边框和弱辉光,避免标题栏变成常亮状态灯。 +- selected 光影和 running 光影如果使用相同颜色或相同动画,会造成语义混淆;实现时应固定 selected 为静态电蓝/冷白外围光,running 为 badge 内 spinner/青绿色弱辉光。 +- 标题栏挂载点必须先定位到现有 workspace 名称/tab count 渲染入口;若该入口分散在 horizontal tabs 与 vertical tabs 两条路径中,应先抽出共享渲染 helper,再移除旧 tab count 并接入 activity 状态,避免在大型 `Workspace::render_tab_bar_contents` 中堆叠重复逻辑。 +- 最大语义风险是把 running terminal 数量误接到数字 badge;实现时应通过类型命名区分 `tab_count` 与 `has_running_terminal`。 + +## 验收标准 + +- workspace 名称旁显示新的 C3 数字 badge,数字表示 workspace tab 数量。 +- 选中的 workspace 外围显示极客风静态光影效果。 +- 当同一 workspace/团队下任意窗口有 terminal long-running command 时,同一个 badge 启用 spinner/辉光。 +- selected 光影和 long-running badge 动画在颜色、位置和动效上有明确区分。 +- 所有 long-running command 结束后,spinner/辉光消失,tab count 数字保留。 +- workspace 名称旁不再显示旧 tab 数量统计 UI;新数字 badge 只代表 tab 数量。 +- 点击或 hover 胶囊没有新增交互。 +- 现有 tab indicator、pane header、running process warning 行为不变。 diff --git a/script/compile_icon b/script/compile_icon index 3022eb1247e..31a872f8297 100755 --- a/script/compile_icon +++ b/script/compile_icon @@ -51,6 +51,8 @@ if [[ ! -f "$BUNDLED_RESOURCES_DIR/Assets.car" ]]; then XCODE_VERSION=$(xcodebuild -version 2>/dev/null | head -1 || echo "unknown") echo "Warning: actool did not produce Assets.car which is required for the latest App icon format." >&2 echo "(Note that XCode version <26 does not support .icon bundles. Your version: $XCODE_VERSION)." >&2 + echo "Keeping the cargo-bundle generated icon because AppIcon is unavailable." >&2 + exit 0 fi # Update Info.plist to reference AppIcon instead of the old .icns diff --git a/specs/repository-workspaces/PRODUCT.md b/specs/repository-workspaces/PRODUCT.md new file mode 100644 index 00000000000..148e4901718 --- /dev/null +++ b/specs/repository-workspaces/PRODUCT.md @@ -0,0 +1,112 @@ +# Repository Workspaces 产品规格 + +## Summary + +为 Zap 增加本机项目组织能力,以 repository 作为一级组织单元,以独立 Git worktree workspace 作为二级组织单元。每个 workspace 管理自己完整的页签集合,并允许在同一 workspace 内运行多个独立终端及其他 PaneGroup 内容。 + +## Problem + +当前产品只通过 TabConfig 创建一次性 worktree 页签,缺少 repository、workspace 生命周期、稳定归属关系和多页签恢复能力。用户无法按仓库和工作空间管理并行开发任务,也无法安全地创建、切换和删除这些工作单元。 + +## Goals + +- 提供清晰的 repository → workspace 双层组织结构。 +- 保持每个 workspace 的 Git 目录和分支隔离。 +- 复用现有顶部 TabBar、PaneGroup、分屏和会话恢复体验。 +- 为创建、迁移和删除操作提供明确且保守的安全语义。 +- 首期仅保存本机状态,并通过 Dogfood Feature Flag 灰度。 + +## Non-goals + +- 跨设备同步 repository、workspace、worktree 或终端状态。 +- Pull Request workspace、远程主机 workspace、端口管理或 setup/teardown scripts。 +- 替换现有终端、PaneGroup 或分屏模型。 +- 在项目组织模式下同时显示 Vertical Tabs。 + +## Figma + +Figma: none provided. 交互和布局以本规格确认过程中批准的视觉方案为准。 + +## References + +- Superset Your First Workspace: https://docs.superset.sh/first-workspace +- Superset Workspaces: https://docs.superset.sh/workspaces +- Superset Terminal: https://docs.superset.sh/terminal-integration + +## Behavior + +1. 当 `RepositoryWorkspaces` Feature Flag 启用时,主窗口左侧显示 repository → workspace 双层树;右侧继续使用顶部现有 TabBar。项目组织模式不显示 Vertical Tabs,也不修改用户原有的 Vertical Tabs 设置值。 + +2. 当 Feature Flag 关闭时,用户继续看到原有项目和页签体验。关闭 Flag 不删除已经保存的 repository、workspace 或页签归属数据。 + +3. repository 树支持展开、折叠、选择、重命名、刷新、在文件管理器中打开和移除。repository 默认名称取目录名,重命名只改变显示名称,不重命名磁盘目录或 Git remote。 + +4. 用户可以通过选择本地目录添加 repository。所选目录必须是 Git 主工作目录;linked worktree、普通目录、缺失目录和不可访问目录均被拒绝,并显示具体原因。 + +5. 用户可以通过 Git URL 添加 repository。界面提供 Git URL 和 clone 目标路径,目标路径默认位于 `~/.warp/repositories/` 下,且允许在开始 clone 前修改。 + +6. clone 期间界面显示明确进度并阻止重复提交。clone 失败时保留用户输入并展示 Git 错误;产品只清理本次操作新建的、不含用户既有内容的目标目录。 + +7. 同一规范化本地路径只能添加一次。重复添加时选中已有 repository,而不是创建重复记录。 + +8. repository 下存在 workspace 时不能移除 repository,界面要求用户先处理这些 workspace。 + +9. 移除 repository 默认只删除 Zap 中的组织记录,不删除本地仓库。仅当 repository 由 Zap clone 时,界面额外提供“同时删除本地仓库目录”复选框,默认不选中。 + +10. 每个 repository 行提供创建 workspace 的明确入口。workspace 创建界面使用“从远端分支新建”和“关联本地分支”两个互斥模式。 + +11. “从远端分支新建”模式在打开时刷新远端引用,允许搜索和选择远端分支,并基于该远端分支创建新的本地分支和独立 worktree。 + +12. 远端模式允许用户输入新本地分支名,也允许启用自动生成。自动生成默认开启;生成名称必须在当前 repository 的本地分支和 worktree 中唯一。 + +13. “关联本地分支”模式只列出已有本地分支。若分支已在主仓库或其他 worktree 中检出,该分支不可创建 workspace,界面显示占用它的路径。 + +14. 每个 workspace 始终对应一个 repository、一个本地分支和一个独立 worktree 路径。同一 repository 下不能有两个 workspace 使用同一本地分支。 + +15. worktree 默认位于 `~/.warp/worktrees//-/`。默认目录名把分支中的路径分隔符和不适合文件名的字符转换为 `-`,并添加短 ID 保证唯一;用户可在创建前修改。目标路径已存在、不可写或位于冲突位置时阻止创建。 + +16. workspace 默认显示名称等于最终本地分支名。用户可以独立重命名 workspace;重命名不改变 Git 分支名。树行在辅助信息中始终显示真实分支名。 + +17. 用户在开始 Git 操作前可以取消创建。创建开始后界面进入不可重复提交的进度状态;完成或失败前不接受第二个创建请求。 + +18. workspace 只有在 worktree、数据库记录和首个终端页签均创建成功后才显示为可用。任一步骤失败时,界面显示原始可操作错误,并撤销本次操作创建的资源。 + +19. 新 workspace 创建成功后自动成为当前 workspace,并打开一个以其 worktree 为启动目录的终端页签。 + +20. 在 workspace 中创建的新页签自动归属当前 workspace。终端默认从 workspace 的 worktree 路径启动;页签仍可使用现有 PaneGroup、分屏、重命名、颜色和拖拽能力。 + +21. workspace 管理完整页签集合,而不只管理终端页签。AI、Notebook、代码或混合 PaneGroup 页签与终端页签使用相同归属和切换规则。 + +22. 切换 workspace 时,顶部 TabBar 只显示目标 workspace 的页签,并恢复该 workspace 在当前窗口上次活动的页签。其他 workspace 的终端和后台进程继续运行,不因切换而关闭或重启。 + +23. workspace 没有页签时显示空状态,并提供“新建终端”操作。空 workspace 仍然有效,不会被自动删除。 + +24. 每个窗口独立记住当前 workspace,以及每个 workspace 的活动页签。应用重启后恢复相同的窗口、workspace、页签顺序和活动位置。 + +25. 跨窗口拖动页签时保留其 workspace 归属;目标窗口切换到该 workspace 并激活被拖入的页签。 + +26. 左侧树底部提供“未归类页签”入口。非 Git 页签、位于主仓库工作目录的旧页签,以及无法可靠映射到 linked worktree 的页签进入该集合。 + +27. 首次启用功能时,Zap 自动把现有项目路径迁移为 repository,并根据 Git common directory 识别 linked worktree。位于同一 linked worktree 的现有页签归入同一个 workspace;迁移不得移动目录、切换分支或重启终端。 + +28. 自动迁移无法确定唯一归属时,页签保留在“未归类页签”,不得通过猜测建立错误关联。 + +29. 删除 workspace 前,Zap 检查 worktree 是否存在、是否有未提交或未跟踪改动,以及分支是否已经合并。任何安全检查失败时,不关闭页签、不终止终端、不修改 Git,也不删除记录。 + +30. workspace 有未提交或未跟踪改动时,删除操作被阻止。用户必须先自行处理改动;首期不提供丢弃改动的快捷方式。 + +31. 删除对话框默认选中“同时删除本地分支”。用户取消勾选时只移除 worktree 和 workspace 记录,保留本地分支。 + +32. 当“同时删除本地分支”已选中且分支未合并到其 upstream 或 repository 默认分支时,Zap 在任何破坏性操作发生前显示第二次强制删除确认。用户取消确认后不改变任何状态。 + +33. 删除确认通过后,Zap 关闭该 workspace 的全部页签和终端、移除 worktree、按选择安全或强制删除本地分支,最后移除 workspace 记录。 + +34. repository 主目录、workspace worktree 或 Git 分支在 Zap 外部被移动、删除或修改时,树行显示明确错误状态。用户可以重新定位 repository/worktree 或移除失效记录;Zap 不静默改用其他目录或分支。 + +35. clone、fetch、分支创建、worktree 创建和删除错误必须保留 Git stderr 中的关键原因,同时提供面向用户的操作上下文。错误不得被空列表、自动选择或无提示关闭所掩盖。 + +36. 长时间 Git 操作不阻塞窗口渲染。对应 repository/workspace 行显示进行中状态,并禁止会与当前操作冲突的重命名、删除或重复创建操作。 + +37. 双层树、创建弹窗和删除确认支持键盘焦点、方向键导航、Enter 确认和 Escape 取消。图标按钮提供可访问名称或 Tooltip,文本和状态在浅色、深色主题下均使用现有主题色。 + +38. repository 和 workspace 的路径、分支和页签归属仅在本机持久化,不上传到云端,也不在其他设备自动创建或恢复。 diff --git a/specs/repository-workspaces/TECH.md b/specs/repository-workspaces/TECH.md new file mode 100644 index 00000000000..35af6b6cf93 --- /dev/null +++ b/specs/repository-workspaces/TECH.md @@ -0,0 +1,237 @@ +# Repository Workspaces 技术规格 + +## Context + +本规格实现 [PRODUCT.md](./PRODUCT.md) 中的 repository → workspace 项目组织行为。 + +当前代码已有部分可复用能力,但缺少稳定领域模型: + +- `app/src/projects.rs:21` 的 `ProjectManagementModel` 只按路径保存最近项目,无法表达 repository 元数据、workspace 或页签归属。 +- `app/src/tab_configs/new_worktree_modal.rs:93` 提供一次性 worktree 创建表单;`app/src/tab_configs/tab_config.rs:417` 通过 TabConfig 生成 `git worktree` shell 命令,无法管理实例生命周期或多个页签。 +- `app/src/tab_configs/repo_picker.rs:60` 在旧 `PersistedWorkspace` 下线后没有真实 repository 数据源。 +- `app/src/workspace/view.rs:855` 的窗口根直接持有 `tabs: Vec` 和单个 `active_tab_index`,多数 Tab/PaneGroup API 假设该向量就是当前可见集合。 +- `app/src/workspace/view.rs:9665` 从当前可见 tabs 生成 `WindowSnapshot`;保存逻辑在 `app/src/persistence/sqlite.rs:1090` 后重建 windows/tabs 行,因此不能用长期稳定的外部记录引用临时 tab 主键。 +- `crates/persistence/src/model.rs:312` 与 `crates/persistence/src/schema.rs:398` 的 tabs 目前只保存窗口、标题和颜色。 +- Feature Flag 定义及通道列表位于 `crates/warp_features/src/lib.rs:9` 与 `crates/warp_features/src/lib.rs:710`。 +- 所有新 Git 子进程必须使用 `crates/command/src/lib.rs:1` 提供的跨平台命令封装。 + +`app/src/workspaces/` 表示云端团队 workspace,与本功能不是同一领域。新内部类型使用 `RepositoryWorkspace`,避免与现有 `workspaces::Workspace` 和窗口根 `workspace::Workspace` 混淆。 + +## Proposed changes + +### 1. Feature Flag 与模块边界 + +在 `crates/warp_features/src/lib.rs` 新增 `FeatureFlag::RepositoryWorkspaces`,并加入 `DOGFOOD_FLAGS`,不加入 Preview 或 Release 列表。 + +新增 `app/src/project_organization/`,按职责拆分: + +- `domain.rs`: `RepositoryId`、`RepositoryWorkspaceId`、`Repository`、`RepositoryWorkspace` 和结构化错误。 +- `model.rs`: repository/workspace 集合、活动操作状态、CRUD 事件和持久化事件桥接。 +- `git.rs`: repository 校验、clone、fetch、ref 解析、worktree 创建、状态检查和删除。 +- `migration.rs`: 旧 projects 与现有 Tab 快照的首次迁移和启动一致性检查。 +- `view/project_tree.rs`: 左侧双层树和空态/错误态。 +- `view/add_repository_modal.rs`: 本地目录与 Git URL 两种添加流程。 +- `view/create_workspace_modal.rs`: 远端基线新建分支与本地分支关联流程。 +- `view/delete_workspace_dialog.rs`: 安全删除、复选框和二次确认。 + +领域模型只依赖持久化事件和 Git 服务,不持有 TerminalModel。UI 通过现有 Workspace/PaneGroup API 创建或关闭页签。 + +### 2. 持久化模型与迁移 + +在 `crates/persistence/migrations/` 新增 migration,并由 Diesel 重新生成 `crates/persistence/src/schema.rs`。不得手改生成后的 schema 作为迁移替代。 + +新增表: + +```sql +CREATE TABLE repositories ( + id TEXT PRIMARY KEY NOT NULL, + display_name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + remote_url TEXT, + source TEXT NOT NULL CHECK (source IN ('local', 'cloned')), + created_at TIMESTAMP NOT NULL, + last_opened_at TIMESTAMP NOT NULL +); + +CREATE TABLE repository_workspaces ( + id TEXT PRIMARY KEY NOT NULL, + repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT, + display_name TEXT NOT NULL, + branch TEXT NOT NULL, + worktree_path TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + last_opened_at TIMESTAMP NOT NULL, + UNIQUE(repository_id, branch) +); + +CREATE TABLE repository_workspace_window_states ( + window_id INTEGER NOT NULL REFERENCES windows(id) ON DELETE CASCADE, + repository_workspace_id TEXT NOT NULL REFERENCES repository_workspaces(id) ON DELETE CASCADE, + active_tab_index INTEGER NOT NULL, + PRIMARY KEY(window_id, repository_workspace_id) +); +``` + +扩展现有表: + +- `tabs.repository_workspace_id TEXT NULL REFERENCES repository_workspaces(id) ON DELETE SET NULL` +- `windows.active_repository_workspace_id TEXT NULL REFERENCES repository_workspaces(id) ON DELETE SET NULL` + +`windows.active_tab_index` 继续保存当前活动集合的索引,并兼容未归类集合。`repository_workspace_window_states` 保存同一窗口中其他真实 workspace 的活动索引;未归类集合不创建关联行。 + +迁移分两阶段执行,保证每个提交都能独立构建和回滚。第一条 migration 创建新表并把旧 `projects` 路径复制到 `repositories`,暂时保留旧表;默认显示名在 Rust 首次加载时规范化为目录名,`source = 'local'`。代码消费者全部切换到新模型后,第二条 migration 删除旧 `projects` 表;该 migration 的 down 方向从 repositories 回填旧路径与时间戳。 + +### 3. Repository 与 Git 服务 + +Git 服务使用 `command::r#async::Command` 或 `command::blocking::Command`,所有参数单独传递。禁止通过 shell 拼接路径、URL、分支名或 ref。 + +repository 校验流程: + +1. 规范化并 canonicalize 路径。 +2. 使用 `git -C rev-parse --show-toplevel` 验证工作树根。 +3. 比较绝对 `--git-dir` 与 `--git-common-dir`,拒绝 linked worktree;保留现有 repository metadata 能力作为 UI 状态来源。 +4. 读取 remote URL 和默认分支;失败返回结构化错误,不降级为普通目录。 + +Git URL clone 默认路径由 URL 的 repository 名生成,并在冲突时要求用户修改。clone 失败只删除本次调用创建的目录;调用前已存在的路径永不自动清理。 + +默认 worktree 目录名由新分支名生成安全 slug:将 `/`、`\\`、空白和非文件名安全字符折叠为 `-`,去除首尾分隔符,并追加 workspace UUID 的前 8 位。最终路径为 `~/.warp/worktrees//-/`,因此分支名不会创建意外的嵌套目录,重名 slug 也不会冲突。 + +分支列表使用完整 refname 分类:`refs/heads/` 与 `refs/remotes//`。不得用字符串删除 `origin/` 前缀推断 ref 类型。创建弹窗打开时执行一次异步 `fetch --prune --quiet --no-tags`,然后用 `for-each-ref` 构造可搜索列表。 + +远端模式: + +1. 验证选中的完整 remote ref 存在。 +2. 验证新本地分支名合法且 `refs/heads/` 不存在。 +3. 原子创建最终目标目录并保留其 canonical path,创建失败时报告占用或 I/O 错误。 +4. 执行唯一一条 `git worktree add --no-track -b ` 命令;成功后验证直接 OID 和新分支不带 upstream。 + +本地模式: + +1. 验证 `refs/heads/` 存在。 +2. 解析 `git worktree list --porcelain`,若分支已检出则返回占用路径。 +3. 与远端模式相同地原子创建最终目标目录,再执行 `git worktree add refs/heads/`。 + +远端与本地创建都只会清理本次原子 claim 的、未注册且为空的目录。远端 `worktree add -b` 失败时绝不自动删除分支,因为无法安全断定该分支由本次调用创建。 + +### 4. 创建与补偿事务 + +`ProjectOrganizationModel` 为每个 repository 维护至多一个冲突操作状态,阻止并发 clone/fetch/create/delete。 + +创建顺序: + +1. 完成 repository、ref、分支名、路径和重复 workspace 的全部只读校验。 +2. 创建 worktree。 +3. 在 SQLite transaction 中写入 `repository_workspaces`。 +4. 将新 workspace 设为窗口活动 workspace,并通过 Workspace API 创建首个终端页签。 + +若步骤 3 或 4 失败,补偿顺序为删除新记录、移除本次创建的 worktree、删除本次创建的新分支。补偿失败时保留结构化错误并在启动一致性检查中显示残留状态;不得把失败的 workspace 标记为 ready。 + +### 5. Workspace 页签集合 + +保持 `app/src/workspace/view.rs` 中现有 `tabs` 和 `active_tab_index` 表示当前活动集合,新增: + +```rust +struct RepositoryWorkspaceTabState { + tabs: Vec, + active_tab_index: usize, +} + +inactive_repository_workspace_tabs: + HashMap, RepositoryWorkspaceTabState> +active_repository_workspace_id: Option +``` + +`None` 表示未归类页签。切换时把当前 `tabs`/`active_tab_index` 与目标状态整体交换,因此现有基于 tab index 的渲染、PaneGroup、焦点和会话 API继续只处理活动集合。 + +新增统一遍历器供以下跨集合行为使用: + +- WindowSnapshot 保存。 +- 应用退出和窗口关闭时的终端清理。 +- 跨窗口拖拽、全局会话定位和需要覆盖后台页签的管理操作。 + +新建页签读取 `active_repository_workspace_id`,为 TabData/TabSnapshot 写入归属,并将终端启动目录设为 workspace worktree。跨窗口拖拽在 `TransferredTab` 中携带 workspace id,目标窗口激活相同 workspace 后插入。 + +`WindowSnapshot` 扩展为包含各 workspace 的有序 TabSnapshot 集合和活动索引。SQLite 保存时扁平写入 tabs 行,并直接写 `repository_workspace_id`;恢复时按该字段重新分组。不得建立引用重建 tab id 的长期绑定表。 + +### 6. 左侧树与弹窗 + +项目组织面板复用现有可调整宽度的左侧区域。Flag 启用时不渲染 Vertical Tabs,但不修改 `TabSettings::use_vertical_tabs` 的持久值;Flag 关闭后用户原设置恢复生效。 + +UI 遵循 `warp-ui-guidelines`:按钮使用现有 ActionButton/Button 主题,颜色来自 Appearance/Theme,图标按钮具有 Tooltip,不新增功能专用按钮主题。 + +树行显示: + +- repository: 展开状态、显示名称、进行中/错误状态、添加 workspace 和更多菜单。 +- workspace: 显示名称、真实分支、页签数量、活动/错误状态。 +- 底部固定“未归类页签”入口和数量。 + +创建弹窗使用 segmented control 切换两种模式。远端模式展示 remote branch、新分支名、自动生成、workspace 名称和 worktree 路径;本地模式展示 local branch、workspace 名称和路径。切换模式时清除不属于目标模式的选择,避免提交陈旧 ref。 + +### 7. 删除与外部状态变化 + +删除前置检查只生成给 UI 的建议快照;真正的删除校验在引用锁内执行: + +1. workspace 记录、worktree 路径和目标分支仍相互一致。 +2. `git status --porcelain` 为空;未跟踪文件同样阻止删除。 +3. 若选择删除分支,判断该分支是否合并到已配置 upstream;没有 upstream 时判断是否合并到 repository 默认分支。 + +删除分支时,服务通过 prepared `git update-ref --stdin` transaction 锁定待删 branch 和非强制删除的 merge target。transaction 只队列 merge target 的 `verify` 与 branch 的 `delete `,不会对同一 branch 同时队列 `verify` 和 `delete`。持锁后重新检查 worktree 注册路径、branch、dirty 状态、目标选择、目标 OID 与合并关系;强制删除不读取或锁定远端 merge target。 + +未合并状态在关闭终端或移除 worktree前触发二次确认。确认完成后依次关闭所属页签、移除 worktree、提交已 prepare 的 branch 删除 transaction,最后删除数据库记录。worktree remove 失败必须 abort transaction;若 worktree 已移除但 transaction commit 失败,服务返回带路径、branch、OID 和辅助 inspection 的明确部分状态,而不是推断或补偿删除 branch。 + +启动一致性检查验证 repository 路径、worktree 路径和 branch/ref。失效记录保留在模型中并带错误状态,UI 提供重新定位或移除记录;不自动选择其他路径或分支。 + +### 8. 旧数据迁移 + +首次加载新模型时: + +1. 迁移旧 projects 为 repositories。 +2. 遍历恢复后的 TabSnapshot,收集其中所有持久化 terminal cwd。 +3. 通过 Git common directory 聚合到 repository;只有 external git directory 指向 linked worktree 的页签才自动创建/关联 `RepositoryWorkspace`。 +4. 当一个页签中的所有可识别 Git cwd 都指向同一 linked worktree 时,该页签归入对应 workspace;同一 worktree 的页签共享一个 workspace,显示名默认取分支名。 +5. 页签同时指向多个 repository/worktree、位于主工作目录、非 Git、缺失路径或解析冲突时,归入未分类集合。 + +迁移不执行 checkout、worktree add、目录移动或终端重启,并使用幂等唯一约束保证重复启动不会重复创建记录。 + +## Testing and validation + +### Unit tests + +- `app/src/project_organization/git_tests.rs`: 覆盖 PRODUCT 行为 4-7、11-18、29-35,包括完整 ref 分类、remote 基线新分支、本地分支已检出、脏 worktree、未合并分支、路径与 shell 特殊字符。 +- `app/src/project_organization/model_tests.rs`: 覆盖行为 3、8-10、14、16、36、38,验证 CRUD、唯一约束映射和并发操作门禁。 +- `app/src/workspace/repository_workspace_tabs_tests.rs`: 覆盖行为 19-25,验证集合交换、活动索引、后台实体保留、新页签归属和跨窗口拖拽。 +- `app/src/project_organization/migration_tests.rs`: 覆盖行为 26-28、34,使用临时 repository/worktree 验证幂等迁移和不确定状态进入未分类。 +- persistence round-trip tests: 覆盖行为 24、27、38,验证多窗口、多 workspace、未分类 tabs 和 down/up migration。 +- modal/view tests: 覆盖行为 1-2、10-18、29-37,验证模式切换、焦点、禁用状态、默认复选框和二次确认。 + +所有逻辑改动遵循 TDD:先添加失败测试并确认失败原因,再实现最小代码使其通过。 + +### Integration tests + +使用 `crates/integration` Builder/TestStep 框架覆盖: + +1. 添加本地 repository,创建远端基线 workspace,打开三个独立终端,切换 workspace 后验证进程仍存活。 +2. 添加 Git URL repository,验证默认 clone 路径可修改并在重启后恢复。 +3. 关联未检出的本地分支;对已检出分支显示占用路径并拒绝创建。 +4. 恢复已有 linked worktree 页签和未分类页签。 +5. 删除干净 workspace,分别验证保留分支、删除已合并分支和二次确认强制删除未合并分支。 +6. 外部删除 worktree 后重启,验证错误状态而非静默 fallback。 + +### Commands + +开发过程中运行最小针对性测试。交付前至少运行: + +```bash +cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2 +cargo check +``` + +若全工作区 nextest 受已知环境或无关失败阻塞,必须记录完整失败并补充所有受影响 crate 和 integration 测试的针对性结果,不得宣称全量测试通过。 + +## Risks and mitigations + +- `Workspace` 大量代码直接访问当前 tabs 向量。通过活动/非活动集合整体交换保持其局部不变量,并为必须覆盖全部集合的少量生命周期路径增加统一遍历器,避免全文件改造成可见索引映射。 +- Git 与 SQLite 无法形成真正原子事务。创建采用补偿操作,删除先做完整 preflight,并通过启动一致性检查暴露残留状态。 +- 多窗口保存会重建 window/tab id。workspace 使用 UUID,Tab 归属直接随快照写入;窗口关联状态在同一保存 transaction 中使用新 window id 重建。 +- 自动迁移可能遇到主仓库页签或不完整 cwd。仅对 linked worktree 建立确定关联,其余进入未分类,优先避免错误迁移。 +- 项目组织面板与 Vertical Tabs 冲突。Flag 启用时只改变渲染选择,不覆盖用户设置,便于关闭 Flag 回滚。