diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 127f746a9edd35bc3b62b489277980868faba1c8..69c5377465a420b2e9f64e16139736fe04b65e5a 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -477,6 +477,36 @@ impl ThreadMetadataStore { } } + pub fn update_main_worktree_paths( + &mut self, + old_paths: &PathList, + new_paths: PathList, + cx: &mut Context, + ) { + let session_ids = match self.threads_by_main_paths.remove(old_paths) { + Some(ids) if !ids.is_empty() => ids, + _ => return, + }; + + let new_index = self + .threads_by_main_paths + .entry(new_paths.clone()) + .or_default(); + + for session_id in &session_ids { + new_index.insert(session_id.clone()); + + if let Some(thread) = self.threads.get_mut(session_id) { + thread.main_worktree_paths = new_paths.clone(); + self.pending_thread_ops_tx + .try_send(DbOperation::Upsert(thread.clone())) + .log_err(); + } + } + + cx.notify(); + } + pub fn create_archived_worktree( &self, worktree_path: String, diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 488127eb0bd04b064c2c6e3b1d8dc297ada9c477..547d3bd83cf97986dbe0006a2454223fba255886 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -283,10 +283,8 @@ impl ListEntry { } } ListEntry::ProjectHeader { key, .. } => multi_workspace - .workspaces() - .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list()) + .workspaces_for_project_group(key, cx) .cloned() - .into_iter() .collect(), ListEntry::ViewMore { .. } => Vec::new(), } @@ -365,35 +363,81 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { /// /// For each path in the thread's `folder_paths`, produces a /// [`WorktreeInfo`] with a short display name, full path, and whether -/// the worktree is the main checkout or a linked git worktree. +/// the worktree is the main checkout or a linked git worktree. When +/// multiple main paths exist and a linked worktree's short name alone +/// wouldn't identify which main project it belongs to, the main project +/// name is prefixed for disambiguation (e.g. `project:feature`). +/// +/// `linked_to_main` maps linked worktree abs paths to their main repo +/// abs path, used to pick the correct prefix. Falls back to a heuristic +/// when no mapping is available. fn worktree_info_from_thread_paths( folder_paths: &PathList, - group_key: &project::ProjectGroupKey, -) -> impl Iterator { - let main_paths = group_key.path_list().paths(); - folder_paths.paths().iter().filter_map(|path| { + main_worktree_paths: &PathList, + linked_to_main: &HashMap, +) -> Vec { + let main_paths = main_worktree_paths.paths(); + + let mut infos: Vec = Vec::new(); + let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new(); + + for path in folder_paths.paths().iter() { let is_main = main_paths.iter().any(|mp| mp.as_path() == path.as_path()); if is_main { - let name = path.file_name()?.to_string_lossy().to_string(); - Some(WorktreeInfo { - name: SharedString::from(name), + let Some(name) = path.file_name() else { + continue; + }; + infos.push(WorktreeInfo { + name: SharedString::from(name.to_string_lossy().to_string()), full_path: SharedString::from(path.display().to_string()), highlight_positions: Vec::new(), kind: ui::WorktreeKind::Main, - }) + }); } else { - let main_path = main_paths - .iter() - .find(|mp| mp.file_name() == path.file_name()) - .or(main_paths.first())?; - Some(WorktreeInfo { - name: linked_worktree_short_name(main_path, path).unwrap_or_default(), + let Some(main_path) = linked_to_main + .get(&**path) + .and_then(|main| main_paths.iter().find(|mp| mp.as_path() == main.as_path())) + .or_else(|| { + main_paths + .iter() + .find(|mp| mp.file_name() == path.file_name()) + .or(main_paths.first()) + }) + else { + continue; + }; + let short_name = linked_worktree_short_name(main_path, path).unwrap_or_default(); + let project_name = main_path + .file_name() + .map(|n| SharedString::from(n.to_string_lossy().to_string())) + .unwrap_or_default(); + linked_short_names.push((short_name.clone(), project_name)); + infos.push(WorktreeInfo { + name: short_name, full_path: SharedString::from(path.display().to_string()), highlight_positions: Vec::new(), kind: ui::WorktreeKind::Linked, - }) + }); } - }) + } + + // When the group has multiple main worktree paths and the thread's + // folder paths don't all share the same short name, prefix each + // linked worktree chip with its main project name so the user knows + // which project it belongs to. + let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name); + + if main_paths.len() > 1 && !all_same_name { + for (info, (_short_name, project_name)) in infos + .iter_mut() + .filter(|i| i.kind == ui::WorktreeKind::Linked) + .zip(linked_short_names.iter()) + { + info.name = SharedString::from(format!("{}:{}", project_name, info.name)); + } + } + + infos } /// Shows a [`RemoteConnectionModal`] on the given workspace and establishes @@ -480,6 +524,16 @@ impl Sidebar { MultiWorkspaceEvent::WorkspaceRemoved(_) => { this.update_entries(cx); } + MultiWorkspaceEvent::ProjectGroupKeyChanged { old_key, new_key } => { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.update_main_worktree_paths( + old_key.path_list(), + new_key.path_list().clone(), + cx, + ); + }); + this.update_entries(cx); + } }, ) .detach(); @@ -912,6 +966,21 @@ impl Sidebar { .as_ref() .is_some_and(|active| group_workspaces.contains(active)); + // Build a mapping from linked worktree paths to their main + // repo path, used to correctly attribute chips. + let linked_to_main: HashMap = group_workspaces + .iter() + .flat_map(|ws| root_repository_snapshots(ws, cx)) + .flat_map(|snapshot| { + let main_path = snapshot.original_repo_abs_path.to_path_buf(); + snapshot + .linked_worktrees() + .iter() + .map(move |wt| (wt.path.clone(), main_path.clone())) + .collect::>() + }) + .collect(); + // Collect live thread infos from all workspaces in this group. let live_infos: Vec<_> = group_workspaces .iter() @@ -949,26 +1018,28 @@ impl Sidebar { }; // Build a ThreadEntry from a metadata row. - let make_thread_entry = |row: ThreadMetadata, - workspace: ThreadEntryWorkspace| - -> ThreadEntry { - let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); - let worktrees: Vec = - worktree_info_from_thread_paths(&row.folder_paths, &group_key).collect(); - ThreadEntry { - metadata: row, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace, - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees, - diff_stats: DiffStats::default(), - } - }; + let make_thread_entry = + |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry { + let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); + let worktrees = worktree_info_from_thread_paths( + &row.folder_paths, + &row.main_worktree_paths, + &linked_to_main, + ); + ThreadEntry { + metadata: row, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace, + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees, + diff_stats: DiffStats::default(), + } + }; // Main code path: one query per group via main_worktree_paths. // The main_worktree_paths column is set on all new threads and @@ -1184,11 +1255,17 @@ impl Sidebar { if is_draft_for_group { if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry { let ws_path_list = workspace_path_list(draft_ws, cx); - let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key); + let main_worktree_paths = + draft_ws.read(cx).project_group_key(cx).path_list().clone(); + let worktrees = worktree_info_from_thread_paths( + &ws_path_list, + &main_worktree_paths, + &linked_to_main, + ); entries.push(ListEntry::DraftThread { key: group_key.clone(), workspace: None, - worktrees: worktrees.collect(), + worktrees, }); } } @@ -1212,9 +1289,14 @@ impl Sidebar { continue; } let ws_path_list = workspace_path_list(ws, cx); - let has_linked_worktrees = - worktree_info_from_thread_paths(&ws_path_list, &group_key) - .any(|wt| wt.kind == ui::WorktreeKind::Linked); + let ws_main_paths = ws.read(cx).project_group_key(cx).path_list().clone(); + let has_linked_worktrees = worktree_info_from_thread_paths( + &ws_path_list, + &ws_main_paths, + &linked_to_main, + ) + .iter() + .any(|wt| wt.kind == ui::WorktreeKind::Linked); if !has_linked_worktrees { continue; } @@ -1227,8 +1309,11 @@ impl Sidebar { if has_threads { continue; } - let worktrees: Vec = - worktree_info_from_thread_paths(&ws_path_list, &group_key).collect(); + let worktrees = worktree_info_from_thread_paths( + &ws_path_list, + &ws_main_paths, + &linked_to_main, + ); entries.push(ListEntry::DraftThread { key: group_key.clone(), diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 8ced8d6f71f6d88ff24a522404417ef7db3a6a7c..420eae134e11309e4d1cc90c45335f7fce76bb22 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -251,6 +251,32 @@ fn save_thread_metadata( cx.run_until_parked(); } +fn save_thread_metadata_with_main_paths( + session_id: &str, + title: &str, + folder_paths: PathList, + main_worktree_paths: PathList, + cx: &mut TestAppContext, +) { + let session_id = acp::SessionId::new(Arc::from(session_id)); + let title = SharedString::from(title.to_string()); + let updated_at = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(); + let metadata = ThreadMetadata { + session_id, + agent_id: agent::ZED_AGENT_ID.clone(), + title, + updated_at, + created_at: None, + folder_paths, + main_worktree_paths, + archived: false, + }; + cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx)); + }); + cx.run_until_parked(); +} + fn focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { sidebar.update_in(cx, |_, window, cx| { cx.focus_self(window); @@ -322,6 +348,11 @@ fn visible_entries_as_strings( } else { "" }; + let is_active = sidebar + .active_entry + .as_ref() + .is_some_and(|active| active.matches_entry(entry)); + let active_indicator = if is_active { " (active)" } else { "" }; match entry { ListEntry::ProjectHeader { label, @@ -338,7 +369,7 @@ fn visible_entries_as_strings( } ListEntry::Thread(thread) => { let title = thread.metadata.title.as_ref(); - let active = if thread.is_live { " *" } else { "" }; + let live = if thread.is_live { " *" } else { "" }; let status_str = match thread.status { AgentThreadStatus::Running => " (running)", AgentThreadStatus::Error => " (error)", @@ -354,7 +385,7 @@ fn visible_entries_as_strings( "" }; let worktree = format_linked_worktree_chips(&thread.worktrees); - format!(" {title}{worktree}{active}{status_str}{notified}{selected}") + format!(" {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}") } ListEntry::ViewMore { is_fully_expanded, .. @@ -374,7 +405,7 @@ fn visible_entries_as_strings( if workspace.is_some() { format!(" [+ New Thread{}]{}", worktree, selected) } else { - format!(" [~ Draft{}]{}", worktree, selected) + format!(" [~ Draft{}]{}{}", worktree, active_indicator, selected) } } } @@ -543,7 +574,10 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]"] + vec![ + // + "v [my-project]", + ] ); } @@ -579,6 +613,7 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Fix crash in project panel", " Add inline diff view", @@ -609,7 +644,11 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] + vec![ + // + "v [project-a]", + " Thread A1", + ] ); // Add a second workspace @@ -620,7 +659,11 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1",] + vec![ + // + "v [project-a]", + " Thread A1", + ] ); } @@ -639,6 +682,7 @@ async fn test_view_more_pagination(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Thread 12", " Thread 11", @@ -749,7 +793,11 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] + vec![ + // + "v [my-project]", + " Thread 1", + ] ); // Collapse @@ -760,7 +808,10 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]"] + vec![ + // + "> [my-project]", + ] ); // Expand @@ -771,7 +822,11 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] + vec![ + // + "v [my-project]", + " Thread 1", + ] ); } @@ -941,6 +996,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [expanded-project]", " Completed thread", " Running thread * (running) <== selected", @@ -1104,10 +1160,14 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] + vec![ + // + "v [my-project]", + " Thread 1", + ] ); - // Focus the sidebar and select the header (index 0) + // Focus the sidebar and select the header focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); @@ -1119,7 +1179,10 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] + vec![ + // + "> [my-project] <== selected", + ] ); // Confirm again expands the group @@ -1128,7 +1191,11 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] + vec![ + // + "v [my-project] <== selected", + " Thread 1", + ] ); } @@ -1179,7 +1246,11 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] + vec![ + // + "v [my-project]", + " Thread 1", + ] ); // Focus sidebar and manually select the header (index 0). Press left to collapse. @@ -1193,7 +1264,10 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] + vec![ + // + "> [my-project] <== selected", + ] ); // Press right to expand @@ -1202,7 +1276,11 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] + vec![ + // + "v [my-project] <== selected", + " Thread 1", + ] ); // Press right again on already-expanded header moves selection down @@ -1229,7 +1307,11 @@ async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContex assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1 <== selected",] + vec![ + // + "v [my-project]", + " Thread 1 <== selected", + ] ); // Pressing left on a child collapses the parent group and selects it @@ -1239,7 +1321,10 @@ async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContex assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] + vec![ + // + "> [my-project] <== selected", + ] ); } @@ -1253,7 +1338,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { // An empty project has only the header. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [empty-project]"] + vec![ + // + "v [empty-project]", + ] ); // Focus sidebar — focus_in does not set a selection @@ -1385,7 +1473,12 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { entries[1..].sort(); assert_eq!( entries, - vec!["v [my-project]", " Hello *", " Hello * (running)",] + vec![ + // + "v [my-project]", + " Hello * (active)", + " Hello * (running)", + ] ); } @@ -1478,7 +1571,11 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp // Thread A is still running; no notification yet. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (running)",] + vec![ + // + "v [project-a]", + " Hello * (running) (active)", + ] ); // Complete thread A's turn (transition Running → Completed). @@ -1488,7 +1585,11 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp // The completed background thread shows a notification indicator. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (!)",] + vec![ + // + "v [project-a]", + " Hello * (!) (active)", + ] ); } @@ -1528,6 +1629,7 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Fix crash in project panel", " Add inline diff view", @@ -1540,7 +1642,11 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) type_in_search(&sidebar, "diff", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Add inline diff view <== selected",] + vec![ + // + "v [my-project]", + " Add inline diff view <== selected", + ] ); // User changes query to something with no matches — list is empty. @@ -1575,6 +1681,7 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Fix Crash In Project Panel <== selected", ] @@ -1585,6 +1692,7 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Fix Crash In Project Panel <== selected", ] @@ -1615,7 +1723,12 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex // Confirm the full list is showing. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread", " Beta thread",] + vec![ + // + "v [my-project]", + " Alpha thread", + " Beta thread", + ] ); // User types a search query to filter down. @@ -1623,7 +1736,11 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex type_in_search(&sidebar, "alpha", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread <== selected",] + vec![ + // + "v [my-project]", + " Alpha thread <== selected", + ] ); // User presses Escape — filter clears, full list is restored. @@ -1633,6 +1750,7 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Alpha thread <== selected", " Beta thread", @@ -1689,6 +1807,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project-a]", " Fix bug in sidebar", " Add tests for editor", @@ -1699,7 +1818,11 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC type_in_search(&sidebar, "sidebar", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Fix bug in sidebar <== selected",] + vec![ + // + "v [project-a]", + " Fix bug in sidebar <== selected", + ] ); // "typo" only matches in the second workspace — the first header disappears. @@ -1715,6 +1838,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project-a]", " Fix bug in sidebar <== selected", " Add tests for editor", @@ -1774,6 +1898,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [alpha-project]", " Fix bug in sidebar <== selected", " Add tests for editor", @@ -1785,7 +1910,11 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { type_in_search(&sidebar, "sidebar", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + vec![ + // + "v [alpha-project]", + " Fix bug in sidebar <== selected", + ] ); // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r @@ -1795,7 +1924,11 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { type_in_search(&sidebar, "fix", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + vec![ + // + "v [alpha-project]", + " Fix bug in sidebar <== selected", + ] ); // A query that matches a workspace name AND a thread in that same workspace. @@ -1804,6 +1937,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [alpha-project]", " Fix bug in sidebar <== selected", " Add tests for editor", @@ -1817,6 +1951,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [alpha-project]", " Fix bug in sidebar <== selected", " Add tests for editor", @@ -1866,7 +2001,11 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte let filtered = visible_entries_as_strings(&sidebar, cx); assert_eq!( filtered, - vec!["v [my-project]", " Hidden gem thread <== selected",] + vec![ + // + "v [my-project]", + " Hidden gem thread <== selected", + ] ); assert!( !filtered.iter().any(|e| e.contains("View More")), @@ -1902,14 +2041,21 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] + vec![ + // + "> [my-project] <== selected", + ] ); // User types a search — the thread appears even though its group is collapsed. type_in_search(&sidebar, "important", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]", " Important thread <== selected",] + vec![ + // + "> [my-project]", + " Important thread <== selected", + ] ); } @@ -1943,6 +2089,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Fix crash in panel <== selected", " Fix lint warnings", @@ -1955,6 +2102,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Fix crash in panel", " Fix lint warnings <== selected", @@ -1966,6 +2114,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [my-project]", " Fix crash in panel <== selected", " Fix lint warnings", @@ -2006,7 +2155,11 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Historical Thread",] + vec![ + // + "v [my-project]", + " Historical Thread", + ] ); // Switch to workspace 1 so we can verify the confirm switches back. @@ -2067,7 +2220,12 @@ async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppCo assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread A", " Thread B",] + vec![ + // + "v [my-project]", + " Thread A", + " Thread B", + ] ); // Keyboard confirm preserves selection. @@ -2119,7 +2277,11 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] + vec![ + // + "v [my-project]", + " Hello * (active)", + ] ); // Simulate the agent generating a title. The notification chain is: @@ -2141,7 +2303,11 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Friendly Greeting with AI *"] + vec![ + // + "v [my-project]", + " Friendly Greeting with AI * (active)", + ] ); } @@ -2292,177 +2458,816 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { ); }); - let connection_b2 = StubAgentConnection::new(); - connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()), - )]); - open_thread_with_connection(&panel_b, connection_b2, cx); - send_message(&panel_b, cx); - let session_id_b2 = active_session_id(&panel_b, cx); - save_test_thread_metadata(&session_id_b2, &project_b, cx).await; - cx.run_until_parked(); + let connection_b2 = StubAgentConnection::new(); + connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()), + )]); + open_thread_with_connection(&panel_b, connection_b2, cx); + send_message(&panel_b, cx); + let session_id_b2 = active_session_id(&panel_b, cx); + save_test_thread_metadata(&session_id_b2, &project_b, cx).await; + cx.run_until_parked(); + + // Panel B is not the active workspace's panel (workspace A is + // active), so opening a thread there should not change focused_thread. + // This prevents running threads in background workspaces from causing + // the selection highlight to jump around. + sidebar.read_with(cx, |sidebar, _cx| { + assert_active_thread( + sidebar, + &session_id_a, + "Opening a thread in a non-active panel should not change focused_thread", + ); + }); + + workspace_b.update_in(cx, |workspace, window, cx| { + workspace.focus_handle(cx).focus(window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_active_thread( + sidebar, + &session_id_a, + "Defocusing the sidebar should not change focused_thread", + ); + }); + + // Switching workspaces via the multi_workspace (simulates clicking + // a workspace header) should clear focused_thread. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned(); + if let Some(workspace) = workspace { + mw.activate(workspace, window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_active_thread( + sidebar, + &session_id_b2, + "Switching workspace should seed focused_thread from the new active panel", + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The seeded thread should be present in the entries" + ); + }); + + // ── 8. Focusing the agent panel thread keeps focused_thread ──── + // Workspace B still has session_id_b2 loaded in the agent panel. + // Clicking into the thread (simulated by focusing its view) should + // keep focused_thread since it was already seeded on workspace switch. + panel_b.update_in(cx, |panel, window, cx| { + if let Some(thread_view) = panel.active_conversation_view() { + thread_view.read(cx).focus_handle(cx).focus(window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_active_thread( + sidebar, + &session_id_b2, + "Focusing the agent panel thread should set focused_thread", + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The focused thread should be present in the entries" + ); + }); +} + +#[gpui::test] +async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/project-a", cx).await; + let fs = cx.update(|cx| ::global(cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Start a thread and send a message so it has history. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, &project, cx).await; + cx.run_until_parked(); + + // Verify the thread appears in the sidebar. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a]", + " Hello * (active)", + ] + ); + + // The "New Thread" button should NOT be in "active/draft" state + // because the panel has a thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), + "Panel has a thread with messages, so active_entry should be Thread, got {:?}", + sidebar.active_entry, + ); + }); + + // Now add a second folder to the workspace, changing the path_list. + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // The workspace path_list is now [project-a, project-b]. The active + // thread's metadata was re-saved with the new paths by the agent panel's + // project subscription. The old [project-a] key is replaced by the new + // key since no other workspace claims it. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a, project-b]", + " Hello * (active)", + ] + ); + + // The "New Thread" button must still be clickable (not stuck in + // "active/draft" state). Verify that `active_thread_is_draft` is + // false — the panel still has the old thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), + "After adding a folder the panel still has a thread with messages, \ + so active_entry should be Thread, got {:?}", + sidebar.active_entry, + ); + }); + + // Actually click "New Thread" by calling create_new_thread and + // verify a new draft is created. + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + // After creating a new thread, the panel should now be in draft + // state (no messages on the new thread). + sidebar.read_with(cx, |sidebar, _cx| { + assert_active_draft( + sidebar, + &workspace, + "After creating a new thread active_entry should be Draft", + ); + }); +} + +#[gpui::test] +async fn test_worktree_add_and_remove_migrates_threads(cx: &mut TestAppContext) { + // When a worktree is added to a project, the project group key changes + // and all historical threads should be migrated to the new key. Removing + // the worktree should migrate them back. + let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save two threads against the initial project group [/project-a]. + save_n_test_threads(2, &project, cx).await; + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a]", + " Thread 2", + " Thread 1", + ] + ); + + // Verify the metadata store has threads under the old key. + let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]); + cx.update(|_window, cx| { + let store = ThreadMetadataStore::global(cx).read(cx); + assert_eq!( + store.entries_for_main_worktree_path(&old_key_paths).count(), + 2, + "should have 2 threads under old key before add" + ); + }); + + // Add a second worktree to the same project. + project + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // The project group key should now be [/project-a, /project-b]. + let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]); + + // Verify multi-workspace state: exactly one project group key, the new one. + multi_workspace.read_with(cx, |mw, _cx| { + let keys: Vec<_> = mw.project_group_keys().cloned().collect(); + assert_eq!( + keys.len(), + 1, + "should have exactly 1 project group key after add" + ); + assert_eq!( + keys[0].path_list(), + &new_key_paths, + "the key should be the new combined path list" + ); + }); + + // Verify threads were migrated to the new key. + cx.update(|_window, cx| { + let store = ThreadMetadataStore::global(cx).read(cx); + assert_eq!( + store.entries_for_main_worktree_path(&old_key_paths).count(), + 0, + "should have 0 threads under old key after migration" + ); + assert_eq!( + store.entries_for_main_worktree_path(&new_key_paths).count(), + 2, + "should have 2 threads under new key after migration" + ); + }); + + // Sidebar should show threads under the new header. + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a, project-b]", + " Thread 2", + " Thread 1", + ] + ); + + // Now remove the second worktree. + let worktree_id = project.read_with(cx, |project, cx| { + project + .visible_worktrees(cx) + .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b")) + .map(|wt| wt.read(cx).id()) + .expect("should find project-b worktree") + }); + project.update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + cx.run_until_parked(); + + // The key should revert to [/project-a]. + multi_workspace.read_with(cx, |mw, _cx| { + let keys: Vec<_> = mw.project_group_keys().cloned().collect(); + assert_eq!( + keys.len(), + 1, + "should have exactly 1 project group key after remove" + ); + assert_eq!( + keys[0].path_list(), + &old_key_paths, + "the key should revert to the original path list" + ); + }); + + // Threads should be migrated back to the old key. + cx.update(|_window, cx| { + let store = ThreadMetadataStore::global(cx).read(cx); + assert_eq!( + store.entries_for_main_worktree_path(&new_key_paths).count(), + 0, + "should have 0 threads under new key after revert" + ); + assert_eq!( + store.entries_for_main_worktree_path(&old_key_paths).count(), + 2, + "should have 2 threads under old key after revert" + ); + }); + + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a]", + " Thread 2", + " Thread 1", + ] + ); +} + +#[gpui::test] +async fn test_worktree_add_key_collision_removes_duplicate_workspace(cx: &mut TestAppContext) { + // When a worktree is added to workspace A and the resulting key matches + // an existing workspace B's key (and B has the same root paths), B + // should be removed as a true duplicate. + let (fs, project_a) = init_multi_project_test(&["/project-a", "/project-b"], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread against workspace A [/project-a]. + save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await; + + // Create workspace B with both worktrees [/project-a, /project-b]. + let project_b = project::Project::test( + fs.clone() as Arc, + ["/project-a".as_ref(), "/project-b".as_ref()], + cx, + ) + .await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + cx.run_until_parked(); + + // Switch back to workspace A so it's the active workspace when the collision happens. + let workspace_a = + multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone()); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_a, window, cx); + }); + cx.run_until_parked(); + + // Save a thread against workspace B [/project-a, /project-b]. + save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await; + + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + // Both project groups should be visible. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a, project-b]", + " Thread B", + "v [project-a]", + " Thread A", + ] + ); + + let workspace_b_id = workspace_b.entity_id(); + + // Now add /project-b to workspace A's project, causing a key collision. + project_a + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // Workspace B should have been removed (true duplicate — same root paths). + multi_workspace.read_with(cx, |mw, _cx| { + let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect(); + assert!( + !workspace_ids.contains(&workspace_b_id), + "workspace B should have been removed after key collision" + ); + }); + + // There should be exactly one project group key now. + let combined_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]); + multi_workspace.read_with(cx, |mw, _cx| { + let keys: Vec<_> = mw.project_group_keys().cloned().collect(); + assert_eq!( + keys.len(), + 1, + "should have exactly 1 project group key after collision" + ); + assert_eq!( + keys[0].path_list(), + &combined_paths, + "the remaining key should be the combined paths" + ); + }); + + // Both threads should be visible under the merged group. + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a, project-b]", + " Thread A", + " Thread B", + ] + ); +} + +#[gpui::test] +async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext) { + // When workspace A adds a folder that makes it collide with workspace B, + // and B is the *active* workspace, A (the incoming one) should be + // dropped so the user stays on B. A linked worktree sibling of A + // should migrate into B's group. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Set up /project-a with a linked worktree. + fs.insert_tree( + "/project-a", + serde_json::json!({ + ".git": { + "worktrees": { + "feature": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature", + serde_json::json!({ + ".git": "gitdir: /project-a/.git/worktrees/feature", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project-a/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/wt-feature"), + ref_name: Some("refs/heads/feature".into()), + sha: "aaa".into(), + is_main: false, + }, + ) + .await; + fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + // Linked worktree sibling of A. + let project_wt = project::Project::test(fs.clone(), ["/wt-feature".as_ref()], cx).await; + project_wt + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Workspace B has both folders already. + let project_b = project::Project::test( + fs.clone() as Arc, + ["/project-a".as_ref(), "/project-b".as_ref()], + cx, + ) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Add agent panels to all workspaces. + let workspace_a_entity = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + add_agent_panel(&workspace_a_entity, cx); + + // Add the linked worktree workspace (sibling of A). + let workspace_wt = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_wt.clone(), window, cx) + }); + add_agent_panel(&workspace_wt, cx); + cx.run_until_parked(); + + // Add workspace B (will become active). + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + add_agent_panel(&workspace_b, cx); + cx.run_until_parked(); + + // Save threads in each group. + save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await; + save_thread_metadata_with_main_paths( + "thread-wt", + "Worktree Thread", + PathList::new(&[PathBuf::from("/wt-feature")]), + PathList::new(&[PathBuf::from("/project-a")]), + cx, + ); + save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await; + + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + // B is active, A and wt-feature are in one group, B in another. + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspace().entity_id()), + workspace_b.entity_id(), + "workspace B should be active" + ); + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.project_group_keys().count(), 2, "should have 2 groups"); + assert_eq!(mw.workspaces().count(), 3, "should have 3 workspaces"); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a, project-b]", + " [~ Draft] (active)", + " Thread B", + "v [project-a]", + " Thread A", + " Worktree Thread {wt-feature}", + ] + ); + + let workspace_a = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces() + .find(|ws| { + ws.entity_id() != workspace_b.entity_id() + && ws.entity_id() != workspace_wt.entity_id() + }) + .unwrap() + .clone() + }); + + // Add /project-b to workspace A's project, causing a collision with B. + project_a + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // Workspace A (the incoming duplicate) should have been dropped. + multi_workspace.read_with(cx, |mw, _cx| { + let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect(); + assert!( + !workspace_ids.contains(&workspace_a.entity_id()), + "workspace A should have been dropped" + ); + }); + + // The active workspace should still be B. + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspace().entity_id()), + workspace_b.entity_id(), + "workspace B should still be active" + ); + + // The linked worktree sibling should have migrated into B's group + // (it got the folder add and now shares the same key). + multi_workspace.read_with(cx, |mw, _cx| { + let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect(); + assert!( + workspace_ids.contains(&workspace_wt.entity_id()), + "linked worktree workspace should still exist" + ); + assert_eq!( + mw.project_group_keys().count(), + 1, + "should have 1 group after merge" + ); + assert_eq!( + mw.workspaces().count(), + 2, + "should have 2 workspaces (B + linked worktree)" + ); + }); + + // The linked worktree workspace should have gotten the new folder. + let wt_worktree_count = + project_wt.read_with(cx, |project, cx| project.visible_worktrees(cx).count()); + assert_eq!( + wt_worktree_count, 2, + "linked worktree project should have gotten /project-b" + ); + + // After: everything merged under one group. Thread A migrated, + // worktree thread shows its chip, B's thread and draft remain. + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [project-a, project-b]", + " [~ Draft] (active)", + " [+ New Thread {project-a:wt-feature}]", + " Thread A", + " Worktree Thread {project-a:wt-feature}", + " Thread B", + ] + ); +} + +#[gpui::test] +async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext) { + // When a worktree is added to the main workspace, a linked worktree + // sibling (different root paths, same project group key) should also + // get the new folder added to its project. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature", + "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; + + // Create a second independent project to add as a folder later. + fs.insert_tree( + "/other-project", + serde_json::json!({ ".git": {}, "src": {} }), + ) + .await; - // Panel B is not the active workspace's panel (workspace A is - // active), so opening a thread there should not change focused_thread. - // This prevents running threads in background workspaces from causing - // the selection highlight to jump around. - sidebar.read_with(cx, |sidebar, _cx| { - assert_active_thread( - sidebar, - &session_id_a, - "Opening a thread in a non-active panel should not change focused_thread", - ); - }); + cx.update(|cx| ::set_global(fs.clone(), cx)); - workspace_b.update_in(cx, |workspace, window, cx| { - workspace.focus_handle(cx).focus(window, cx); - }); - cx.run_until_parked(); + 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; - sidebar.read_with(cx, |sidebar, _cx| { - assert_active_thread( - sidebar, - &session_id_a, - "Defocusing the sidebar should not change focused_thread", - ); - }); + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; - // Switching workspaces via the multi_workspace (simulates clicking - // a workspace header) should clear focused_thread. - multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned(); - if let Some(workspace) = workspace { - mw.activate(workspace, window, cx); - } + 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); + + // Add agent panel to the main workspace. + let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + add_agent_panel(&main_workspace, cx); + + // Open the linked worktree as a separate workspace. + let wt_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) }); + add_agent_panel(&wt_workspace, cx); cx.run_until_parked(); - sidebar.read_with(cx, |sidebar, _cx| { - assert_active_thread( - sidebar, - &session_id_b2, - "Switching workspace should seed focused_thread from the new active panel", - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The seeded thread should be present in the entries" + // Both workspaces should share the same project group key [/project]. + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!( + mw.project_group_keys().count(), + 1, + "should have 1 project group key before add" ); + assert_eq!(mw.workspaces().count(), 2, "should have 2 workspaces"); }); - // ── 8. Focusing the agent panel thread keeps focused_thread ──── - // Workspace B still has session_id_b2 loaded in the agent panel. - // Clicking into the thread (simulated by focusing its view) should - // keep focused_thread since it was already seeded on workspace switch. - panel_b.update_in(cx, |panel, window, cx| { - if let Some(thread_view) = panel.active_conversation_view() { - thread_view.read(cx).focus_handle(cx).focus(window, cx); - } - }); - cx.run_until_parked(); + // Save threads against each workspace. + save_named_thread_metadata("main-thread", "Main Thread", &main_project, cx).await; + save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await; - sidebar.read_with(cx, |sidebar, _cx| { - assert_active_thread( - sidebar, - &session_id_b2, - "Focusing the agent panel thread should set focused_thread", - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The focused thread should be present in the entries" + // Verify both threads are under the old key [/project]. + let old_key_paths = PathList::new(&[PathBuf::from("/project")]); + cx.update(|_window, cx| { + let store = ThreadMetadataStore::global(cx).read(cx); + assert_eq!( + store.entries_for_main_worktree_path(&old_key_paths).count(), + 2, + "should have 2 threads under old key before add" ); }); -} - -#[gpui::test] -async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/project-a", cx).await; - let fs = cx.update(|cx| ::global(cx)); - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - // Start a thread and send a message so it has history. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, &project, cx).await; + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); cx.run_until_parked(); - // Verify the thread appears in the sidebar. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello *",] + vec![ + // + "v [project]", + " [~ Draft {wt-feature}] (active)", + " Worktree Thread {wt-feature}", + " Main Thread", + ] ); - // The "New Thread" button should NOT be in "active/draft" state - // because the panel has a thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), - "Panel has a thread with messages, so active_entry should be Thread, got {:?}", - sidebar.active_entry, - ); - }); - - // Now add a second folder to the workspace, changing the path_list. - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - project + // Add /other-project as a folder to the main workspace. + main_project .update(cx, |project, cx| { - project.find_or_create_worktree("/project-b", true, cx) + project.find_or_create_worktree("/other-project", true, cx) }) .await .expect("should add worktree"); cx.run_until_parked(); - // The workspace path_list is now [project-a, project-b]. The active - // thread's metadata was re-saved with the new paths by the agent panel's - // project subscription, so it stays visible under the updated group. - // The old [project-a] group persists in the sidebar (empty) because - // project_group_keys is append-only. + // The linked worktree workspace should have gotten the new folder too. + let wt_worktree_count = + worktree_project.read_with(cx, |project, cx| project.visible_worktrees(cx).count()); assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a, project-b]", // - " Hello *", - "v [project-a]", - ] + wt_worktree_count, 2, + "linked worktree project should have gotten the new folder" ); - // The "New Thread" button must still be clickable (not stuck in - // "active/draft" state). Verify that `active_thread_is_draft` is - // false — the panel still has the old thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), - "After adding a folder the panel still has a thread with messages, \ - so active_entry should be Thread, got {:?}", - sidebar.active_entry, + // Both workspaces should still exist under one key. + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().count(), 2, "both workspaces should survive"); + assert_eq!( + mw.project_group_keys().count(), + 1, + "should still have 1 project group key" ); }); - // Actually click "New Thread" by calling create_new_thread and - // verify a new draft is created. - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.create_new_thread(&workspace, window, cx); + // Threads should have been migrated to the new key. + let new_key_paths = + PathList::new(&[PathBuf::from("/other-project"), PathBuf::from("/project")]); + cx.update(|_window, cx| { + let store = ThreadMetadataStore::global(cx).read(cx); + assert_eq!( + store.entries_for_main_worktree_path(&old_key_paths).count(), + 0, + "should have 0 threads under old key after migration" + ); + assert_eq!( + store.entries_for_main_worktree_path(&new_key_paths).count(), + 2, + "should have 2 threads under new key after migration" + ); }); + + // Both threads should still be visible in the sidebar. + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); cx.run_until_parked(); - // After creating a new thread, the panel should now be in draft - // state (no messages on the new thread). - sidebar.read_with(cx, |sidebar, _cx| { - assert_active_draft( - sidebar, - &workspace, - "After creating a new thread active_entry should be Draft", - ); - }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [other-project, project]", + " [~ Draft {project:wt-feature}] (active)", + " Worktree Thread {project:wt-feature}", + " Main Thread", + ] + ); } #[gpui::test] @@ -2490,7 +3295,11 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] + vec![ + // + "v [my-project]", + " Hello * (active)", + ] ); // Simulate cmd-n @@ -2505,7 +3314,12 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [~ Draft]", " Hello *"], + vec![ + // + "v [my-project]", + " [~ Draft] (active)", + " Hello *", + ], "After Cmd-N the sidebar should show a highlighted Draft entry" ); @@ -2538,7 +3352,11 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] + vec![ + // + "v [my-project]", + " Hello * (active)", + ] ); // Open a new draft thread via a server connection. This gives the @@ -2550,7 +3368,12 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [~ Draft]", " Hello *"], + vec![ + // + "v [my-project]", + " [~ Draft] (active)", + " Hello *", + ], ); let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); @@ -2644,7 +3467,11 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} *"] + vec![ + // + "v [project]", + " Hello {wt-feature-a} * (active)", + ] ); // Simulate Cmd-N in the worktree workspace. @@ -2659,9 +3486,10 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project]", - " [~ Draft {wt-feature-a}]", - " Hello {wt-feature-a} *" + " [~ Draft {wt-feature-a}] (active)", + " Hello {wt-feature-a} *", ], "After Cmd-N in an absorbed worktree, the sidebar should show \ a highlighted Draft entry under the main repo header" @@ -2736,7 +3564,11 @@ async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Fix Bug {rosewood} <== selected"], + vec![ + // + "v [project]", + " Fix Bug {rosewood} <== selected", + ], ); } @@ -2757,16 +3589,28 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - // Save a thread against a worktree path that doesn't exist yet. - save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await; + // Save a thread against a worktree path with the correct main + // worktree association (as if the git state had been resolved). + save_thread_metadata_with_main_paths( + "wt-thread", + "Worktree Thread", + PathList::new(&[PathBuf::from("/wt/rosewood")]), + PathList::new(&[PathBuf::from("/project")]), + cx, + ); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Thread is not visible yet — no worktree knows about this path. + // Thread is visible because its main_worktree_paths match the group. + // The chip name is derived from the path even before git discovery. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]"] + vec![ + // + "v [project]", + " Worktree Thread {rosewood}", + ] ); // Now add the worktree to the git state and trigger a rescan. @@ -2787,7 +3631,11 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Worktree Thread {rosewood}",] + vec![ + // + "v [project]", + " Worktree Thread {rosewood}", + ] ); } @@ -2857,6 +3705,7 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project]", " Thread A {wt-feature-a}", " Thread B {wt-feature-b}", @@ -2878,6 +3727,7 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project]", " Thread A {wt-feature-a}", " Thread B {wt-feature-b}", @@ -2953,6 +3803,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project]", " [+ New Thread {wt-feature-b}]", " Thread A {wt-feature-a}", @@ -3032,8 +3883,9 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project_a, project_b]", - " Cross Worktree Thread {olivetti}, {selectric}", + " Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}", ] ); } @@ -3105,6 +3957,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project_a, project_b]", " Same Branch Thread {olivetti}", ] @@ -3209,8 +4062,9 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp assert_eq!( entries, vec![ + // "v [project]", - " [~ Draft]", + " [~ Draft] (active)", " Hello {wt-feature-a} * (running)", ] ); @@ -3296,8 +4150,9 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [project]", - " [~ Draft]", + " [~ Draft] (active)", " Hello {wt-feature-a} * (running)", ] ); @@ -3307,7 +4162,12 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " [~ Draft]", " Hello {wt-feature-a} * (!)",] + vec![ + // + "v [project]", + " [~ Draft] (active)", + " Hello {wt-feature-a} * (!)", + ] ); } @@ -3363,7 +4223,11 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut // Thread should appear under the main repo with a worktree chip. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], + vec![ + // + "v [project]", + " WT Thread {wt-feature-a}", + ], ); // Only 1 workspace should exist. @@ -3452,7 +4316,11 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], + vec![ + // + "v [project]", + " WT Thread {wt-feature-a}", + ], ); focus_sidebar(&sidebar, cx); @@ -4495,6 +5363,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ + // "v [other, project]", "v [project]", " Worktree Thread {wt-feature-a}", @@ -6282,19 +7151,23 @@ mod property_test { SwitchToThread { index: usize }, SwitchToProjectGroup { index: usize }, AddLinkedWorktree { project_group_index: usize }, + AddWorktreeToProject { project_group_index: usize }, + RemoveWorktreeFromProject { project_group_index: usize }, } - // Distribution (out of 20 slots): - // SaveThread: 5 slots (~25%) - // SaveWorktreeThread: 2 slots (~10%) - // ToggleAgentPanel: 1 slot (~5%) - // CreateDraftThread: 1 slot (~5%) - // AddProject: 1 slot (~5%) - // ArchiveThread: 2 slots (~10%) - // SwitchToThread: 2 slots (~10%) - // SwitchToProjectGroup: 2 slots (~10%) - // AddLinkedWorktree: 4 slots (~20%) - const DISTRIBUTION_SLOTS: u32 = 20; + // Distribution (out of 24 slots): + // SaveThread: 5 slots (~21%) + // SaveWorktreeThread: 2 slots (~8%) + // ToggleAgentPanel: 1 slot (~4%) + // CreateDraftThread: 1 slot (~4%) + // AddProject: 1 slot (~4%) + // ArchiveThread: 2 slots (~8%) + // SwitchToThread: 2 slots (~8%) + // SwitchToProjectGroup: 2 slots (~8%) + // AddLinkedWorktree: 4 slots (~17%) + // AddWorktreeToProject: 2 slots (~8%) + // RemoveWorktreeFromProject: 2 slots (~8%) + const DISTRIBUTION_SLOTS: u32 = 24; impl TestState { fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation { @@ -6336,6 +7209,18 @@ mod property_test { 16..=19 => Operation::SaveThread { project_group_index: extra % project_group_count, }, + 20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject { + project_group_index: extra % project_group_count, + }, + 20..=21 => Operation::SaveThread { + project_group_index: extra % project_group_count, + }, + 22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject { + project_group_index: extra % project_group_count, + }, + 22..=23 => Operation::SaveThread { + project_group_index: extra % project_group_count, + }, _ => unreachable!(), } } @@ -6593,6 +7478,57 @@ mod property_test { main_workspace_path: main_path.clone(), }); } + Operation::AddWorktreeToProject { + project_group_index, + } => { + let workspace = multi_workspace.read_with(cx, |mw, cx| { + let key = mw.project_group_keys().nth(project_group_index).unwrap(); + mw.workspaces_for_project_group(key, cx).next().cloned() + }); + let Some(workspace) = workspace else { return }; + let project = workspace.read_with(cx, |ws, _| ws.project().clone()); + + let new_path = state.next_workspace_path(); + state + .fs + .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} })) + .await; + + let result = project + .update(cx, |project, cx| { + project.find_or_create_worktree(&new_path, true, cx) + }) + .await; + if result.is_err() { + return; + } + cx.run_until_parked(); + } + Operation::RemoveWorktreeFromProject { + project_group_index, + } => { + let workspace = multi_workspace.read_with(cx, |mw, cx| { + let key = mw.project_group_keys().nth(project_group_index).unwrap(); + mw.workspaces_for_project_group(key, cx).next().cloned() + }); + let Some(workspace) = workspace else { return }; + let project = workspace.read_with(cx, |ws, _| ws.project().clone()); + + let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count()); + if worktree_count <= 1 { + return; + } + + let worktree_id = project.read_with(cx, |p, cx| { + p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id()) + }); + if let Some(worktree_id) = worktree_id { + project.update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + cx.run_until_parked(); + } + } } } @@ -6620,6 +7556,7 @@ mod property_test { verify_all_threads_are_shown(sidebar, cx)?; verify_active_state_matches_current_workspace(sidebar, cx)?; verify_all_workspaces_are_reachable(sidebar, cx)?; + verify_workspace_group_key_integrity(sidebar, cx)?; Ok(()) } @@ -6871,6 +7808,15 @@ mod property_test { Ok(()) } + fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + multi_workspace + .read(cx) + .assert_project_group_key_integrity(cx) + } + #[gpui::property_test(config = ProptestConfig { cases: 50, ..Default::default() diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index f4e8b47399e1420a4b01d380ad4a6532a0934a2d..bc9a5d59c74aa1cadc60ecbcb1f08b2afc3f3abd 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -101,6 +101,10 @@ pub enum MultiWorkspaceEvent { ActiveWorkspaceChanged, WorkspaceAdded(Entity), WorkspaceRemoved(EntityId), + ProjectGroupKeyChanged { + old_key: ProjectGroupKey, + new_key: ProjectGroupKey, + }, } pub enum SidebarEvent { @@ -302,7 +306,7 @@ pub struct MultiWorkspace { workspaces: Vec>, active_workspace: ActiveWorkspace, project_group_keys: Vec, - provisional_project_group_keys: HashMap, + workspace_group_keys: HashMap, sidebar: Option>, sidebar_open: bool, sidebar_overlay: Option, @@ -355,7 +359,7 @@ impl MultiWorkspace { Self { window_id: window.window_handle().window_id(), project_group_keys: Vec::new(), - provisional_project_group_keys: HashMap::default(), + workspace_group_keys: HashMap::default(), workspaces: Vec::new(), active_workspace: ActiveWorkspace::Transient(workspace), sidebar: None, @@ -559,19 +563,11 @@ impl MultiWorkspace { cx.subscribe_in(&project, window, { let workspace = workspace.downgrade(); move |this, _project, event, _window, cx| match event { - project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { + project::Event::WorktreeAdded(_) + | project::Event::WorktreeRemoved(_) + | project::Event::WorktreeUpdatedRootRepoCommonDir(_) => { if let Some(workspace) = workspace.upgrade() { - this.add_project_group_key(workspace.read(cx).project_group_key(cx)); - } - } - project::Event::WorktreeUpdatedRootRepoCommonDir(_) => { - if let Some(workspace) = workspace.upgrade() { - this.maybe_clear_provisional_project_group_key(&workspace, cx); - this.add_project_group_key( - this.project_group_key_for_workspace(&workspace, cx), - ); - this.remove_stale_project_group_keys(cx); - cx.notify(); + this.handle_workspace_key_change(&workspace, cx); } } _ => {} @@ -587,7 +583,111 @@ impl MultiWorkspace { .detach(); } - pub fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) { + fn handle_workspace_key_change( + &mut self, + workspace: &Entity, + cx: &mut Context, + ) { + let workspace_id = workspace.entity_id(); + let old_key = self.project_group_key_for_workspace(workspace, cx); + let new_key = workspace.read(cx).project_group_key(cx); + + if new_key.path_list().paths().is_empty() || old_key == new_key { + return; + } + + let active_workspace = self.workspace().clone(); + + self.set_workspace_group_key(workspace, new_key.clone()); + + let changed_root_paths = workspace.read(cx).root_paths(cx); + let old_paths = old_key.path_list().paths(); + let new_paths = new_key.path_list().paths(); + + // Remove workspaces that already had the new key and have the same + // root paths (true duplicates that this workspace is replacing). + // + // NOTE: These are dropped without prompting for unsaved changes because + // the user explicitly added a folder that makes this workspace + // identical to the duplicate — they are intentionally overwriting it. + let duplicate_workspaces: Vec> = self + .workspaces + .iter() + .filter(|ws| { + ws.entity_id() != workspace_id + && self.project_group_key_for_workspace(ws, cx) == new_key + && ws.read(cx).root_paths(cx) == changed_root_paths + }) + .cloned() + .collect(); + + if duplicate_workspaces.contains(&active_workspace) { + // The active workspace is among the duplicates — drop the + // incoming workspace instead so the user stays where they are. + self.detach_workspace(workspace, cx); + self.workspaces.retain(|w| w != workspace); + } else { + for ws in &duplicate_workspaces { + self.detach_workspace(ws, cx); + self.workspaces.retain(|w| w != ws); + } + } + + // Propagate folder adds/removes to linked worktree siblings + // (different root paths, same old key) so they stay in the group. + let group_workspaces: Vec> = self + .workspaces + .iter() + .filter(|ws| { + ws.entity_id() != workspace_id + && self.project_group_key_for_workspace(ws, cx) == old_key + }) + .cloned() + .collect(); + + for workspace in &group_workspaces { + // Pre-set this to stop later WorktreeAdded events from triggering + self.set_workspace_group_key(&workspace, new_key.clone()); + + let project = workspace.read(cx).project().clone(); + + for added_path in new_paths.iter().filter(|p| !old_paths.contains(p)) { + project + .update(cx, |project, cx| { + project.find_or_create_worktree(added_path, true, cx) + }) + .detach_and_log_err(cx); + } + + for removed_path in old_paths.iter().filter(|p| !new_paths.contains(p)) { + project.update(cx, |project, cx| { + project.remove_worktree_for_main_worktree_path(removed_path, cx); + }); + } + } + + // Restore the active workspace after removals may have shifted + // the index. If the previously active workspace was removed, + // fall back to the workspace whose key just changed. + if let ActiveWorkspace::Persistent(_) = &self.active_workspace { + let target = if self.workspaces.contains(&active_workspace) { + &active_workspace + } else { + workspace + }; + if let Some(new_index) = self.workspaces.iter().position(|ws| ws == target) { + self.active_workspace = ActiveWorkspace::Persistent(new_index); + } + } + + self.remove_stale_project_group_keys(cx); + + cx.emit(MultiWorkspaceEvent::ProjectGroupKeyChanged { old_key, new_key }); + self.serialize(cx); + cx.notify(); + } + + fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) { if project_group_key.path_list().paths().is_empty() { return; } @@ -598,12 +698,12 @@ impl MultiWorkspace { self.project_group_keys.insert(0, project_group_key); } - pub fn set_provisional_project_group_key( + pub(crate) fn set_workspace_group_key( &mut self, workspace: &Entity, project_group_key: ProjectGroupKey, ) { - self.provisional_project_group_keys + self.workspace_group_keys .insert(workspace.entity_id(), project_group_key.clone()); self.add_project_group_key(project_group_key); } @@ -613,28 +713,12 @@ impl MultiWorkspace { workspace: &Entity, cx: &App, ) -> ProjectGroupKey { - self.provisional_project_group_keys + self.workspace_group_keys .get(&workspace.entity_id()) .cloned() .unwrap_or_else(|| workspace.read(cx).project_group_key(cx)) } - fn maybe_clear_provisional_project_group_key( - &mut self, - workspace: &Entity, - cx: &App, - ) { - let live_key = workspace.read(cx).project_group_key(cx); - if self - .provisional_project_group_keys - .get(&workspace.entity_id()) - .is_some_and(|key| *key == live_key) - { - self.provisional_project_group_keys - .remove(&workspace.entity_id()); - } - } - fn remove_stale_project_group_keys(&mut self, cx: &App) { let workspace_keys: HashSet = self .workspaces @@ -1045,7 +1129,6 @@ impl MultiWorkspace { self.promote_transient(old, cx); } else { self.detach_workspace(&old, cx); - cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id())); } } } else { @@ -1056,7 +1139,6 @@ impl MultiWorkspace { }); if let Some(old) = self.active_workspace.set_transient(workspace) { self.detach_workspace(&old, cx); - cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id())); } } @@ -1083,7 +1165,7 @@ impl MultiWorkspace { /// Returns the index of the newly inserted workspace. fn promote_transient(&mut self, workspace: Entity, cx: &mut Context) -> usize { let project_group_key = self.project_group_key_for_workspace(&workspace, cx); - self.add_project_group_key(project_group_key); + self.set_workspace_group_key(&workspace, project_group_key); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); self.workspaces.len() - 1 @@ -1099,10 +1181,10 @@ impl MultiWorkspace { for workspace in std::mem::take(&mut self.workspaces) { if workspace != active { self.detach_workspace(&workspace, cx); - cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id())); } } self.project_group_keys.clear(); + self.workspace_group_keys.clear(); self.active_workspace = ActiveWorkspace::Transient(active); cx.notify(); } @@ -1128,7 +1210,7 @@ impl MultiWorkspace { workspace.set_multi_workspace(weak_self, cx); }); - self.add_project_group_key(project_group_key); + self.set_workspace_group_key(&workspace, project_group_key); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); cx.notify(); @@ -1136,10 +1218,12 @@ impl MultiWorkspace { } } - /// Clears session state and DB binding for a workspace that is being - /// removed or replaced. The DB row is preserved so the workspace still - /// appears in the recent-projects list. + /// Detaches a workspace: clears session state, DB binding, cached + /// group key, and emits `WorkspaceRemoved`. The DB row is preserved + /// so the workspace still appears in the recent-projects list. fn detach_workspace(&mut self, workspace: &Entity, cx: &mut Context) { + self.workspace_group_keys.remove(&workspace.entity_id()); + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id())); workspace.update(cx, |workspace, _cx| { workspace.session_id.take(); workspace._schedule_serialize_workspace.take(); @@ -1313,6 +1397,46 @@ impl MultiWorkspace { tasks } + #[cfg(any(test, feature = "test-support"))] + pub fn assert_project_group_key_integrity(&self, cx: &App) -> anyhow::Result<()> { + let stored_keys: HashSet<&ProjectGroupKey> = self.project_group_keys().collect(); + + let workspace_group_keys: HashSet<&ProjectGroupKey> = + self.workspace_group_keys.values().collect(); + let extra_keys = &workspace_group_keys - &stored_keys; + anyhow::ensure!( + extra_keys.is_empty(), + "workspace_group_keys values not in project_group_keys: {:?}", + extra_keys, + ); + + let cached_ids: HashSet = self.workspace_group_keys.keys().copied().collect(); + let workspace_ids: HashSet = + self.workspaces.iter().map(|ws| ws.entity_id()).collect(); + anyhow::ensure!( + cached_ids == workspace_ids, + "workspace_group_keys entity IDs don't match workspaces.\n\ + only in cache: {:?}\n\ + only in workspaces: {:?}", + &cached_ids - &workspace_ids, + &workspace_ids - &cached_ids, + ); + + for workspace in self.workspaces() { + let live_key = workspace.read(cx).project_group_key(cx); + let cached_key = &self.workspace_group_keys[&workspace.entity_id()]; + anyhow::ensure!( + *cached_key == live_key, + "workspace {:?} has live key {:?} but cached key {:?}", + workspace.entity_id(), + live_key, + cached_key, + ); + } + + Ok(()) + } + #[cfg(any(test, feature = "test-support"))] pub fn set_random_database_id(&mut self, cx: &mut Context) { self.workspace().update(cx, |workspace, _cx| { @@ -1471,7 +1595,6 @@ impl MultiWorkspace { for workspace in &removed_workspaces { this.detach_workspace(workspace, cx); - cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id())); } let removed_any = !removed_workspaces.is_empty(); diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 259346fe097826b3dcc19fb8fad0b8f07ddd0488..9cab28c0ca4ab34b2189985e898285dd82dd4f32 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -185,157 +185,3 @@ async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) { ); }); } - -#[gpui::test] -async fn test_project_group_keys_on_worktree_added(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; - fs.insert_tree("/root_b", json!({ "file.txt": "" })).await; - let project = Project::test(fs, ["/root_a".as_ref()], cx).await; - - let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - - multi_workspace.update(cx, |mw, cx| { - mw.open_sidebar(cx); - }); - - // Add a second worktree to the same project. - let (worktree, _) = project - .update(cx, |project, cx| { - project.find_or_create_worktree("/root_b", true, cx) - }) - .await - .unwrap(); - worktree - .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - cx.run_until_parked(); - - let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); - assert_ne!( - initial_key, updated_key, - "key should change after adding a worktree" - ); - - multi_workspace.read_with(cx, |mw, _cx| { - let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); - assert_eq!( - keys.len(), - 2, - "should have both the original and updated key" - ); - assert_eq!(*keys[0], updated_key); - assert_eq!(*keys[1], initial_key); - }); -} - -#[gpui::test] -async fn test_project_group_keys_on_worktree_removed(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; - fs.insert_tree("/root_b", json!({ "file.txt": "" })).await; - let project = Project::test(fs, ["/root_a".as_ref(), "/root_b".as_ref()], cx).await; - - let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - - multi_workspace.update(cx, |mw, cx| { - mw.open_sidebar(cx); - }); - - // Remove one worktree. - let worktree_b_id = project.read_with(cx, |project, cx| { - project - .worktrees(cx) - .find(|wt| wt.read(cx).root_name().as_unix_str() == "root_b") - .unwrap() - .read(cx) - .id() - }); - project.update(cx, |project, cx| { - project.remove_worktree(worktree_b_id, cx); - }); - cx.run_until_parked(); - - let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); - assert_ne!( - initial_key, updated_key, - "key should change after removing a worktree" - ); - - multi_workspace.read_with(cx, |mw, _cx| { - let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); - assert_eq!( - keys.len(), - 2, - "should accumulate both the original and post-removal key" - ); - assert_eq!(*keys[0], updated_key); - assert_eq!(*keys[1], initial_key); - }); -} - -#[gpui::test] -async fn test_project_group_keys_across_multiple_workspaces_and_worktree_changes( - cx: &mut TestAppContext, -) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; - fs.insert_tree("/root_b", json!({ "file.txt": "" })).await; - fs.insert_tree("/root_c", json!({ "file.txt": "" })).await; - let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; - let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await; - - let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx)); - let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx)); - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - - multi_workspace.update(cx, |mw, cx| { - mw.open_sidebar(cx); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - multi_workspace.read_with(cx, |mw, _cx| { - assert_eq!(mw.project_group_keys().count(), 2); - }); - - // Now add a worktree to project_a. This should produce a third key. - let (worktree, _) = project_a - .update(cx, |project, cx| { - project.find_or_create_worktree("/root_c", true, cx) - }) - .await - .unwrap(); - worktree - .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - cx.run_until_parked(); - - let key_a_updated = project_a.read_with(cx, |p, cx| p.project_group_key(cx)); - assert_ne!(key_a, key_a_updated); - - multi_workspace.read_with(cx, |mw, _cx| { - let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); - assert_eq!( - keys.len(), - 3, - "should have key_a, key_b, and the updated key_a with root_c" - ); - assert_eq!(*keys[0], key_a_updated); - assert_eq!(*keys[1], key_b); - assert_eq!(*keys[2], key_a); - }); -} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 81224c0e2db520a278bfb21429e211ba9a4f09ae..d40b7abae0c036a5cdd227ec8a547bd3c10b262c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -9886,7 +9886,7 @@ async fn open_remote_project_inner( }); if let Some(project_group_key) = provisional_project_group_key.clone() { - multi_workspace.set_provisional_project_group_key(&new_workspace, project_group_key); + multi_workspace.set_workspace_group_key(&new_workspace, project_group_key); } multi_workspace.activate(new_workspace.clone(), window, cx); new_workspace