diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 499266bce6a5eeb1ccc5e58b17dca25fcb47178f..52aebde37aa60a17b1903d52a24d06a46bfdfbc1 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -16,9 +16,9 @@ use agent_ui::{ use chrono::{DateTime, Utc}; use editor::Editor; use gpui::{ - Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState, - Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle, linear_color_stop, - linear_gradient, list, prelude::*, px, + Action as _, AnyElement, App, Context, DismissEvent, Entity, FocusHandle, Focusable, + KeyContext, ListState, Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle, + linear_color_stop, linear_gradient, list, prelude::*, px, }; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, @@ -141,7 +141,12 @@ impl ActiveEntry { (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => { thread.metadata.session_id == *session_id } - (ActiveEntry::Draft(_workspace), ListEntry::DraftThread { .. }) => true, + ( + ActiveEntry::Draft(_), + ListEntry::DraftThread { + workspace: None, .. + }, + ) => true, _ => false, } } @@ -215,6 +220,7 @@ enum ListEntry { has_running_threads: bool, waiting_thread_count: usize, is_active: bool, + has_threads: bool, }, Thread(ThreadEntry), ViewMore { @@ -224,16 +230,9 @@ enum ListEntry { /// The user's active draft thread. Shows a prefix of the currently-typed /// prompt, or "Untitled Thread" if the prompt is empty. DraftThread { - worktrees: Vec, - }, - /// A convenience row for starting a new thread. Shown when a project group - /// has no threads, or when an open linked worktree workspace has no threads. - /// When `workspace` is `Some`, this entry is for a specific linked worktree - /// workspace and can be dismissed (removing that workspace). - NewThread { key: project::ProjectGroupKey, - worktrees: Vec, workspace: Option>, + worktrees: Vec, }, } @@ -256,35 +255,22 @@ impl ListEntry { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed(_) => Vec::new(), }, - ListEntry::DraftThread { .. } => { - vec![multi_workspace.workspace().clone()] - } - ListEntry::ProjectHeader { key, .. } => { - // The header only activates the main worktree workspace - // (the one whose root paths match the group key's path list). - multi_workspace - .workspaces() - .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list()) - .cloned() - .into_iter() - .collect() - } - ListEntry::NewThread { key, workspace, .. } => { - // When the NewThread entry is for a specific linked worktree - // workspace, that workspace is reachable. Otherwise fall back - // to the main worktree workspace. + ListEntry::DraftThread { workspace, .. } => { if let Some(ws) = workspace { vec![ws.clone()] } else { - multi_workspace - .workspaces() - .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list()) - .cloned() - .into_iter() - .collect() + // workspace: None means this is the active draft, + // which always lives on the current workspace. + vec![multi_workspace.workspace().clone()] } } - _ => Vec::new(), + ListEntry::ProjectHeader { key, .. } => multi_workspace + .workspaces() + .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list()) + .cloned() + .into_iter() + .collect(), + ListEntry::ViewMore { .. } => Vec::new(), } } } @@ -1040,6 +1026,17 @@ impl Sidebar { } } + let has_threads = if !threads.is_empty() { + true + } else { + let store = ThreadMetadataStore::global(cx).read(cx); + store + .entries_for_main_worktree_path(&path_list) + .next() + .is_some() + || store.entries_for_path(&path_list).next().is_some() + }; + if !query.is_empty() { let workspace_highlight_positions = fuzzy_match_positions(&query, &label).unwrap_or_default(); @@ -1078,6 +1075,7 @@ impl Sidebar { has_running_threads, waiting_thread_count, is_active, + has_threads, }); for thread in matched_threads { @@ -1096,6 +1094,7 @@ impl Sidebar { has_running_threads, waiting_thread_count, is_active, + has_threads, }); if is_collapsed { @@ -1108,32 +1107,31 @@ impl Sidebar { let ws_path_list = workspace_path_list(draft_ws, cx); let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key); entries.push(ListEntry::DraftThread { + key: group_key.clone(), + workspace: None, worktrees: worktrees.collect(), }); } } - // Emit NewThread entries: - // 1. When the group has zero threads (convenient affordance). - // 2. For each open linked worktree workspace in this group - // that has no threads (makes the workspace reachable and - // dismissable). - let group_has_no_threads = threads.is_empty() && !group_workspaces.is_empty(); - - if !is_draft_for_group && group_has_no_threads { - entries.push(ListEntry::NewThread { - key: group_key.clone(), - worktrees: Vec::new(), - workspace: None, - }); - } - - // Emit a NewThread for each open linked worktree workspace - // that has no threads. Skip the workspace if it's showing - // the active draft (it already has a DraftThread entry). - if !is_draft_for_group { + // Emit a DraftThread for each open linked worktree workspace + // that has no threads. Skip the specific workspace that is + // showing the active draft (it already has a DraftThread entry + // from the block above). + { + let draft_ws_id = if is_draft_for_group { + self.active_entry.as_ref().and_then(|e| match e { + ActiveEntry::Draft(ws) => Some(ws.entity_id()), + _ => None, + }) + } else { + None + }; let thread_store = ThreadMetadataStore::global(cx); for ws in &group_workspaces { + if Some(ws.entity_id()) == draft_ws_id { + continue; + } let ws_path_list = workspace_path_list(ws, cx); let has_linked_worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key) @@ -1152,10 +1150,11 @@ impl Sidebar { } let worktrees: Vec = worktree_info_from_thread_paths(&ws_path_list, &group_key).collect(); - entries.push(ListEntry::NewThread { + + entries.push(ListEntry::DraftThread { key: group_key.clone(), - worktrees, workspace: Some(ws.clone()), + worktrees, }); } } @@ -1295,6 +1294,7 @@ impl Sidebar { has_running_threads, waiting_thread_count, is_active: is_active_group, + has_threads, } => self.render_project_header( ix, false, @@ -1305,6 +1305,7 @@ impl Sidebar { *waiting_thread_count, *is_active_group, is_selected, + *has_threads, cx, ), ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx), @@ -1312,14 +1313,17 @@ impl Sidebar { key, is_fully_expanded, } => self.render_view_more(ix, key.path_list(), *is_fully_expanded, is_selected, cx), - ListEntry::DraftThread { worktrees, .. } => { - self.render_draft_thread(ix, is_active, worktrees, is_selected, cx) - } - ListEntry::NewThread { + ListEntry::DraftThread { key, - worktrees, workspace, - } => self.render_new_thread(ix, key, worktrees, workspace.as_ref(), is_selected, cx), + worktrees, + } => { + if workspace.is_some() { + self.render_new_thread(ix, key, worktrees, workspace.as_ref(), is_selected, cx) + } else { + self.render_draft_thread(ix, is_active, worktrees, is_selected, cx) + } + } }; if is_group_header_after_first { @@ -1369,6 +1373,7 @@ impl Sidebar { waiting_thread_count: usize, is_active: bool, is_focused: bool, + has_threads: bool, cx: &mut Context, ) -> AnyElement { let path_list = key.path_list(); @@ -1386,16 +1391,6 @@ impl Sidebar { (IconName::ChevronDown, "Collapse Project") }; - let has_new_thread_entry = self.contents.entries.get(ix + 1).is_some_and(|entry| { - matches!( - entry, - ListEntry::NewThread { .. } | ListEntry::DraftThread { .. } - ) - }); - let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx); - - let workspace = self.workspace_for_group(path_list, cx); - let path_list_for_toggle = path_list.clone(); let path_list_for_collapse = path_list.clone(); let view_more_expanded = self.expanded_groups.contains_key(path_list); @@ -1415,6 +1410,8 @@ impl Sidebar { .element_active .blend(color.element_background.opacity(0.2)); + let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix); + h_flex() .id(id) .group(&group_name) @@ -1433,7 +1430,6 @@ impl Sidebar { .justify_between() .child( h_flex() - .cursor_pointer() .relative() .min_w_0() .w_full() @@ -1486,13 +1482,13 @@ impl Sidebar { ) .child( h_flex() - .when(self.project_header_menu_ix != Some(ix), |this| { - this.visible_on_hover(group_name) + .when(!is_ellipsis_menu_open, |this| { + this.visible_on_hover(&group_name) }) .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .child(self.render_project_header_menu(ix, id_prefix, key, cx)) + .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx)) .when(view_more_expanded && !is_collapsed, |this| { this.child( IconButton::new( @@ -1514,57 +1510,70 @@ impl Sidebar { })), ) }) - .when_some( - workspace.filter(|_| show_new_thread_button), - |this, workspace| { - let path_list = path_list.clone(); - this.child( - IconButton::new( - SharedString::from(format!( - "{id_prefix}project-header-new-thread-{ix}", - )), - IconName::Plus, - ) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("New Thread")) - .on_click(cx.listener( - move |this, _, window, cx| { - this.collapsed_groups.remove(&path_list); - this.selection = None; - this.create_new_thread(&workspace, window, cx); - }, - )), - ) - }, - ), + .child({ + let path_list = path_list.clone(); + let focus_handle = self.focus_handle.clone(); + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-new-thread-{ix}", + )), + IconName::Plus, + ) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in("New Thread", &NewThread, &focus_handle, cx) + }) + .on_click(cx.listener( + move |this, _, window, cx| { + this.collapsed_groups.remove(&path_list); + this.selection = None; + let workspace = this + .active_workspace(cx) + .filter(|ws| { + let key = ws.read(cx).project_group_key(cx); + *key.path_list() == path_list + }) + .or_else(|| this.workspace_for_group(&path_list, cx)); + if let Some(workspace) = workspace { + this.create_new_thread(&workspace, window, cx); + } else { + this.open_workspace_for_group(&path_list, window, cx); + } + }, + )) + }), ) .map(|this| { - let path_list = path_list.clone(); - this.cursor_pointer() - .when(!is_active, |this| this.hover(|s| s.bg(hover_color))) - .tooltip(Tooltip::text("Open Workspace")) - .on_click(cx.listener(move |this, _, window, cx| { - if let Some(workspace) = this.workspace_for_group(&path_list, cx) { - this.active_entry = Some(ActiveEntry::Draft(workspace.clone())); - if let Some(multi_workspace) = this.multi_workspace.upgrade() { - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), window, cx); - }); - } - if AgentPanel::is_visible(&workspace, cx) { - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); - }); + if !has_threads && is_active { + this + } else { + let path_list = path_list.clone(); + this.cursor_pointer() + .when(!is_active, |this| this.hover(|s| s.bg(hover_color))) + .tooltip(Tooltip::text("Open Workspace")) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(workspace) = this.workspace_for_group(&path_list, cx) { + this.active_entry = Some(ActiveEntry::Draft(workspace.clone())); + if let Some(multi_workspace) = this.multi_workspace.upgrade() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), window, cx); + }); + } + if AgentPanel::is_visible(&workspace, cx) { + workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); + } + } else { + this.open_workspace_for_group(&path_list, window, cx); } - } else { - this.open_workspace_for_group(&path_list, window, cx); - } - })) + })) + } }) .into_any_element() } - fn render_project_header_menu( + fn render_project_header_ellipsis_menu( &self, ix: usize, id_prefix: &str, @@ -1590,72 +1599,75 @@ impl Sidebar { let multi_workspace = multi_workspace.clone(); let project_group_key = project_group_key.clone(); - let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, _cx| { - let mut menu = menu - .header("Project Folders") - .end_slot_action(Box::new(menu::EndSlot)); + let menu = + ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| { + let mut menu = menu + .header("Project Folders") + .end_slot_action(Box::new(menu::EndSlot)); - for path in project_group_key.path_list().paths() { - let Some(name) = path.file_name() else { - continue; - }; - let name: SharedString = name.to_string_lossy().into_owned().into(); - let path = path.clone(); - let project_group_key = project_group_key.clone(); - let multi_workspace = multi_workspace.clone(); - menu = menu.entry_with_end_slot_on_hover( - name.clone(), - None, - |_, _| {}, - IconName::Close, - "Remove Folder".into(), - move |_window, cx| { - multi_workspace - .update(cx, |multi_workspace, cx| { - multi_workspace.remove_folder_from_project_group( - &project_group_key, - &path, - cx, - ); - }) - .ok(); + for path in project_group_key.path_list().paths() { + let Some(name) = path.file_name() else { + continue; + }; + let name: SharedString = name.to_string_lossy().into_owned().into(); + let path = path.clone(); + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); + menu = menu.entry_with_end_slot_on_hover( + name.clone(), + None, + |_, _| {}, + IconName::Close, + "Remove Folder".into(), + move |_window, cx| { + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.remove_folder_from_project_group( + &project_group_key, + &path, + cx, + ); + }) + .ok(); + }, + ); + } + + let menu = menu.separator().entry( + "Add Folder to Project", + Some(Box::new(AddFolderToProject)), + { + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); + move |window, cx| { + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.prompt_to_add_folders_to_project_group( + &project_group_key, + window, + cx, + ); + }) + .ok(); + } }, ); - } - let menu = menu.separator().entry( - "Add Folder to Project", - Some(Box::new(AddFolderToProject)), - { - let project_group_key = project_group_key.clone(); - let multi_workspace = multi_workspace.clone(); - move |window, cx| { + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); + let weak_menu = menu_cx.weak_entity(); + menu.separator() + .entry("Remove Project", None, move |window, cx| { multi_workspace .update(cx, |multi_workspace, cx| { - multi_workspace.prompt_to_add_folders_to_project_group( - &project_group_key, - window, - cx, - ); + multi_workspace + .remove_project_group(&project_group_key, window, cx) + .detach_and_log_err(cx); }) .ok(); - } - }, - ); - - let project_group_key = project_group_key.clone(); - let multi_workspace = multi_workspace.clone(); - menu.separator() - .entry("Remove Project", None, move |window, cx| { - multi_workspace - .update(cx, |multi_workspace, cx| { - multi_workspace - .remove_project_group(&project_group_key, window, cx) - .detach_and_log_err(cx); - }) - .ok(); - }) - }); + weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + }) + }); let this = this.clone(); window @@ -1713,6 +1725,7 @@ impl Sidebar { has_running_threads, waiting_thread_count, is_active, + has_threads, } = self.contents.entries.get(header_idx)? else { return None; @@ -1730,6 +1743,7 @@ impl Sidebar { *has_running_threads, *waiting_thread_count, *is_active, + *has_threads, is_selected, cx, ); @@ -1983,18 +1997,18 @@ impl Sidebar { self.expand_thread_group(&path_list, cx); } } - ListEntry::DraftThread { .. } => { - // Already active — nothing to do. - } - ListEntry::NewThread { key, workspace, .. } => { - let path_list = key.path_list().clone(); - if let Some(workspace) = workspace - .clone() - .or_else(|| self.workspace_for_group(&path_list, cx)) - { + ListEntry::DraftThread { key, workspace, .. } => { + if let Some(workspace) = workspace.clone() { self.create_new_thread(&workspace, window, cx); } else { - self.open_workspace_for_group(&path_list, window, cx); + let path_list = key.path_list().clone(); + if let Some(workspace) = self.workspace_for_group(&path_list, cx) { + if !AgentPanel::is_visible(&workspace, cx) { + workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); + } + } } } } @@ -2370,10 +2384,7 @@ impl Sidebar { } } Some( - ListEntry::Thread(_) - | ListEntry::ViewMore { .. } - | ListEntry::NewThread { .. } - | ListEntry::DraftThread { .. }, + ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::DraftThread { .. }, ) => { for i in (0..ix).rev() { if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i) @@ -2401,10 +2412,7 @@ impl Sidebar { let header_ix = match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { .. }) => Some(ix), Some( - ListEntry::Thread(_) - | ListEntry::ViewMore { .. } - | ListEntry::NewThread { .. } - | ListEntry::DraftThread { .. }, + ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::DraftThread { .. }, ) => (0..ix).rev().find(|&i| { matches!( self.contents.entries.get(i), @@ -2870,7 +2878,7 @@ impl Sidebar { let session_id = thread.metadata.session_id.clone(); self.archive_thread(&session_id, window, cx); } - Some(ListEntry::NewThread { + Some(ListEntry::DraftThread { workspace: Some(workspace), .. }) => { @@ -3687,9 +3695,9 @@ impl Sidebar { ) -> AnyElement { let label: SharedString = if is_active { self.active_draft_text(cx) - .unwrap_or_else(|| "Untitled Thread".into()) + .unwrap_or_else(|| "New Thread".into()) } else { - "Untitled Thread".into() + "New Thread".into() }; let id = SharedString::from(format!("draft-thread-btn-{}", ix)); @@ -3709,7 +3717,16 @@ impl Sidebar { .collect(), ) .selected(true) - .focused(is_selected); + .focused(is_selected) + .on_click(cx.listener(|this, _, window, cx| { + if let Some(workspace) = this.active_workspace(cx) { + if !AgentPanel::is_visible(&workspace, cx) { + workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); + } + } + })); div() .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { @@ -3758,7 +3775,7 @@ impl Sidebar { } })); - // Linked worktree NewThread entries can be dismissed, which removes + // Linked worktree DraftThread entries can be dismissed, which removes // the workspace from the multi-workspace. if let Some(workspace) = workspace.cloned() { thread_item = thread_item.action_slot( diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 8f4745a3ee3686134b547edaba2f46d6fc42f793..a5ec8c74e42c42d34044f05cd2251d6f2c8077bc 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -296,13 +296,17 @@ fn visible_entries_as_strings( format!(" + View More{}", selected) } } - ListEntry::DraftThread { worktrees, .. } => { - let worktree = format_linked_worktree_chips(worktrees); - format!(" [~ Draft{}]{}", worktree, selected) - } - ListEntry::NewThread { worktrees, .. } => { + ListEntry::DraftThread { + workspace, + worktrees, + .. + } => { let worktree = format_linked_worktree_chips(worktrees); - format!(" [+ New Thread{}]{}", worktree, selected) + if workspace.is_some() { + format!(" [+ New Thread{}]{}", worktree, selected) + } else { + format!(" [~ Draft{}]{}", worktree, selected) + } } } }) @@ -472,7 +476,7 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]"] + vec!["v [my-project]"] ); } @@ -723,6 +727,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { has_running_threads: false, waiting_thread_count: 0, is_active: true, + has_threads: true, }, ListEntry::Thread(ThreadEntry { metadata: ThreadMetadata { @@ -851,6 +856,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { has_running_threads: false, waiting_thread_count: 0, is_active: false, + has_threads: false, }, ]; @@ -1170,10 +1176,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - // An empty project has the header and a new thread button. + // An empty project has only the header. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [empty-project]", " [+ New Thread]"] + vec!["v [empty-project]"] ); // Focus sidebar — focus_in does not set a selection @@ -1184,11 +1190,7 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - // SelectNext moves to the new thread button - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // At the end, wraps back to first entry + // At the end (only one entry), wraps back to first entry cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); @@ -2689,7 +2691,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { // Thread is not visible yet — no worktree knows about this path. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " [+ New Thread]"] + vec!["v [project]"] ); // Now add the worktree to the git state and trigger a rescan. @@ -3436,7 +3438,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje ListEntry::ViewMore { .. } => { panic!("unexpected `View More` entry while opening linked worktree thread"); } - ListEntry::DraftThread { .. } | ListEntry::NewThread { .. } => {} + ListEntry::DraftThread { .. } => {} } } @@ -4420,7 +4422,6 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test visible_entries_as_strings(&sidebar, cx), vec![ "v [other, project]", - " [+ New Thread]", "v [project]", " Worktree Thread {wt-feature-a}", ] @@ -5484,7 +5485,7 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut #[gpui::test] async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) { // When a linked worktree is opened as its own workspace and the user - // switches away, the workspace must still be reachable from a NewThread + // switches away, the workspace must still be reachable from a DraftThread // sidebar entry. Pressing RemoveSelectedThread (shift-backspace) on that // entry should remove the workspace. init_test(cx); @@ -5579,7 +5580,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA "linked worktree workspace should be reachable, but reachable are: {reachable:?}" ); - // Find the NewThread entry for the linked worktree and dismiss it. + // Find the DraftThread entry for the linked worktree and dismiss it. let new_thread_ix = sidebar.read_with(cx, |sidebar, _| { sidebar .contents @@ -5588,13 +5589,13 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA .position(|entry| { matches!( entry, - ListEntry::NewThread { + ListEntry::DraftThread { workspace: Some(_), .. } ) }) - .expect("expected a NewThread entry for the linked worktree") + .expect("expected a DraftThread entry for the linked worktree") }); assert_eq!( @@ -5611,7 +5612,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), 1, - "linked worktree workspace should be removed after dismissing NewThread entry" + "linked worktree workspace should be removed after dismissing DraftThread entry" ); } @@ -5978,6 +5979,175 @@ async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &m ); } +#[gpui::test] +async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project( + cx: &mut TestAppContext, +) { + // Regression test for a property-test finding: + // AddLinkedWorktree { project_group_index: 0 } + // AddProject { use_worktree: true } + // AddProject { use_worktree: false } + // After these three steps, the linked-worktree workspace was not + // reachable from any sidebar entry. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + + cx.observe_new( + |workspace: &mut Workspace, + window: Option<&mut Window>, + cx: &mut gpui::Context| { + if let Some(window) = window { + let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx)); + workspace.add_panel(panel, window, cx); + } + }, + ) + .detach(); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/my-project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = + project::Project::test(fs.clone() as Arc, ["/my-project".as_ref()], cx).await; + project.update(cx, |p, cx| p.git_scans_complete(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); + + // Step 1: Create a linked worktree for the main project. + let worktree_name = "wt-0"; + let worktree_path = "/worktrees/wt-0"; + + fs.insert_tree( + worktree_path, + serde_json::json!({ + ".git": "gitdir: /my-project/.git/worktrees/wt-0", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/my-project/.git/worktrees/wt-0", + serde_json::json!({ + "commondir": "../../", + "HEAD": "ref: refs/heads/wt-0", + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/my-project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from(worktree_path), + ref_name: Some(format!("refs/heads/{}", worktree_name).into()), + sha: "aaa".into(), + is_main: false, + }, + ) + .await; + + let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone()); + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + cx.run_until_parked(); + + // Step 2: Open the linked worktree as its own workspace. + let worktree_project = + project::Project::test(fs.clone() as Arc, [worktree_path.as_ref()], cx).await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + cx.run_until_parked(); + + // Step 3: Add an unrelated project. + fs.insert_tree( + "/other-project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + let other_project = project::Project::test( + fs.clone() as Arc, + ["/other-project".as_ref()], + cx, + ) + .await; + other_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(other_project.clone(), window, cx); + }); + cx.run_until_parked(); + + // Force a full sidebar rebuild with all groups expanded. + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.collapsed_groups.clear(); + let path_lists: Vec = sidebar + .contents + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::ProjectHeader { key, .. } => Some(key.path_list().clone()), + _ => None, + }) + .collect(); + for path_list in path_lists { + sidebar.expanded_groups.insert(path_list, 10_000); + } + sidebar.update_entries(cx); + }); + cx.run_until_parked(); + + // The linked-worktree workspace must be reachable from at least one + // sidebar entry — otherwise the user has no way to navigate to it. + let worktree_ws_id = worktree_workspace.entity_id(); + let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| { + let mw = multi_workspace.read(cx); + + let all: HashSet = mw.workspaces().map(|ws| ws.entity_id()).collect(); + let reachable: HashSet = sidebar + .contents + .entries + .iter() + .flat_map(|entry| entry.reachable_workspaces(mw, cx)) + .map(|ws| ws.entity_id()) + .collect(); + (all, reachable) + }); + + let unreachable = &all_ids - &reachable_ids; + eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n")); + + assert!( + unreachable.is_empty(), + "workspaces not reachable from any sidebar entry: {:?}\n\ + (linked-worktree workspace id: {:?})", + unreachable, + worktree_ws_id, + ); +} + mod property_test { use super::*; use gpui::proptest::prelude::*; @@ -6236,6 +6406,7 @@ mod property_test { mw.test_add_workspace(project.clone(), window, cx) }); } + Operation::ArchiveThread { index } => { let session_id = state.saved_thread_ids[index].clone(); sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| { @@ -6600,18 +6771,20 @@ mod property_test { anyhow::bail!("sidebar should still have an associated multi-workspace"); }; - let mw = multi_workspace.read(cx); + let multi_workspace = multi_workspace.read(cx); let reachable_workspaces: HashSet = sidebar .contents .entries .iter() - .flat_map(|entry| entry.reachable_workspaces(mw, cx)) + .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx)) .map(|ws| ws.entity_id()) .collect(); - let all_workspace_ids: HashSet = - mw.workspaces().map(|ws| ws.entity_id()).collect(); + let all_workspace_ids: HashSet = multi_workspace + .workspaces() + .map(|ws| ws.entity_id()) + .collect(); let unreachable = &all_workspace_ids - &reachable_workspaces; @@ -6628,7 +6801,6 @@ mod property_test { cases: 50, ..Default::default() })] - #[ignore = "temporarily disabled to unblock PRs from landing"] async fn test_sidebar_invariants( #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)] raw_operations: Vec,