diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 69c5377465a420b2e9f64e16139736fe04b65e5a..127f746a9edd35bc3b62b489277980868faba1c8 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -477,36 +477,6 @@ 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 547d3bd83cf97986dbe0006a2454223fba255886..488127eb0bd04b064c2c6e3b1d8dc297ada9c477 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -283,8 +283,10 @@ impl ListEntry { } } ListEntry::ProjectHeader { key, .. } => multi_workspace - .workspaces_for_project_group(key, cx) + .workspaces() + .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list()) .cloned() + .into_iter() .collect(), ListEntry::ViewMore { .. } => Vec::new(), } @@ -363,81 +365,35 @@ 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. 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. +/// the worktree is the main checkout or a linked git worktree. fn worktree_info_from_thread_paths( folder_paths: &PathList, - 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() { + group_key: &project::ProjectGroupKey, +) -> impl Iterator { + let main_paths = group_key.path_list().paths(); + folder_paths.paths().iter().filter_map(|path| { let is_main = main_paths.iter().any(|mp| mp.as_path() == path.as_path()); if is_main { - let Some(name) = path.file_name() else { - continue; - }; - infos.push(WorktreeInfo { - name: SharedString::from(name.to_string_lossy().to_string()), + let name = path.file_name()?.to_string_lossy().to_string(); + Some(WorktreeInfo { + name: SharedString::from(name), full_path: SharedString::from(path.display().to_string()), highlight_positions: Vec::new(), kind: ui::WorktreeKind::Main, - }); + }) } else { - 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, + 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(), 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 @@ -524,16 +480,6 @@ 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(); @@ -966,21 +912,6 @@ 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() @@ -1018,28 +949,26 @@ 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 = 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(), - } - }; + 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(), + } + }; // Main code path: one query per group via main_worktree_paths. // The main_worktree_paths column is set on all new threads and @@ -1255,17 +1184,11 @@ 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 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, - ); + let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key); entries.push(ListEntry::DraftThread { key: group_key.clone(), workspace: None, - worktrees, + worktrees: worktrees.collect(), }); } } @@ -1289,14 +1212,9 @@ impl Sidebar { continue; } let ws_path_list = workspace_path_list(ws, cx); - 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); + let has_linked_worktrees = + worktree_info_from_thread_paths(&ws_path_list, &group_key) + .any(|wt| wt.kind == ui::WorktreeKind::Linked); if !has_linked_worktrees { continue; } @@ -1309,11 +1227,8 @@ impl Sidebar { if has_threads { continue; } - let worktrees = worktree_info_from_thread_paths( - &ws_path_list, - &ws_main_paths, - &linked_to_main, - ); + let worktrees: Vec = + worktree_info_from_thread_paths(&ws_path_list, &group_key).collect(); 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 420eae134e11309e4d1cc90c45335f7fce76bb22..8ced8d6f71f6d88ff24a522404417ef7db3a6a7c 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -251,32 +251,6 @@ 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); @@ -348,11 +322,6 @@ 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, @@ -369,7 +338,7 @@ fn visible_entries_as_strings( } ListEntry::Thread(thread) => { let title = thread.metadata.title.as_ref(); - let live = if thread.is_live { " *" } else { "" }; + let active = if thread.is_live { " *" } else { "" }; let status_str = match thread.status { AgentThreadStatus::Running => " (running)", AgentThreadStatus::Error => " (error)", @@ -385,7 +354,7 @@ fn visible_entries_as_strings( "" }; let worktree = format_linked_worktree_chips(&thread.worktrees); - format!(" {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}") + format!(" {title}{worktree}{active}{status_str}{notified}{selected}") } ListEntry::ViewMore { is_fully_expanded, .. @@ -405,7 +374,7 @@ fn visible_entries_as_strings( if workspace.is_some() { format!(" [+ New Thread{}]{}", worktree, selected) } else { - format!(" [~ Draft{}]{}{}", worktree, active_indicator, selected) + format!(" [~ Draft{}]{}", worktree, selected) } } } @@ -574,10 +543,7 @@ 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]"] ); } @@ -613,7 +579,6 @@ 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", @@ -644,11 +609,7 @@ 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 @@ -659,11 +620,7 @@ 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",] ); } @@ -682,7 +639,6 @@ 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", @@ -793,11 +749,7 @@ 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 @@ -808,10 +760,7 @@ 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 @@ -822,11 +771,7 @@ 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"] ); } @@ -996,7 +941,6 @@ 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", @@ -1160,14 +1104,10 @@ 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 + // Focus the sidebar and select the header (index 0) focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); @@ -1179,10 +1119,7 @@ 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 @@ -1191,11 +1128,7 @@ 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",] ); } @@ -1246,11 +1179,7 @@ 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. @@ -1264,10 +1193,7 @@ 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 @@ -1276,11 +1202,7 @@ 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 @@ -1307,11 +1229,7 @@ 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 @@ -1321,10 +1239,7 @@ 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"] ); } @@ -1338,10 +1253,7 @@ 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 @@ -1473,12 +1385,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { entries[1..].sort(); assert_eq!( entries, - vec![ - // - "v [my-project]", - " Hello * (active)", - " Hello * (running)", - ] + vec!["v [my-project]", " Hello *", " Hello * (running)",] ); } @@ -1571,11 +1478,7 @@ 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) (active)", - ] + vec!["v [project-a]", " Hello * (running)",] ); // Complete thread A's turn (transition Running → Completed). @@ -1585,11 +1488,7 @@ 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 * (!) (active)", - ] + vec!["v [project-a]", " Hello * (!)",] ); } @@ -1629,7 +1528,6 @@ 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", @@ -1642,11 +1540,7 @@ 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. @@ -1681,7 +1575,6 @@ 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", ] @@ -1692,7 +1585,6 @@ 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", ] @@ -1723,12 +1615,7 @@ 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. @@ -1736,11 +1623,7 @@ 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. @@ -1750,7 +1633,6 @@ 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", @@ -1807,7 +1689,6 @@ 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", @@ -1818,11 +1699,7 @@ 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. @@ -1838,7 +1715,6 @@ 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", @@ -1898,7 +1774,6 @@ 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", @@ -1910,11 +1785,7 @@ 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 @@ -1924,11 +1795,7 @@ 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. @@ -1937,7 +1804,6 @@ 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", @@ -1951,7 +1817,6 @@ 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", @@ -2001,11 +1866,7 @@ 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")), @@ -2041,21 +1902,14 @@ 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",] ); } @@ -2089,7 +1943,6 @@ 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", @@ -2102,7 +1955,6 @@ 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", @@ -2114,7 +1966,6 @@ 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", @@ -2155,11 +2006,7 @@ 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. @@ -2220,12 +2067,7 @@ 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. @@ -2277,11 +2119,7 @@ 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 * (active)", - ] + vec!["v [my-project]", " Hello *"] ); // Simulate the agent generating a title. The notification chain is: @@ -2303,11 +2141,7 @@ 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 * (active)", - ] + vec!["v [my-project]", " Friendly Greeting with AI *"] ); } @@ -2449,825 +2283,186 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { sidebar.read_with(cx, |sidebar, _cx| { assert_active_thread( sidebar, - &session_id_a, - "Switching workspace should seed focused_thread from the new active panel", - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The seeded thread should be present in the entries" - ); - }); - - 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; - - cx.update(|cx| ::set_global(fs.clone(), cx)); + &session_id_a, + "Switching workspace should seed focused_thread from the new active panel", + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The seeded thread should be present in the entries" + ); + }); - 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; + 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(); - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .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", + ); + }); - 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); + workspace_b.update_in(cx, |workspace, window, cx| { + workspace.focus_handle(cx).focus(window, cx); + }); + cx.run_until_parked(); - // 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); + sidebar.read_with(cx, |sidebar, _cx| { + assert_active_thread( + sidebar, + &session_id_a, + "Defocusing the sidebar should not change focused_thread", + ); + }); - // 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) + // 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); + } }); - add_agent_panel(&wt_workspace, cx); cx.run_until_parked(); - // 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" + 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" ); - assert_eq!(mw.workspaces().count(), 2, "should have 2 workspaces"); }); - // 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; + // ── 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(); - // 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" + 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); - sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(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]", - " [~ Draft {wt-feature}] (active)", - " Worktree Thread {wt-feature}", - " Main Thread", - ] + vec!["v [project-a]", " Hello *",] ); - // Add /other-project as a folder to the main workspace. - main_project + // 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("/other-project", true, cx) + project.find_or_create_worktree("/project-b", true, cx) }) .await .expect("should add worktree"); cx.run_until_parked(); - // 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()); + // 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. assert_eq!( - wt_worktree_count, 2, - "linked worktree project should have gotten the new folder" + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a, project-b]", // + " Hello *", + "v [project-a]", + ] ); - // 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" + // 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, ); }); - // 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" - ); + // 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); }); - - // Both threads should still be visible in the sidebar. - 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 [other-project, project]", - " [~ Draft {project:wt-feature}] (active)", - " Worktree Thread {project:wt-feature}", - " Main Thread", - ] - ); + // 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] @@ -3295,11 +2490,7 @@ 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 * (active)", - ] + vec!["v [my-project]", " Hello *"] ); // Simulate cmd-n @@ -3314,12 +2505,7 @@ 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] (active)", - " Hello *", - ], + vec!["v [my-project]", " [~ Draft]", " Hello *"], "After Cmd-N the sidebar should show a highlighted Draft entry" ); @@ -3352,11 +2538,7 @@ 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 * (active)", - ] + vec!["v [my-project]", " Hello *"] ); // Open a new draft thread via a server connection. This gives the @@ -3368,12 +2550,7 @@ 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] (active)", - " Hello *", - ], + vec!["v [my-project]", " [~ Draft]", " Hello *"], ); let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); @@ -3467,11 +2644,7 @@ 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} * (active)", - ] + vec!["v [project]", " Hello {wt-feature-a} *"] ); // Simulate Cmd-N in the worktree workspace. @@ -3486,10 +2659,9 @@ 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}] (active)", - " Hello {wt-feature-a} *", + " [~ Draft {wt-feature-a}]", + " Hello {wt-feature-a} *" ], "After Cmd-N in an absorbed worktree, the sidebar should show \ a highlighted Draft entry under the main repo header" @@ -3564,11 +2736,7 @@ 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"], ); } @@ -3589,28 +2757,16 @@ 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 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, - ); + // Save a thread against a worktree path that doesn't exist yet. + save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Thread is visible because its main_worktree_paths match the group. - // The chip name is derived from the path even before git discovery. + // Thread is not visible yet — no worktree knows about this path. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [project]", - " Worktree Thread {rosewood}", - ] + vec!["v [project]"] ); // Now add the worktree to the git state and trigger a rescan. @@ -3631,11 +2787,7 @@ 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}",] ); } @@ -3705,7 +2857,6 @@ 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}", @@ -3727,7 +2878,6 @@ 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}", @@ -3803,7 +2953,6 @@ 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}", @@ -3883,9 +3032,8 @@ 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 {project_a:olivetti}, {project_b:selectric}", + " Cross Worktree Thread {olivetti}, {selectric}", ] ); } @@ -3957,7 +3105,6 @@ 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}", ] @@ -4062,9 +3209,8 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp assert_eq!( entries, vec![ - // "v [project]", - " [~ Draft] (active)", + " [~ Draft]", " Hello {wt-feature-a} * (running)", ] ); @@ -4150,9 +3296,8 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ - // "v [project]", - " [~ Draft] (active)", + " [~ Draft]", " Hello {wt-feature-a} * (running)", ] ); @@ -4162,12 +3307,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [project]", - " [~ Draft] (active)", - " Hello {wt-feature-a} * (!)", - ] + vec!["v [project]", " [~ Draft]", " Hello {wt-feature-a} * (!)",] ); } @@ -4223,11 +3363,7 @@ 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. @@ -4316,11 +3452,7 @@ 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); @@ -5363,7 +4495,6 @@ 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}", @@ -7151,23 +6282,19 @@ 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 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; + // 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; impl TestState { fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation { @@ -7209,18 +6336,6 @@ 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!(), } } @@ -7478,57 +6593,6 @@ 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(); - } - } } } @@ -7556,7 +6620,6 @@ 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(()) } @@ -7808,15 +6871,6 @@ 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 bc9a5d59c74aa1cadc60ecbcb1f08b2afc3f3abd..f4e8b47399e1420a4b01d380ad4a6532a0934a2d 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -101,10 +101,6 @@ pub enum MultiWorkspaceEvent { ActiveWorkspaceChanged, WorkspaceAdded(Entity), WorkspaceRemoved(EntityId), - ProjectGroupKeyChanged { - old_key: ProjectGroupKey, - new_key: ProjectGroupKey, - }, } pub enum SidebarEvent { @@ -306,7 +302,7 @@ pub struct MultiWorkspace { workspaces: Vec>, active_workspace: ActiveWorkspace, project_group_keys: Vec, - workspace_group_keys: HashMap, + provisional_project_group_keys: HashMap, sidebar: Option>, sidebar_open: bool, sidebar_overlay: Option, @@ -359,7 +355,7 @@ impl MultiWorkspace { Self { window_id: window.window_handle().window_id(), project_group_keys: Vec::new(), - workspace_group_keys: HashMap::default(), + provisional_project_group_keys: HashMap::default(), workspaces: Vec::new(), active_workspace: ActiveWorkspace::Transient(workspace), sidebar: None, @@ -563,11 +559,19 @@ 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::WorktreeUpdatedRootRepoCommonDir(_) => { + project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { if let Some(workspace) = workspace.upgrade() { - this.handle_workspace_key_change(&workspace, cx); + 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(); } } _ => {} @@ -583,111 +587,7 @@ impl MultiWorkspace { .detach(); } - 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) { + pub fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) { if project_group_key.path_list().paths().is_empty() { return; } @@ -698,12 +598,12 @@ impl MultiWorkspace { self.project_group_keys.insert(0, project_group_key); } - pub(crate) fn set_workspace_group_key( + pub fn set_provisional_project_group_key( &mut self, workspace: &Entity, project_group_key: ProjectGroupKey, ) { - self.workspace_group_keys + self.provisional_project_group_keys .insert(workspace.entity_id(), project_group_key.clone()); self.add_project_group_key(project_group_key); } @@ -713,12 +613,28 @@ impl MultiWorkspace { workspace: &Entity, cx: &App, ) -> ProjectGroupKey { - self.workspace_group_keys + self.provisional_project_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 @@ -1129,6 +1045,7 @@ impl MultiWorkspace { self.promote_transient(old, cx); } else { self.detach_workspace(&old, cx); + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id())); } } } else { @@ -1139,6 +1056,7 @@ impl MultiWorkspace { }); if let Some(old) = self.active_workspace.set_transient(workspace) { self.detach_workspace(&old, cx); + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id())); } } @@ -1165,7 +1083,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.set_workspace_group_key(&workspace, project_group_key); + self.add_project_group_key(project_group_key); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); self.workspaces.len() - 1 @@ -1181,10 +1099,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(); } @@ -1210,7 +1128,7 @@ impl MultiWorkspace { workspace.set_multi_workspace(weak_self, cx); }); - self.set_workspace_group_key(&workspace, project_group_key); + self.add_project_group_key(project_group_key); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); cx.notify(); @@ -1218,12 +1136,10 @@ impl MultiWorkspace { } } - /// 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. + /// 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. 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(); @@ -1397,46 +1313,6 @@ 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| { @@ -1595,6 +1471,7 @@ 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 9cab28c0ca4ab34b2189985e898285dd82dd4f32..259346fe097826b3dcc19fb8fad0b8f07ddd0488 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -185,3 +185,157 @@ 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 d40b7abae0c036a5cdd227ec8a547bd3c10b262c..81224c0e2db520a278bfb21429e211ba9a4f09ae 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_workspace_group_key(&new_workspace, project_group_key); + multi_workspace.set_provisional_project_group_key(&new_workspace, project_group_key); } multi_workspace.activate(new_workspace.clone(), window, cx); new_workspace