diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index e585b7aca6f8164743802ed3a9cfe59d8d10ee45..60fe464c98b2d9670b5c46de49090c49c98f93f5 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -2458,7 +2458,7 @@ impl Sidebar { else { return; }; - thread_worktree_archive::archive_thread( + let outcome = thread_worktree_archive::archive_thread( session_id, current_workspace, multi_workspace_handle, @@ -2493,6 +2493,23 @@ impl Sidebar { self.workspace_for_group(path_list, cx) }); + // If the group's workspace is about to lose all its roots, find a + // surviving workspace in the same window instead. + let safe_workspace = if group_workspace + .as_ref() + .is_some_and(|ws| workspace_will_be_empty(ws, &outcome.roots_to_delete, cx)) + { + self.multi_workspace.upgrade().and_then(|mw| { + let mw = mw.read(cx); + mw.workspaces() + .iter() + .find(|ws| !workspace_will_be_empty(ws, &outcome.roots_to_delete, cx)) + .cloned() + }) + } else { + group_workspace.clone() + }; + let next_thread = current_pos.and_then(|pos| { let group_start = self.contents.entries[..pos] .iter() @@ -2534,10 +2551,21 @@ impl Sidebar { // but belongs to its own workspace). Loading into the wrong panel binds // the thread to the wrong project, which corrupts its stored folder_paths // when metadata is saved via ThreadMetadata::from_thread. - let target_workspace = match &next.workspace { + let mut target_workspace = match &next.workspace { ThreadEntryWorkspace::Open(ws) => Some(ws.clone()), ThreadEntryWorkspace::Closed(_) => group_workspace, }; + // If the next thread's workspace is also being deleted, fall + // back to a surviving workspace. + if target_workspace + .as_ref() + .is_some_and(|ws| workspace_will_be_empty(ws, &outcome.roots_to_delete, cx)) + { + #[allow(clippy::redundant_clone)] + { + target_workspace = safe_workspace.clone(); + } + } if let Some(ref ws) = target_workspace { self.active_entry = Some(ActiveEntry::Thread { session_id: next_metadata.session_id.clone(), @@ -2562,8 +2590,14 @@ impl Sidebar { } } } else { - if let Some(workspace) = &group_workspace { + let fallback = safe_workspace.or(group_workspace); + if let Some(workspace) = &fallback { self.active_entry = Some(ActiveEntry::Draft(workspace.clone())); + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + multi_workspace.update(cx, |mw, cx| { + mw.activate(workspace.clone(), window, cx); + }); + } if let Some(agent_panel) = workspace.read(cx).panel::(cx) { agent_panel.update(cx, |panel, cx| { panel.new_thread(&NewThread, window, cx); @@ -3871,6 +3905,21 @@ impl Render for Sidebar { } } +fn workspace_will_be_empty( + workspace: &Entity, + roots_to_delete: &[PathBuf], + cx: &App, +) -> bool { + if roots_to_delete.is_empty() { + return false; + } + let root_paths = workspace.read(cx).root_paths(cx); + !root_paths.is_empty() + && root_paths + .iter() + .all(|path| roots_to_delete.iter().any(|d| d.as_path() == path.as_ref())) +} + fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index b50ebe097d6202d4fe3e10cca85e4d1eee326575..89c4badf7d449e23b5adee79ccdcfca3af0a0ed8 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -5419,3 +5419,146 @@ mod property_test { } } } + +#[gpui::test] +async fn test_archive_only_thread_in_worktree_switches_to_surviving_workspace( + cx: &mut TestAppContext, +) { + // When the user archives the only thread in a linked-worktree workspace, + // the sidebar should switch to a surviving workspace (the main repo) + // instead of staying on the doomed worktree workspace. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/wt-feature"), + ref_name: Some("refs/heads/feature".into()), + sha: "aaa".into(), + is_main: 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(), ["/wt-feature".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate the worktree workspace so the user is "looking at" it. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(worktree_workspace.clone(), window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); + let _main_panel = add_agent_panel(&main_workspace, cx); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + + // Open a thread in the worktree workspace's panel. + open_thread_with_connection(&worktree_panel, StubAgentConnection::new(), cx); + send_message(&worktree_panel, cx); + let thread_session_id = active_session_id(&worktree_panel, cx); + + // Save thread metadata associated with the worktree workspace. + save_thread_metadata( + thread_session_id.clone(), + "Worktree Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + None, + &worktree_project, + cx, + ); + cx.run_until_parked(); + + // Set the sidebar's active entry to this thread. + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.active_entry = Some(ActiveEntry::Thread { + session_id: thread_session_id.clone(), + workspace: worktree_workspace.clone(), + }); + cx.notify(); + }); + cx.run_until_parked(); + + // Verify the active workspace is the worktree workspace before archiving. + let active_before = multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()); + let worktree_index = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces() + .iter() + .position(|ws| ws == &worktree_workspace) + .unwrap() + }); + assert_eq!( + active_before, worktree_index, + "before archiving, the worktree workspace should be active" + ); + + // Archive the thread. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread(&thread_session_id, window, cx); + }); + cx.run_until_parked(); + + // After archiving, the active workspace should be the main workspace, + // not the doomed worktree workspace. + let active_after = multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()); + let main_index = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces() + .iter() + .position(|ws| ws == &main_workspace) + .unwrap() + }); + assert_eq!( + active_after, main_index, + "after archiving, the sidebar should have switched to the main (surviving) workspace" + ); + + // The active entry should be a Draft on the main workspace, not the + // worktree workspace. + sidebar.read_with(cx, |sidebar, _| match &sidebar.active_entry { + Some(ActiveEntry::Draft(ws)) => { + assert_eq!( + ws, &main_workspace, + "active_entry should be Draft on the main workspace, not the worktree" + ); + } + other => panic!( + "expected ActiveEntry::Draft on main workspace, got {:?}", + other + ), + }); +}