From d14e16144eb95d4b4fc3e4f5f41920c8a7b7f3b0 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 19 May 2026 23:08:39 +0200 Subject: [PATCH] sidebar: Don't block worktree archival on empty drafts (#57145) If we have empty drafts, they don't show up in the UI, so you can't get rid of them. But they currently blocked worktree archival. Which is particularly troublesome with terminal agents in a few cases. This should hopefully solve the issue for terminals, but I think I need to do a follow-up to investigate what happens when the last draft is closed. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 235 ++- crates/agent_ui/src/draft_prompt_store.rs | 41 +- crates/agent_ui/src/thread_metadata_store.rs | 69 +- .../agent_ui/src/thread_worktree_archive.rs | 21 + crates/sidebar/src/sidebar.rs | 794 ++++++++--- crates/sidebar/src/sidebar_tests.rs | 1259 ++++++++++++++++- 6 files changed, 2195 insertions(+), 224 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 151a04d8bf..862b007f52 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 34d17d4999..c0b45ed758 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 594b0d00b8..f3bbdaa0c9 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 b510da96b4..f3d54657d4 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 7fcaf38a3f..1dfec54eba 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 982f0c6cd2..c65eda086a 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,