diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 151a04d8bf3..862b007f52c 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2758,6 +2758,25 @@ impl AgentPanel { /// the panel and deletes its metadata row. Used by the sidebar when /// the user dismisses a parked draft. pub fn remove_thread(&mut self, id: ThreadId, window: &mut Window, cx: &mut Context) { + self.remove_thread_internal(id, true, window, cx); + } + + pub fn remove_thread_without_activating_draft( + &mut self, + id: ThreadId, + window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread_internal(id, false, window, cx); + } + + fn remove_thread_internal( + &mut self, + id: ThreadId, + activate_draft_after_remove: bool, + window: &mut Window, + cx: &mut Context, + ) { self.retained_threads.remove(&id); ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.delete(id, cx); @@ -2774,7 +2793,12 @@ impl AgentPanel { if self.active_thread_id(cx) == Some(id) { self.clear_overlay_state(); - self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + if activate_draft_after_remove { + self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + } else { + self.base_view = BaseView::Uninitialized; + self.refresh_base_view_subscriptions(window, cx); + } self.serialize(cx); cx.emit(AgentPanelEvent::ActiveViewChanged); cx.notify(); @@ -2845,6 +2869,37 @@ impl AgentPanel { } } + pub fn draft_prompt_blocks_if_in_memory( + &self, + id: ThreadId, + cx: &App, + ) -> Option> { + let cv = self + .retained_threads + .get(&id) + .or_else(|| { + self.draft_thread + .as_ref() + .filter(|draft| draft.read(cx).thread_id == id) + }) + .or_else(|| match &self.base_view { + BaseView::AgentThread { conversation_view } + if conversation_view.read(cx).thread_id == id => + { + Some(conversation_view) + } + _ => None, + })?; + let thread_view = cv.read(cx).root_thread_view()?; + let thread_view = thread_view.read(cx); + Some( + thread_view + .message_editor + .read(cx) + .draft_content_blocks_snapshot(cx), + ) + } + fn new_native_agent_thread_from_summary( &mut self, action: &NewNativeAgentThreadFromSummary, @@ -6681,6 +6736,184 @@ mod tests { } } + #[gpui::test] + async fn test_draft_prompt_blocks_use_current_editor_snapshot(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + let _stub_connection = + crate::test_support::set_stub_agent_connection(StubAgentConnection::new()); + panel.update_in(cx, |panel, window, cx| { + panel.selected_agent = Agent::Stub; + panel.activate_draft(true, AgentThreadSource::AgentPanel, window, cx); + }); + cx.run_until_parked(); + + let thread_id = active_thread_id(&panel, cx); + let thread = panel.read_with(cx, |panel, cx| { + panel + .active_agent_thread(cx) + .expect("draft thread should be active") + }); + let message_editor = panel.read_with(cx, |panel, cx| { + panel + .active_thread_view(cx) + .expect("draft thread view should be active") + .read(cx) + .message_editor + .clone() + }); + + thread.update(cx, |thread, cx| { + thread.set_draft_prompt( + Some(vec![acp::ContentBlock::Text(acp::TextContent::new( + "stale prompt", + ))]), + cx, + ); + }); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("fresh prompt", window, cx); + }); + let blocks = panel.read_with(cx, |panel, cx| { + panel + .draft_prompt_blocks_if_in_memory(thread_id, cx) + .expect("draft should be in memory") + }); + assert_eq!(blocks.len(), 1); + assert_eq!(expect_text_block(&blocks[0]), "fresh prompt"); + + thread.update(cx, |thread, cx| { + thread.set_draft_prompt( + Some(vec![acp::ContentBlock::Text(acp::TextContent::new( + "stale prompt after clear", + ))]), + cx, + ); + }); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("", window, cx); + }); + let blocks = panel.read_with(cx, |panel, cx| { + panel + .draft_prompt_blocks_if_in_memory(thread_id, cx) + .expect("draft should be in memory") + }); + assert!( + blocks.is_empty(), + "cleared editor snapshot should override stale saved draft prompt" + ); + } + + #[gpui::test] + async fn test_draft_has_user_content_checks_all_live_copies(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project_a", json!({ "file.txt": "" })) + .await; + fs.insert_tree("/project_b", json!({ "file.txt": "" })) + .await; + let project_a = Project::test(fs.clone(), [Path::new("/project_a")], cx).await; + let project_b = Project::test(fs.clone(), [Path::new("/project_b")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let workspace_a = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + let _stub_connection = + crate::test_support::set_stub_agent_connection(StubAgentConnection::new()); + panel_a.update_in(cx, |panel, window, cx| { + panel.selected_agent = Agent::Stub; + panel.activate_draft(true, AgentThreadSource::AgentPanel, window, cx); + }); + cx.run_until_parked(); + let thread_id = active_thread_id(&panel_a, cx); + + panel_b.update_in(cx, |panel, window, cx| { + panel.load_agent_thread( + Agent::Stub, + thread_id, + Some(PathList::new(&[PathBuf::from("/project_b")])), + None, + false, + AgentThreadSource::AgentPanel, + window, + cx, + ); + }); + cx.run_until_parked(); + + crate::test_support::type_draft_prompt(&panel_b, "content in second panel", cx); + let panel_a_blocks = panel_a.read_with(cx, |panel, cx| { + panel + .draft_prompt_blocks_if_in_memory(thread_id, cx) + .expect("draft should be live in first panel") + }); + assert!( + panel_a_blocks.is_empty(), + "first live draft copy should be empty" + ); + + let has_user_content = cx.update(|_, cx| { + crate::draft_prompt_store::draft_has_user_content( + thread_id, + [&workspace_a, &workspace_b], + cx, + ) + }); + assert!( + has_user_content, + "a later live draft copy with content should keep the draft" + ); + } + #[test] fn test_build_conflict_resolution_prompt_single_conflict() { let conflicts = vec![ConflictContent { diff --git a/crates/agent_ui/src/draft_prompt_store.rs b/crates/agent_ui/src/draft_prompt_store.rs index 34d17d49994..c0b45ed7589 100644 --- a/crates/agent_ui/src/draft_prompt_store.rs +++ b/crates/agent_ui/src/draft_prompt_store.rs @@ -29,7 +29,7 @@ pub fn read(thread_id: ThreadId, cx: &App) -> Option> { let kvp = KeyValueStore::global(cx); let raw = kvp .scoped(NAMESPACE) - .read(&thread_id_key(thread_id)) + .read(&thread_id.to_key_string()) .log_err() .flatten()?; serde_json::from_str(&raw).log_err() @@ -41,7 +41,7 @@ pub fn write( cx: &App, ) -> Task> { let kvp = KeyValueStore::global(cx); - let key = thread_id_key(thread_id); + let key = thread_id.to_key_string(); let payload = match serde_json::to_string(prompt).context("serializing draft prompt") { Ok(payload) => payload, Err(err) => return Task::ready(Err(err)), @@ -51,12 +51,43 @@ pub fn write( pub fn delete(thread_id: ThreadId, cx: &App) -> Task> { let kvp = KeyValueStore::global(cx); - let key = thread_id_key(thread_id); + let key = thread_id.to_key_string(); cx.background_spawn(async move { kvp.scoped(NAMESPACE).delete(key).await }) } -fn thread_id_key(thread_id: ThreadId) -> String { - thread_id.to_key_string() +pub fn draft_has_user_content<'a>( + thread_id: ThreadId, + workspaces: impl IntoIterator>, + cx: &App, +) -> bool { + let mut found_live_copy = false; + for blocks in workspaces + .into_iter() + .filter_map(|workspace| workspace.read(cx).panel::(cx)) + .filter_map(|panel| { + panel + .read(cx) + .draft_prompt_blocks_if_in_memory(thread_id, cx) + }) + { + found_live_copy = true; + if blocks_have_user_content(&blocks) { + return true; + } + } + + if found_live_copy { + false + } else { + read(thread_id, cx).is_some_and(|blocks| blocks_have_user_content(&blocks)) + } +} + +fn blocks_have_user_content(blocks: &[acp::ContentBlock]) -> bool { + blocks.iter().any(|block| match block { + acp::ContentBlock::Text(text) => !text.text.trim().is_empty(), + _ => true, + }) } /// Rewrites `[@Something](scheme://...)` mention links as `@Something` so the diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 594b0d00b83..f3bbdaa0c9e 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -347,6 +347,20 @@ impl ThreadMetadata { pub fn main_worktree_paths(&self) -> &PathList { self.worktree_paths.main_worktree_path_list() } + + pub fn references_folder_path(&self, path: &Path) -> bool { + self.folder_paths() + .paths() + .iter() + .any(|folder_path| folder_path.as_path() == path) + } + + pub fn matches_remote_connection( + &self, + remote_connection: Option<&RemoteConnectionOptions>, + ) -> bool { + same_remote_connection_identity(self.remote_connection.as_ref(), remote_connection) + } } /// Derives worktree display info from a thread's stored path list. @@ -615,9 +629,7 @@ impl ThreadMetadataStore { .flatten() .filter_map(|s| self.threads.get(s)) .filter(|s| !s.archived) - .filter(move |s| { - same_remote_connection_identity(s.remote_connection.as_ref(), remote_connection) - }) + .filter(move |s| s.matches_remote_connection(remote_connection)) } /// Returns threads whose `main_worktree_paths` matches the given path list @@ -639,9 +651,7 @@ impl ThreadMetadataStore { .flatten() .filter_map(|s| self.threads.get(s)) .filter(|s| !s.archived) - .filter(move |s| { - same_remote_connection_identity(s.remote_connection.as_ref(), remote_connection) - }) + .filter(move |s| s.matches_remote_connection(remote_connection)) } fn reload(&mut self, cx: &mut Context) -> Shared> { @@ -858,19 +868,28 @@ impl ThreadMetadataStore { thread_id: Option, path: &Path, remote_connection: Option<&RemoteConnectionOptions>, + ) -> bool { + self.path_is_referenced_by_unarchived_threads_matching( + thread_id, + path, + remote_connection, + |_| true, + ) + } + + pub fn path_is_referenced_by_unarchived_threads_matching( + &self, + thread_id: Option, + path: &Path, + remote_connection: Option<&RemoteConnectionOptions>, + matches: impl Fn(&ThreadMetadata) -> bool, ) -> bool { self.entries().any(|thread| { Some(thread.thread_id) != thread_id && !thread.archived - && same_remote_connection_identity( - thread.remote_connection.as_ref(), - remote_connection, - ) - && thread - .folder_paths() - .paths() - .iter() - .any(|other_path| other_path.as_path() == path) + && thread.matches_remote_connection(remote_connection) + && thread.references_folder_path(path) + && matches(thread) }) } @@ -1123,6 +1142,26 @@ impl ThreadMetadataStore { cx.notify(); } + pub fn unarchived_draft_ids_matching( + &self, + matches: impl Fn(&ThreadMetadata) -> bool, + ) -> Vec { + self.entries() + .filter(|thread| thread.is_draft() && !thread.archived && matches(thread)) + .map(|thread| thread.thread_id) + .collect() + } + + pub fn delete_all( + &mut self, + thread_ids: impl IntoIterator, + cx: &mut Context, + ) { + for thread_id in thread_ids { + self.delete(thread_id, cx); + } + } + fn new(db: ThreadMetadataDb, cx: &mut Context) -> Self { let weak_store = cx.weak_entity(); diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs index b510da96b4e..f3d54657d49 100644 --- a/crates/agent_ui/src/thread_worktree_archive.rs +++ b/crates/agent_ui/src/thread_worktree_archive.rs @@ -831,6 +831,27 @@ pub fn all_open_workspaces(cx: &App) -> Vec> { .collect() } +pub fn workspaces_for_archive( + multi_workspace: Option<&Entity>, + cx: &App, +) -> Vec> { + let mut workspaces = multi_workspace + .map(|multi_workspace| { + multi_workspace + .read(cx) + .workspaces() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + for workspace in all_open_workspaces(cx) { + if !workspaces.contains(&workspace) { + workspaces.push(workspace); + } + } + workspaces +} + fn current_app_state(cx: &mut AsyncApp) -> Option> { cx.update(|cx| { all_open_workspaces(cx) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 7fcaf38a3f5..1dfec54ebaa 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -475,10 +475,6 @@ fn linked_worktree_path_lists_for_workspaces( .collect() } -fn workspace_has_terminal_metadata(workspace: &Entity, cx: &App) -> bool { - workspace_has_terminal_metadata_except(workspace, None, cx) -} - fn workspace_has_terminal_metadata_except( workspace: &Entity, except_terminal_id: Option, @@ -3657,6 +3653,7 @@ impl Sidebar { } fn should_load_closed_workspace_for_archive( + &self, folder_paths: &PathList, project_group_key: &ProjectGroupKey, remote_connection: Option<&RemoteConnectionOptions>, @@ -3668,13 +3665,17 @@ impl Sidebar { return false; } + let archive_workspaces = self.archive_workspaces(cx); let thread_store = ThreadMetadataStore::global(cx); let thread_store = thread_store.read(cx); if folder_paths.ordered_paths().any(|path| { - thread_store.path_is_referenced_by_unarchived_threads( + Self::path_is_referenced_by_unarchived_threads_for_archive( + &thread_store, except_thread_id, path, remote_connection, + &archive_workspaces, + cx, ) }) { return false; @@ -3692,6 +3693,208 @@ impl Sidebar { }) } + fn path_is_referenced_by_unarchived_threads_for_archive( + thread_store: &ThreadMetadataStore, + except_thread_id: Option, + path: &Path, + remote_connection: Option<&RemoteConnectionOptions>, + archive_workspaces: &[Entity], + cx: &App, + ) -> bool { + thread_store.path_is_referenced_by_unarchived_threads_matching( + except_thread_id, + path, + remote_connection, + |thread| Self::thread_blocks_worktree_archive(thread, archive_workspaces, cx), + ) + } + + fn archive_workspaces(&self, cx: &App) -> Vec> { + let multi_workspace = self.multi_workspace.upgrade(); + thread_worktree_archive::workspaces_for_archive(multi_workspace.as_ref(), cx) + } + + fn count_threads_blocking_worktree_archive( + &self, + path_list: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + cx: &App, + ) -> usize { + let archive_workspaces = self.archive_workspaces(cx); + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(path_list, remote_connection) + .filter(|thread| Some(thread.thread_id) != except_thread_id) + .filter(|thread| Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx)) + .count() + } + + fn roots_to_archive_for_paths( + &self, + folder_paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + except_terminal_id: Option, + cx: &App, + ) -> Vec { + let workspaces = self.archive_workspaces(cx); + folder_paths + .ordered_paths() + .filter_map(|path| { + thread_worktree_archive::build_root_plan(path, remote_connection, &workspaces, cx) + }) + .filter(|plan| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + !Self::path_is_referenced_by_unarchived_threads_for_archive( + &store, + except_thread_id, + plan.root_path.as_path(), + remote_connection, + &workspaces, + cx, + ) + }) + .filter(|root| { + TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { + !terminal_store.read(cx).path_is_referenced_by_terminal( + except_terminal_id, + root.root_path.as_path(), + remote_connection, + ) + }) + }) + .collect() + } + + fn linked_worktree_workspace_to_remove( + &self, + folder_paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + except_terminal_id: Option, + roots_to_archive: &[thread_worktree_archive::RootPlan], + cx: &App, + ) -> Option> { + if folder_paths.is_empty() { + return None; + } + + let remaining = self.count_threads_blocking_worktree_archive( + folder_paths, + remote_connection, + except_thread_id, + cx, + ); + + if remaining > 0 { + return None; + } + + let multi_workspace = self.multi_workspace.upgrade()?; + let workspace = + multi_workspace + .read(cx) + .workspace_for_paths(folder_paths, remote_connection, cx)?; + + if workspace_has_terminal_metadata_except(&workspace, except_terminal_id, cx) { + return None; + } + + if !roots_to_archive.is_empty() { + let archive_paths: HashSet<&Path> = roots_to_archive + .iter() + .map(|root| root.root_path.as_path()) + .collect(); + let project = workspace.read(cx).project().clone(); + let visible_worktree_paths = project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect::>(); + return (!visible_worktree_paths.is_empty() + && visible_worktree_paths + .iter() + .all(|path| archive_paths.contains(path.as_ref()))) + .then_some(workspace); + } + + let group_key = workspace.read(cx).project_group_key(cx); + (group_key.path_list() != folder_paths).then_some(workspace) + } + + fn delete_empty_drafts_for_archive_roots( + &self, + roots: &[thread_worktree_archive::RootPlan], + cx: &mut Context, + ) { + self.delete_empty_drafts_for_archive_targets( + roots + .iter() + .map(|root| (root.root_path.as_path(), root.remote_connection.as_ref())), + cx, + ); + } + + fn delete_empty_drafts_for_archive_paths( + &self, + paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + cx: &mut Context, + ) { + self.delete_empty_drafts_for_archive_targets( + paths + .ordered_paths() + .map(|path| (path.as_path(), remote_connection)), + cx, + ); + } + + fn delete_empty_drafts_for_archive_targets<'a>( + &self, + targets: impl IntoIterator)>, + cx: &mut Context, + ) { + let targets = targets.into_iter().collect::>(); + if targets.is_empty() { + return; + } + + let archive_workspaces = self.archive_workspaces(cx); + let draft_thread_ids = ThreadMetadataStore::global(cx) + .read(cx) + .unarchived_draft_ids_matching(|thread| { + targets.iter().any(|(path, remote_connection)| { + thread.matches_remote_connection(*remote_connection) + && thread.references_folder_path(path) + }) && !Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx) + }); + if draft_thread_ids.is_empty() { + return; + } + + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.delete_all(draft_thread_ids, cx); + }); + } + + fn thread_blocks_worktree_archive( + thread: &ThreadMetadata, + archive_workspaces: &[Entity], + cx: &App, + ) -> bool { + if !thread.is_draft() { + return true; + } + + agent_ui::draft_prompt_store::draft_has_user_content( + thread.thread_id, + archive_workspaces, + cx, + ) + } + async fn wait_for_archive_workspace_metadata( workspace: &Entity, cx: &mut gpui::AsyncApp, @@ -3821,7 +4024,7 @@ impl Sidebar { folder_paths, project_group_key, } = workspace - && Self::should_load_closed_workspace_for_archive( + && self.should_load_closed_workspace_for_archive( folder_paths, project_group_key, metadata.remote_connection.as_ref(), @@ -3859,83 +4062,22 @@ impl Sidebar { .and_then(|position| self.neighboring_activatable_entry(position)); let terminal_folder_paths = metadata.folder_paths().clone(); - let roots_to_archive = { - let mut workspaces = self - .multi_workspace - .upgrade() - .map(|multi_workspace| { - multi_workspace - .read(cx) - .workspaces() - .cloned() - .collect::>() - }) - .unwrap_or_default(); - for workspace in thread_worktree_archive::all_open_workspaces(cx) { - if !workspaces.contains(&workspace) { - workspaces.push(workspace); - } - } + let roots_to_archive = self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + None, + Some(terminal_id), + cx, + ); - metadata - .folder_paths() - .ordered_paths() - .filter_map(|path| { - thread_worktree_archive::build_root_plan( - path, - metadata.remote_connection.as_ref(), - &workspaces, - cx, - ) - }) - .filter(|plan| { - let store = ThreadMetadataStore::global(cx); - let store = store.read(cx); - !store.path_is_referenced_by_unarchived_threads( - None, - &plan.root_path, - metadata.remote_connection.as_ref(), - ) - }) - .filter(|root| { - TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { - !terminal_store.read(cx).path_is_referenced_by_terminal( - Some(terminal_id), - root.root_path.as_path(), - metadata.remote_connection.as_ref(), - ) - }) - }) - .collect::>() - }; - - let workspace_to_remove = if terminal_folder_paths.is_empty() { - None - } else { - let remaining = ThreadMetadataStore::global(cx) - .read(cx) - .entries_for_path(&terminal_folder_paths, metadata.remote_connection.as_ref()) - .count(); - - if remaining > 0 { - None - } else { - let workspace = self.multi_workspace.upgrade().and_then(|multi_workspace| { - multi_workspace - .read(cx) - .workspace_for_paths(&terminal_folder_paths, None, cx) - }); - - workspace.and_then(|workspace| { - if workspace_has_terminal_metadata_except(&workspace, Some(terminal_id), cx) { - return None; - } - - let group_key = workspace.read(cx).project_group_key(cx); - (group_key.path_list() != &terminal_folder_paths).then_some(workspace) - }) - } - }; + let workspace_to_remove = self.linked_worktree_workspace_to_remove( + &terminal_folder_paths, + metadata.remote_connection.as_ref(), + None, + Some(terminal_id), + &roots_to_archive, + cx, + ); let mut workspaces_to_remove: Vec> = workspace_to_remove.into_iter().collect(); @@ -4003,6 +4145,13 @@ impl Sidebar { } this.update_in(cx, |this, window, cx| { + if terminal_workspace_removed { + this.delete_empty_drafts_for_archive_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + cx, + ); + } // If the terminal's workspace has already been removed, // don't synthesize a fallback draft in the detached // AgentPanel. @@ -4193,33 +4342,44 @@ impl Sidebar { ) { let store = ThreadMetadataStore::global(cx); let metadata = store.read(cx).entry_by_session(session_id).cloned(); - let active_workspace = metadata.as_ref().and_then(|metadata| { + let metadata_thread_id = metadata.as_ref().map(|metadata| metadata.thread_id); + let thread_entry = self.contents.entries.iter().find_map(|entry| match entry { + ListEntry::Thread(thread) => metadata_thread_id + .map_or_else( + || thread.metadata.session_id.as_ref() == Some(session_id), + |thread_id| thread.metadata.thread_id == thread_id, + ) + .then(|| thread.clone()), + _ => None, + }); + let thread_id = metadata_thread_id.or_else(|| { + thread_entry + .as_ref() + .map(|thread| thread.metadata.thread_id) + }); + let active_workspace = thread_id.and_then(|thread_id| { self.active_entry.as_ref().and_then(|entry| { - if entry.is_active_thread(&metadata.thread_id) { + if entry.is_active_thread(&thread_id) { Some(entry.workspace().clone()) } else { None } }) }); - let thread_id = metadata.as_ref().map(|metadata| metadata.thread_id); let thread_folder_paths = metadata .as_ref() .map(|metadata| metadata.folder_paths().clone()) + .or_else(|| { + thread_entry + .as_ref() + .map(|thread| thread.metadata.folder_paths().clone()) + }) .or_else(|| { active_workspace .as_ref() .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx))) }); - let thread_entry_workspace = self.contents.entries.iter().find_map(|entry| match entry { - ListEntry::Thread(thread) => thread_id - .map_or_else( - || thread.metadata.session_id.as_ref() == Some(session_id), - |tid| thread.metadata.thread_id == tid, - ) - .then(|| thread.workspace.clone()), - _ => None, - }); + let thread_entry_workspace = thread_entry.map(|thread| thread.workspace); if let ( Some(metadata), @@ -4228,7 +4388,7 @@ impl Sidebar { project_group_key, }), ) = (metadata.as_ref(), thread_entry_workspace) - && Self::should_load_closed_workspace_for_archive( + && self.should_load_closed_workspace_for_archive( &folder_paths, &project_group_key, metadata.remote_connection.as_ref(), @@ -4255,52 +4415,13 @@ impl Sidebar { let roots_to_archive = metadata .as_ref() .map(|metadata| { - let mut workspaces = self - .multi_workspace - .upgrade() - .map(|multi_workspace| { - multi_workspace - .read(cx) - .workspaces() - .cloned() - .collect::>() - }) - .unwrap_or_default(); - for workspace in thread_worktree_archive::all_open_workspaces(cx) { - if !workspaces.contains(&workspace) { - workspaces.push(workspace); - } - } - metadata - .folder_paths() - .ordered_paths() - .filter_map(|path| { - thread_worktree_archive::build_root_plan( - path, - metadata.remote_connection.as_ref(), - &workspaces, - cx, - ) - }) - .filter(|plan| { - thread_id.map_or(true, |tid| { - !store.read(cx).path_is_referenced_by_unarchived_threads( - Some(tid), - &plan.root_path, - metadata.remote_connection.as_ref(), - ) - }) - }) - .filter(|root| { - TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { - !terminal_store.read(cx).path_is_referenced_by_terminal( - None, - root.root_path.as_path(), - metadata.remote_connection.as_ref(), - ) - }) - }) - .collect::>() + self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + thread_id, + None, + cx, + ) }) .unwrap_or_default(); @@ -4317,35 +4438,16 @@ impl Sidebar { // Check if archiving this thread would leave its worktree workspace // with no threads, requiring workspace removal. let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| { - if folder_paths.is_empty() { - return None; - } - let thread_remote_connection = metadata.as_ref().and_then(|m| m.remote_connection.as_ref()); - let remaining = ThreadMetadataStore::global(cx) - .read(cx) - .entries_for_path(folder_paths, thread_remote_connection) - .filter(|t| t.session_id.as_ref() != Some(session_id)) - .count(); - - if remaining > 0 { - return None; - } - - let multi_workspace = self.multi_workspace.upgrade()?; - let workspace = multi_workspace - .read(cx) - .workspace_for_paths(folder_paths, None, cx)?; - - if workspace_has_terminal_metadata(&workspace, cx) { - return None; - } - - let group_key = workspace.read(cx).project_group_key(cx); - let is_linked_worktree = group_key.path_list() != folder_paths; - - is_linked_worktree.then_some(workspace) + self.linked_worktree_workspace_to_remove( + folder_paths, + thread_remote_connection, + thread_id, + None, + &roots_to_archive, + cx, + ) }); // Also find workspaces for root plans that aren't covered by @@ -4407,6 +4509,9 @@ impl Sidebar { }); let thread_folder_paths = thread_folder_paths.clone(); + let thread_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { return anyhow::Ok(()); @@ -4418,6 +4523,13 @@ impl Sidebar { } this.update_in(cx, |this, window, cx| { + if let Some(thread_folder_paths) = thread_folder_paths.as_ref() { + this.delete_empty_drafts_for_archive_paths( + thread_folder_paths, + thread_remote_connection.as_ref(), + cx, + ); + } let in_flight = thread_id.and_then(|tid| { this.start_archive_worktree_task(tid, roots_to_archive, cx) }); @@ -4426,6 +4538,7 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + thread_remote_connection.as_ref(), in_flight, window, cx, @@ -4437,6 +4550,9 @@ impl Sidebar { } else if !close_item_tasks.is_empty() { let session_id = session_id.clone(); let thread_folder_paths = thread_folder_paths.clone(); + let thread_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { let result: anyhow::Result<()> = task.await; @@ -4452,6 +4568,7 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + thread_remote_connection.as_ref(), in_flight, window, cx, @@ -4468,6 +4585,9 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.as_ref()), in_flight, window, cx, @@ -4497,6 +4617,7 @@ impl Sidebar { thread_id: Option, neighbor: Option<&ActivatableEntry>, thread_folder_paths: Option<&PathList>, + thread_remote_connection: Option<&RemoteConnectionOptions>, in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>, window: &mut Window, cx: &mut Context, @@ -4521,11 +4642,10 @@ impl Sidebar { // archived thread from its workspace's panel so that switching // to that workspace later doesn't show a stale thread. if let Some(folder_paths) = thread_folder_paths { - if let Some(workspace) = self - .multi_workspace - .upgrade() - .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx)) - { + if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { + mw.read(cx) + .workspace_for_paths(folder_paths, thread_remote_connection, cx) + }) { if let Some(panel) = workspace.read(cx).panel::(cx) { let panel_shows_archived = panel .read(cx) @@ -4552,10 +4672,10 @@ impl Sidebar { // No neighbor or its workspace isn't open — just clear the // panel so the group is left empty. if let Some(folder_paths) = thread_folder_paths { - let workspace = self - .multi_workspace - .upgrade() - .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx)); + let workspace = self.multi_workspace.upgrade().and_then(|mw| { + mw.read(cx) + .workspace_for_paths(folder_paths, thread_remote_connection, cx) + }); if let Some(workspace) = workspace { if let Some(panel) = workspace.read(cx).panel::(cx) { panel.update(cx, |panel, cx| { @@ -4576,6 +4696,8 @@ impl Sidebar { return None; } + self.delete_empty_drafts_for_archive_roots(&roots, cx); + let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); let task = cx.spawn(async move |_this, cx| { match Self::archive_worktree_roots(roots, cancel_rx, cx).await { @@ -4610,6 +4732,8 @@ impl Sidebar { return; } + self.delete_empty_drafts_for_archive_roots(&roots, cx); + let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); cx.spawn(async move |_this, cx| { let outcome = Self::archive_worktree_roots(roots, cancel_rx, cx).await; @@ -4617,7 +4741,7 @@ impl Sidebar { match outcome { Ok(ArchiveWorktreeOutcome::Success | ArchiveWorktreeOutcome::Cancelled) => {} Err(error) => { - log::error!("Failed to archive worktree after closing terminal: {error:#}"); + log::error!("Failed to archive worktree after closing sidebar item: {error:#}"); } } }) @@ -4706,11 +4830,9 @@ impl Sidebar { AgentThreadStatus::Completed | AgentThreadStatus::Error => {} } if thread.is_draft { - if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace { - let workspace = workspace.clone(); - let draft_id = thread.metadata.thread_id; - self.remove_draft(draft_id, &workspace, window, cx); - } + let workspace = thread.workspace.clone(); + let draft_id = thread.metadata.thread_id; + self.remove_draft(draft_id, &workspace, window, cx); } else if let Some(session_id) = thread.metadata.session_id.clone() { self.archive_thread(&session_id, window, cx); } @@ -5263,9 +5385,12 @@ impl Sidebar { .on_click({ let thread_workspace = thread_workspace.clone(); cx.listener(move |this, _, window, cx| { - if let ThreadEntryWorkspace::Open(workspace) = &thread_workspace { - this.remove_draft(thread_id_for_actions, workspace, window, cx); - } + this.remove_draft( + thread_id_for_actions, + &thread_workspace, + window, + cx, + ); }) }), ) @@ -5460,30 +5585,301 @@ impl Sidebar { } } - /// Deletes a parked draft thread (its metadata row, any kvp-stored - /// draft prompt) and promotes a sibling in the same group, if any, to - /// the active entry. - fn remove_draft( + /// Closed linked-worktree drafts need an open workspace so archive root + /// planning can inspect repositories before deleting the worktree. + fn open_workspace_and_remove_draft( &mut self, draft_id: ThreadId, - workspace: &Entity, + folder_paths: PathList, + project_group_key: ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { - workspace.update(cx, |ws, cx| { - if let Some(panel) = ws.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.remove_thread(draft_id, window, cx); - }); - } - }); + let Some((open_task, modal_workspace)) = + self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) + else { + return; + }; + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + Self::wait_for_archive_workspace_metadata(&workspace, cx).await; + + this.update_in(cx, |this, window, cx| { + let workspace = ThreadEntryWorkspace::Open(workspace); + this.remove_draft(draft_id, &workspace, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn remove_draft( + &mut self, + draft_id: ThreadId, + workspace: &ThreadEntryWorkspace, + window: &mut Window, + cx: &mut Context, + ) { + let metadata = ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .cloned(); + + if let ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } = workspace + && self.should_load_closed_workspace_for_archive( + folder_paths, + project_group_key, + metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.as_ref()), + Some(draft_id), + None, + cx, + ) + { + self.open_workspace_and_remove_draft( + draft_id, + folder_paths.clone(), + project_group_key.clone(), + window, + cx, + ); + return; + } + + let draft_folder_paths = metadata + .as_ref() + .map(|metadata| metadata.folder_paths().clone()) + .or_else(|| match workspace { + ThreadEntryWorkspace::Open(workspace) => { + Some(PathList::new(&workspace.read(cx).root_paths(cx))) + } + ThreadEntryWorkspace::Closed { folder_paths, .. } => Some(folder_paths.clone()), + }); + let draft_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); + let roots_to_archive = metadata + .as_ref() + .map(|metadata| { + self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + Some(draft_id), + None, + cx, + ) + }) + .unwrap_or_default(); let was_active = self .active_entry .as_ref() - .is_some_and(|e| e.is_active_thread(&draft_id)); + .is_some_and(|entry| entry.is_active_thread(&draft_id)); + let neighbor = self + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .and_then(|position| self.neighboring_activatable_entry(position)); + + let workspace_to_remove = draft_folder_paths.as_ref().and_then(|folder_paths| { + self.linked_worktree_workspace_to_remove( + folder_paths, + draft_remote_connection.as_ref(), + Some(draft_id), + None, + &roots_to_archive, + cx, + ) + }); + let mut workspaces_to_remove: Vec> = + workspace_to_remove.into_iter().collect(); + let close_item_tasks = self.close_items_for_archived_worktrees( + &roots_to_archive, + &mut workspaces_to_remove, + window, + cx, + ); + + if !workspaces_to_remove.is_empty() { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let draft_workspace_removed = matches!( + workspace, + ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace) + ); + let (fallback_paths, project_group_key) = neighbor + .as_ref() + .map(|neighbor| neighbor.project_location(cx)) + .unwrap_or_else(|| { + workspaces_to_remove + .first() + .map(|workspace| { + let key = workspace.read(cx).project_group_key(cx); + (key.path_list().clone(), key) + }) + .unwrap_or_default() + }); + + let excluded = workspaces_to_remove.clone(); + let remove_task = multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.remove( + workspaces_to_remove, + move |this, window, cx| { + let active_workspace = this.workspace().clone(); + this.find_or_create_workspace( + fallback_paths, + project_group_key.host(), + Some(project_group_key), + |options, window, cx| { + connect_remote(active_workspace, options, window, cx) + }, + &excluded, + None, + OpenMode::Activate, + window, + cx, + ) + }, + window, + cx, + ) + }); + + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + if !remove_task.await? { + return anyhow::Ok(()); + } + + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + if draft_workspace_removed { + if let Some(draft_folder_paths) = draft_folder_paths.as_ref() { + this.delete_empty_drafts_for_archive_paths( + draft_folder_paths, + draft_remote_connection.as_ref(), + cx, + ); + } + } + this.remove_draft_entry( + draft_id, + &workspace, + was_active, + neighbor.as_ref(), + !draft_workspace_removed, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else if !close_item_tasks.is_empty() { + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + this.remove_draft_entry( + draft_id, + &workspace, + was_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + self.remove_draft_entry( + draft_id, + workspace, + was_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + } + } + + fn remove_draft_entry( + &mut self, + draft_id: ThreadId, + workspace: &ThreadEntryWorkspace, + was_active: bool, + neighbor: Option<&ActivatableEntry>, + activate_panel_draft: bool, + roots_to_archive: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let removed_from_panel = if let ThreadEntryWorkspace::Open(workspace) = workspace { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + if activate_panel_draft { + panel.remove_thread(draft_id, window, cx); + } else { + panel.remove_thread_without_activating_draft(draft_id, window, cx); + } + }); + true + } else { + false + } + }) + } else { + false + }; + + if !removed_from_panel { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.delete(draft_id, cx); + }); + } + + self.start_detached_archive_worktree_task(roots_to_archive, cx); + if was_active { self.active_entry = None; + if !activate_panel_draft { + if neighbor + .as_ref() + .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) + { + return; + } + self.sync_active_entry_from_active_workspace(cx); + } } self.update_entries(cx); diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 982f0c6cd24..c65eda086ab 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -454,6 +454,34 @@ fn save_thread_metadata_with_main_paths( cx.run_until_parked(); } +fn save_draft_metadata_with_main_paths( + title: Option, + folder_paths: PathList, + main_worktree_paths: PathList, + updated_at: DateTime, + cx: &mut TestAppContext, +) -> ThreadId { + let thread_id = ThreadId::new(); + let metadata = ThreadMetadata { + thread_id, + session_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), + title, + title_override: None, + updated_at, + created_at: None, + interacted_at: None, + worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(), + archived: false, + remote_connection: None, + }; + cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + cx.run_until_parked(); + thread_id +} + fn focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { sidebar.update_in(cx, |_, window, cx| { cx.focus_self(window); @@ -1884,6 +1912,8 @@ async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) }); let worktree_panel = add_agent_panel(&worktree_workspace, cx); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); let archived_session_id = acp::SessionId::new(Arc::from("archived-wt-thread")); save_thread_metadata( @@ -1916,6 +1946,19 @@ async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace &main_project, cx, ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); let terminal_id = worktree_panel .update_in(cx, |panel, window, cx| { @@ -1956,12 +1999,20 @@ async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace terminal_metadata_deleted, "terminal metadata should be deleted after close" ); - let unarchived_worktree_threads = cx.update(|_, cx| { - let worktree_path_list = - PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let empty_draft_metadata_deleted = cx.update(|_, cx| { ThreadMetadataStore::global(cx) .read(cx) - .entries_for_path(&worktree_path_list, None) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); + let unarchived_worktree_threads = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&worktree_folder_paths, None) .count() }); assert_eq!( @@ -1987,6 +2038,672 @@ async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace ); } +#[gpui::test] +async fn test_terminal_close_event_deletes_empty_draft_when_linked_worktree_has_no_archive_root( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = + project::Project::test(fs.clone(), ["/external-worktree".as_ref()], cx).await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let _sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + + let terminal_id = worktree_panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + worktree_panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted when removing the linked worktree workspace" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after closing its last terminal" + ); + assert!( + fs.is_dir(Path::new("/external-worktree")).await, + "external linked worktree directory should remain on disk when no archive root is produced" + ); +} + +#[gpui::test] +async fn test_terminal_close_event_keeps_linked_worktree_workspace_with_live_editor_draft( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/feature-a/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let _sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Worktree Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + + worktree_panel.update_in(cx, |panel, window, cx| { + panel.load_agent_thread( + Agent::Stub, + draft_id, + Some(worktree_folder_paths.clone()), + None, + false, + AgentThreadSource::AgentPanel, + window, + cx, + ); + }); + cx.run_until_parked(); + let editor_text = + worktree_panel.read_with(cx, |panel, cx| panel.editor_text_if_in_memory(draft_id, cx)); + assert_eq!( + editor_text, + Some(None), + "draft should be in memory with empty editor text before editing" + ); + + let message_editor = worktree_panel.read_with(cx, |panel, cx| { + panel + .active_thread_view(cx) + .expect("draft should be loaded in the agent panel") + .read(cx) + .message_editor + .clone() + }); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("keep this draft", window, cx); + }); + + let terminal_id = worktree_panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + let live_blocks = worktree_panel.read_with(cx, |panel, cx| { + panel.draft_prompt_blocks_if_in_memory(draft_id, cx) + }); + assert!( + matches!( + live_blocks.as_deref(), + Some([acp::ContentBlock::Text(text)]) if text.text == "keep this draft" + ), + "edited draft should still be readable from the panel after opening the terminal" + ); + + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 2, + "should start with main and linked worktree workspaces" + ); + + worktree_panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + let terminal_metadata_deleted = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none() + }); + assert!( + terminal_metadata_deleted, + "terminal metadata should be deleted after close" + ); + let unarchived_worktree_threads = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&worktree_folder_paths, None) + .count() + }); + assert_eq!( + unarchived_worktree_threads, 1, + "edited draft should remain as a worktree thread reference" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_some(), + "linked worktree workspace should stay open while an edited draft references it" + ); + assert!( + fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should remain on disk while an edited draft references it" + ); +} + +#[gpui::test] +async fn test_archive_selected_draft_archives_linked_worktree_after_last_draft( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/feature-a/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let first_draft_id = save_draft_metadata_with_main_paths( + Some("First Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + let second_draft_id = save_draft_metadata_with_main_paths( + Some("Second Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 4, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + first_draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "first draft", + ))], + cx, + ) + }) + .await + .expect("first draft prompt should persist"); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + second_draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "second draft", + ))], + cx, + ) + }) + .await + .expect("second draft prompt should persist"); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let first_draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == first_draft_id + ) + }) + .expect("first draft should be visible in sidebar") + }); + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(first_draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..4 { + cx.run_until_parked(); + } + + let first_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(first_draft_id) + .is_none() + }); + assert!( + first_draft_metadata_deleted, + "first discarded draft metadata should be deleted" + ); + let second_draft_metadata_kept = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(second_draft_id) + .is_some() + }); + assert!( + second_draft_metadata_kept, + "remaining contentful draft should still block worktree archival" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_some(), + "linked worktree workspace should remain while another draft references it" + ); + assert!( + fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should remain while another draft references it" + ); + + let second_draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == second_draft_id + ) + }) + .expect("second draft should be visible in sidebar") + }); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(second_draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let second_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(second_draft_id) + .is_none() + }); + assert!( + second_draft_metadata_deleted, + "last discarded draft metadata should be deleted" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after closing its last draft" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after closing its last draft" + ); +} + +#[gpui::test] +async fn test_archive_selected_draft_archives_closed_linked_worktree(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Closed Worktree Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "closed draft", + ))], + cx, + ) + }) + .await + .expect("draft prompt should persist"); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .expect("closed worktree draft should be visible in sidebar") + }); + sidebar.read_with(cx, |sidebar, _cx| { + match &sidebar.contents.entries[draft_index] { + ListEntry::Thread(thread) => match &thread.workspace { + ThreadEntryWorkspace::Closed { folder_paths, .. } => { + assert_eq!(folder_paths, &worktree_folder_paths); + } + ThreadEntryWorkspace::Open(_) => { + panic!("linked worktree draft should start closed") + } + }, + _ => panic!("expected draft row"), + } + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .is_none() + }); + assert!( + draft_metadata_deleted, + "discarded closed worktree draft metadata should be deleted" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "temporary linked worktree workspace should be removed after discarding its last draft" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "discarding a closed linked worktree draft should leave only the main workspace" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after discarding its last draft" + ); +} + #[gpui::test] async fn test_terminal_close_event_closes_sidebar_terminal(cx: &mut TestAppContext) { let project = init_test_project_with_agent_panel("/my-project", cx).await; @@ -2459,6 +3176,19 @@ async fn test_archive_selected_terminal_archives_closed_linked_worktree(cx: &mut store.save(metadata, cx); }); }); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); cx.run_until_parked(); @@ -2503,6 +3233,16 @@ async fn test_archive_selected_terminal_archives_closed_linked_worktree(cx: &mut terminal_metadata_deleted, "terminal metadata should be deleted after closing from the sidebar" ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); assert!( multi_workspace .read_with(cx, |multi_workspace, cx| { @@ -2596,6 +3336,19 @@ async fn test_archive_selected_thread_archives_closed_linked_worktree(cx: &mut T &main_project, cx, ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); cx.run_until_parked(); @@ -2641,6 +3394,16 @@ async fn test_archive_selected_thread_archives_closed_linked_worktree(cx: &mut T Some(true), "thread metadata should remain archived after worktree archival" ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); assert!( multi_workspace .read_with(cx, |multi_workspace, cx| { @@ -2663,6 +3426,127 @@ async fn test_archive_selected_thread_archives_closed_linked_worktree(cx: &mut T ); } +#[gpui::test] +async fn test_archive_selected_thread_deletes_empty_draft_when_linked_worktree_has_no_archive_root( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let worktree_session_id = acp::SessionId::new(Arc::from("external-worktree-thread")); + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + save_thread_metadata_with_main_paths( + "external-worktree-thread", + "External Worktree Thread", + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + cx, + ); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let thread_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&worktree_session_id))) + .expect("worktree thread should be visible in sidebar") + }); + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(thread_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let thread_archived = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&worktree_session_id) + .map(|thread| thread.archived) + }); + assert_eq!( + thread_archived, + Some(true), + "thread metadata should remain archived after workspace removal" + ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted when removing the linked worktree workspace" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after archiving its last thread" + ); + assert!( + fs.is_dir(Path::new("/external-worktree")).await, + "external linked worktree directory should remain on disk when no archive root is produced" + ); +} + #[gpui::test] async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( cx: &mut TestAppContext, @@ -12472,6 +13356,215 @@ async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &m ); } +#[gpui::test] +async fn test_discard_mixed_workspace_draft_closes_only_archived_worktree_items( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/main-repo", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": { + "lib.rs": "pub fn hello() {}", + }, + }), + ) + .await; + + fs.insert_tree( + "/worktrees/main-repo/feature-b/main-repo", + serde_json::json!({ + ".git": "gitdir: /main-repo/.git/worktrees/feature-b", + "src": { + "main.rs": "fn main() { hello(); }", + }, + }), + ) + .await; + + fs.add_linked_worktree_for_repo( + Path::new("/main-repo/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "def".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let mixed_project = project::Project::test( + fs.clone(), + [ + "/main-repo".as_ref(), + "/worktrees/main-repo/feature-b/main-repo".as_ref(), + ], + cx, + ) + .await; + + mixed_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + let worktree_ids: Vec<(WorktreeId, Arc)> = workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .visible_worktrees(cx) + .map(|worktree| (worktree.read(cx).id(), worktree.read(cx).abs_path())) + .collect() + }); + + let main_repo_worktree_id = worktree_ids + .iter() + .find(|(_, path)| path.as_ref() == Path::new("/main-repo")) + .map(|(id, _)| *id) + .expect("should find main-repo worktree"); + + let feature_b_worktree_id = worktree_ids + .iter() + .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo")) + .map(|(id, _)| *id) + .expect("should find feature-b worktree"); + + let main_repo_path = project::ProjectPath { + worktree_id: main_repo_worktree_id, + path: Arc::from(rel_path("src/lib.rs")), + }; + let feature_b_path = project::ProjectPath { + worktree_id: feature_b_worktree_id, + path: Arc::from(rel_path("src/main.rs")), + }; + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(main_repo_path.clone(), None, true, window, cx) + }) + .await + .expect("should open main-repo file"); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(feature_b_path.clone(), None, true, window, cx) + }) + .await + .expect("should open feature-b file"); + + let folder_paths = PathList::new(&[ + PathBuf::from("/main-repo"), + PathBuf::from("/worktrees/main-repo/feature-b/main-repo"), + ]); + let main_worktree_paths = + PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/main-repo")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Mixed Workspace Draft".into()), + folder_paths, + main_worktree_paths, + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "mixed workspace draft", + ))], + cx, + ) + }) + .await + .expect("draft prompt should persist"); + + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .expect("mixed workspace draft should be visible") + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "mixed workspace should be preserved" + ); + + let open_paths_after: Vec = workspace.read_with(cx, |workspace, cx| { + workspace + .panes() + .iter() + .flat_map(|pane| { + pane.read(cx) + .items() + .filter_map(|item| item.project_path(cx)) + }) + .collect() + }); + assert!( + open_paths_after + .iter() + .any(|project_path| project_path.worktree_id == main_repo_worktree_id), + "main-repo file should still be open" + ); + assert!( + !open_paths_after + .iter() + .any(|project_path| project_path.worktree_id == feature_b_worktree_id), + "feature-b file should have been closed" + ); + + let draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .is_none() + }); + assert!( + draft_metadata_deleted, + "discarded draft metadata should be deleted" + ); +} + #[test] fn test_worktree_info_branch_names_for_main_worktrees() { let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]); @@ -12715,6 +13808,164 @@ async fn test_remote_archive_thread_with_active_connection( ); } +#[gpui::test] +async fn test_remote_linked_worktree_workspace_to_remove_uses_remote_connection( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + init_test(cx); + + cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + + let app_state = cx.update(|cx| { + let app_state = workspace::AppState::test(cx); + workspace::init(app_state.clone(), cx); + app_state + }); + + let server_fs = FakeFs::new(server_cx.executor()); + server_fs + .insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + server_fs + .insert_tree( + "/external-worktree", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + server_fs.set_branch_name(Path::new("/project/.git"), Some("main")); + server_fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + server_fs + .add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "abc".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + let (worktree_project, _headless, remote_connection) = start_remote_project( + &server_fs, + Path::new("/external-worktree"), + &app_state, + None, + cx, + server_cx, + ) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.run_until_parked(); + + cx.update(|cx| ::set_global(app_state.fs.clone(), cx)); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(worktree_project.clone(), window, cx) + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let worktree_session_id = acp::SessionId::new(Arc::from("remote-worktree-thread")); + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + let main_folder_paths = PathList::new(&[PathBuf::from("/project")]); + let worktree_thread_id = ThreadId::new(); + cx.update(|_window, cx| { + let metadata = ThreadMetadata { + thread_id: worktree_thread_id, + session_id: Some(worktree_session_id.clone()), + agent_id: agent::ZED_AGENT_ID.clone(), + title: Some("Remote Worktree Thread".into()), + title_override: None, + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + created_at: None, + interacted_at: None, + worktree_paths: WorktreePaths::from_path_lists( + main_folder_paths, + worktree_folder_paths.clone(), + ) + .unwrap(), + archived: false, + remote_connection: Some(remote_connection.clone()), + }; + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + cx.run_until_parked(); + + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths( + &worktree_folder_paths, + Some(&remote_connection), + cx, + ) + }) + .is_some(), + "remote linked-worktree workspace should be open before archiving" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "the test must exercise a remote-only workspace lookup" + ); + assert_ne!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).project_group_key(cx) + }) + .path_list(), + &worktree_folder_paths, + "remote workspace must be classified as a linked worktree under the main project" + ); + + let workspace_to_remove = sidebar.read_with(cx, |sidebar, cx| { + sidebar + .linked_worktree_workspace_to_remove( + &worktree_folder_paths, + Some(&remote_connection), + Some(worktree_thread_id), + None, + &[], + cx, + ) + .map(|workspace| workspace.entity_id()) + }); + let active_workspace_id = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().entity_id() + }); + assert_eq!( + workspace_to_remove, + Some(active_workspace_id), + "archive helper should resolve the remote linked-worktree workspace" + ); + assert!( + server_fs.is_dir(Path::new("/external-worktree")).await, + "direct helper check should not remove the linked worktree from disk" + ); +} + #[gpui::test] async fn test_remote_archive_thread_with_disconnected_remote( cx: &mut TestAppContext,