Cargo.lock 🔗
@@ -15871,7 +15871,6 @@ dependencies = [
"agent_ui",
"anyhow",
"chrono",
- "collections",
"editor",
"feature_flags",
"fs",
Max Brunsfeld , Eric Holk , and Mikayla Maki created
Release Notes:
* [x] It's possible to get into a state where agent panel shows a thread
that is archived
- N/A
---------
Co-authored-by: Eric Holk <eric@zed.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Cargo.lock | 1
crates/agent_ui/src/agent_panel.rs | 4
crates/project/src/project.rs | 6
crates/sidebar/Cargo.toml | 1
crates/sidebar/src/project_group_builder.rs | 282 ---------
crates/sidebar/src/sidebar.rs | 664 +++++++++++-----------
crates/sidebar/src/sidebar_tests.rs | 347 +++++------
crates/workspace/src/multi_workspace.rs | 20
8 files changed, 525 insertions(+), 800 deletions(-)
@@ -15871,7 +15871,6 @@ dependencies = [
"agent_ui",
"anyhow",
"chrono",
- "collections",
"editor",
"feature_flags",
"fs",
@@ -2076,6 +2076,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ if let Some(store) = ThreadMetadataStore::try_global(cx) {
+ store.update(cx, |store, cx| store.unarchive(&session_id, cx));
+ }
+
if let Some(conversation_view) = self.background_threads.remove(&session_id) {
self.set_active_view(
ActiveView::AgentThread { conversation_view },
@@ -6049,11 +6049,7 @@ impl ProjectGroupKey {
/// Creates a new `ProjectGroupKey` with the given path list.
///
/// The path list should point to the git main worktree paths for a project.
- ///
- /// This should be used only in a few places to make sure we can ensure the
- /// main worktree path invariant. Namely, this should only be called from
- /// [`Workspace`].
- pub(crate) fn new(host: Option<RemoteConnectionOptions>, paths: PathList) -> Self {
+ pub fn new(host: Option<RemoteConnectionOptions>, paths: PathList) -> Self {
Self { paths, host }
}
@@ -23,7 +23,6 @@ agent_settings.workspace = true
agent_ui = { workspace = true, features = ["audio"] }
anyhow.workspace = true
chrono.workspace = true
-collections.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
@@ -1,282 +0,0 @@
-//! The sidebar groups threads by a canonical path list.
-//!
-//! Threads have a path list associated with them, but this is the absolute path
-//! of whatever worktrees they were associated with. In the sidebar, we want to
-//! group all threads by their main worktree, and then we add a worktree chip to
-//! the sidebar entry when that thread is in another worktree.
-//!
-//! This module is provides the functions and structures necessary to do this
-//! lookup and mapping.
-
-use collections::{HashMap, HashSet, vecmap::VecMap};
-use gpui::{App, Entity};
-use project::ProjectGroupKey;
-use std::{
- path::{Path, PathBuf},
- sync::Arc,
-};
-use workspace::{MultiWorkspace, PathList, Workspace};
-
-#[derive(Default)]
-pub struct ProjectGroup {
- pub workspaces: Vec<Entity<Workspace>>,
- /// Root paths of all open workspaces in this group. Used to skip
- /// redundant thread-store queries for linked worktrees that already
- /// have an open workspace.
- covered_paths: HashSet<Arc<Path>>,
-}
-
-impl ProjectGroup {
- fn add_workspace(&mut self, workspace: &Entity<Workspace>, cx: &App) {
- if !self.workspaces.contains(workspace) {
- self.workspaces.push(workspace.clone());
- }
- for path in workspace.read(cx).root_paths(cx) {
- self.covered_paths.insert(path);
- }
- }
-
- pub fn first_workspace(&self) -> &Entity<Workspace> {
- self.workspaces
- .first()
- .expect("groups always have at least one workspace")
- }
-
- pub fn main_workspace(&self, cx: &App) -> &Entity<Workspace> {
- self.workspaces
- .iter()
- .find(|ws| {
- !crate::root_repository_snapshots(ws, cx)
- .any(|snapshot| snapshot.is_linked_worktree())
- })
- .unwrap_or_else(|| self.first_workspace())
- }
-}
-
-pub struct ProjectGroupBuilder {
- /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path
- directory_mappings: HashMap<PathBuf, PathBuf>,
- project_groups: VecMap<ProjectGroupKey, ProjectGroup>,
-}
-
-impl ProjectGroupBuilder {
- fn new() -> Self {
- Self {
- directory_mappings: HashMap::default(),
- project_groups: VecMap::new(),
- }
- }
-
- pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self {
- let mut builder = Self::new();
- // First pass: collect all directory mappings from every workspace
- // so we know how to canonicalize any path (including linked
- // worktree paths discovered by the main repo's workspace).
- for workspace in mw.workspaces() {
- builder.add_workspace_mappings(workspace.read(cx), cx);
- }
-
- // Second pass: group each workspace using canonical paths derived
- // from the full set of mappings.
- for workspace in mw.workspaces() {
- let group_name = workspace.read(cx).project_group_key(cx);
- builder
- .project_group_entry(&group_name)
- .add_workspace(workspace, cx);
- }
- builder
- }
-
- fn project_group_entry(&mut self, name: &ProjectGroupKey) -> &mut ProjectGroup {
- self.project_groups.entry_ref(name).or_insert_default()
- }
-
- fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {
- let old = self
- .directory_mappings
- .insert(PathBuf::from(work_directory), PathBuf::from(original_repo));
- if let Some(old) = old {
- debug_assert_eq!(
- &old, original_repo,
- "all worktrees should map to the same main worktree"
- );
- }
- }
-
- pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) {
- for repo in workspace.project().read(cx).repositories(cx).values() {
- let snapshot = repo.read(cx).snapshot();
-
- self.add_mapping(
- &snapshot.work_directory_abs_path,
- &snapshot.original_repo_abs_path,
- );
-
- for worktree in snapshot.linked_worktrees.iter() {
- self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path);
- }
- }
- }
-
- pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
- self.directory_mappings
- .get(path)
- .map(AsRef::as_ref)
- .unwrap_or(path)
- }
-
- /// Whether the given group should load threads for a linked worktree
- /// at `worktree_path`. Returns `false` if the worktree already has an
- /// open workspace in the group (its threads are loaded via the
- /// workspace loop) or if the worktree's canonical path list doesn't
- /// match `group_path_list`.
- pub fn group_owns_worktree(
- &self,
- group: &ProjectGroup,
- group_path_list: &PathList,
- worktree_path: &Path,
- ) -> bool {
- if group.covered_paths.contains(worktree_path) {
- return false;
- }
- let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path]));
- canonical == *group_path_list
- }
-
- /// Canonicalizes every path in a [`PathList`] using the builder's
- /// directory mappings.
- fn canonicalize_path_list(&self, path_list: &PathList) -> PathList {
- let paths: Vec<_> = path_list
- .paths()
- .iter()
- .map(|p| self.canonicalize_path(p).to_path_buf())
- .collect();
- PathList::new(&paths)
- }
-
- pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupKey, &ProjectGroup)> {
- self.project_groups.iter()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::Arc;
-
- use super::*;
- use fs::FakeFs;
- use gpui::TestAppContext;
- use settings::SettingsStore;
-
- fn init_test(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = SettingsStore::test(cx);
- cx.set_global(settings_store);
- theme_settings::init(theme::LoadThemes::JustBase, cx);
- });
- }
-
- async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- "/project",
- serde_json::json!({
- ".git": {
- "worktrees": {
- "feature-a": {
- "commondir": "../../",
- "HEAD": "ref: refs/heads/feature-a",
- },
- },
- },
- "src": {},
- }),
- )
- .await;
- fs.insert_tree(
- "/wt/feature-a",
- serde_json::json!({
- ".git": "gitdir: /project/.git/worktrees/feature-a",
- "src": {},
- }),
- )
- .await;
- fs.add_linked_worktree_for_repo(
- std::path::Path::new("/project/.git"),
- false,
- git::repository::Worktree {
- path: std::path::PathBuf::from("/wt/feature-a"),
- ref_name: Some("refs/heads/feature-a".into()),
- sha: "abc".into(),
- is_main: false,
- },
- )
- .await;
- fs
- }
-
- #[gpui::test]
- async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
- init_test(cx);
- let fs = create_fs_with_main_and_worktree(cx).await;
- cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
- let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
- project
- .update(cx, |project, cx| project.git_scans_complete(cx))
- .await;
-
- let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
- workspace::MultiWorkspace::test_new(project.clone(), window, cx)
- });
-
- multi_workspace.read_with(cx, |mw, cx| {
- let mut canonicalizer = ProjectGroupBuilder::new();
- for workspace in mw.workspaces() {
- canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
- }
-
- // The main repo path should canonicalize to itself.
- assert_eq!(
- canonicalizer.canonicalize_path(Path::new("/project")),
- Path::new("/project"),
- );
-
- // An unknown path returns None.
- assert_eq!(
- canonicalizer.canonicalize_path(Path::new("/something/else")),
- Path::new("/something/else"),
- );
- });
- }
-
- #[gpui::test]
- async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
- init_test(cx);
- let fs = create_fs_with_main_and_worktree(cx).await;
- cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
- // Open the worktree checkout as its own project.
- let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
- project
- .update(cx, |project, cx| project.git_scans_complete(cx))
- .await;
-
- let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
- workspace::MultiWorkspace::test_new(project.clone(), window, cx)
- });
-
- multi_workspace.read_with(cx, |mw, cx| {
- let mut canonicalizer = ProjectGroupBuilder::new();
- for workspace in mw.workspaces() {
- canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
- }
-
- // The worktree checkout path should canonicalize to the main repo.
- assert_eq!(
- canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
- Path::new("/project"),
- );
- });
- }
-}
@@ -23,7 +23,9 @@ use gpui::{
use menu::{
Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
};
-use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name};
+use project::{
+ AgentId, AgentRegistryStore, Event as ProjectEvent, ProjectGroupKey, linked_worktree_short_name,
+};
use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
use remote::RemoteConnectionOptions;
use ui::utils::platform_title_bar_height;
@@ -54,10 +56,6 @@ use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
-use crate::project_group_builder::ProjectGroupBuilder;
-
-mod project_group_builder;
-
#[cfg(test)]
mod sidebar_tests;
@@ -136,13 +134,7 @@ impl ActiveEntry {
(ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
thread.metadata.session_id == *session_id
}
- (
- ActiveEntry::Draft(workspace),
- ListEntry::NewThread {
- workspace: entry_workspace,
- ..
- },
- ) => workspace == entry_workspace,
+ (ActiveEntry::Draft(_workspace), ListEntry::DraftThread { .. }) => true,
_ => false,
}
}
@@ -209,9 +201,8 @@ impl ThreadEntry {
#[derive(Clone)]
enum ListEntry {
ProjectHeader {
- path_list: PathList,
+ key: ProjectGroupKey,
label: SharedString,
- workspace: Entity<Workspace>,
highlight_positions: Vec<usize>,
has_running_threads: bool,
waiting_thread_count: usize,
@@ -219,30 +210,25 @@ enum ListEntry {
},
Thread(ThreadEntry),
ViewMore {
- path_list: PathList,
+ key: ProjectGroupKey,
is_fully_expanded: bool,
},
+ /// 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<WorktreeInfo>,
+ },
+ /// A convenience row for starting a new thread. Shown when a project group
+ /// has no threads, or when the active workspace contains linked worktrees
+ /// with no threads for that specific worktree set.
NewThread {
- path_list: PathList,
- workspace: Entity<Workspace>,
+ key: project::ProjectGroupKey,
worktrees: Vec<WorktreeInfo>,
},
}
#[cfg(test)]
impl ListEntry {
- fn workspace(&self) -> Option<Entity<Workspace>> {
- match self {
- ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
- ListEntry::Thread(thread_entry) => match &thread_entry.workspace {
- ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
- ThreadEntryWorkspace::Closed(_) => None,
- },
- ListEntry::ViewMore { .. } => None,
- ListEntry::NewThread { workspace, .. } => Some(workspace.clone()),
- }
- }
-
fn session_id(&self) -> Option<&acp::SessionId> {
match self {
ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id),
@@ -321,27 +307,32 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
/// Derives worktree display info from a thread's stored path list.
///
-/// For each path in the thread's `folder_paths` that canonicalizes to a
-/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`]
-/// with the short worktree name and full path.
+/// For each path in the thread's `folder_paths` that is not one of the
+/// group's main paths (i.e. it's a git linked worktree), produces a
+/// [`WorktreeInfo`] with the short worktree name and full path.
fn worktree_info_from_thread_paths(
folder_paths: &PathList,
- project_groups: &ProjectGroupBuilder,
+ group_key: &project::ProjectGroupKey,
) -> Vec<WorktreeInfo> {
+ let main_paths = group_key.path_list().paths();
folder_paths
.paths()
.iter()
.filter_map(|path| {
- let canonical = project_groups.canonicalize_path(path);
- if canonical != path.as_path() {
- Some(WorktreeInfo {
- name: linked_worktree_short_name(canonical, path).unwrap_or_default(),
- full_path: SharedString::from(path.display().to_string()),
- highlight_positions: Vec::new(),
- })
- } else {
- None
+ if main_paths.iter().any(|mp| mp.as_path() == path.as_path()) {
+ return None;
}
+ // Find the main path whose file name matches this linked
+ // worktree's file name, falling back to the first main path.
+ 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(),
+ })
})
.collect()
}
@@ -677,10 +668,41 @@ impl Sidebar {
result
}
+ /// Finds an open workspace whose project group key matches the given path list.
+ fn workspace_for_group(&self, path_list: &PathList, cx: &App) -> Option<Entity<Workspace>> {
+ let mw = self.multi_workspace.upgrade()?;
+ let mw = mw.read(cx);
+ mw.workspaces()
+ .iter()
+ .find(|ws| ws.read(cx).project_group_key(cx).path_list() == path_list)
+ .cloned()
+ }
+
+ /// Opens a new workspace for a group that has no open workspaces.
+ fn open_workspace_for_group(
+ &mut self,
+ path_list: &PathList,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+ return;
+ };
+
+ let paths: Vec<std::path::PathBuf> =
+ path_list.paths().iter().map(|p| p.to_path_buf()).collect();
+
+ multi_workspace
+ .update(cx, |mw, cx| {
+ mw.open_project(paths, workspace::OpenMode::Activate, window, cx)
+ })
+ .detach_and_log_err(cx);
+ }
+
/// Rebuilds the sidebar contents from current workspace and thread state.
///
- /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
- /// repository, then populates thread entries from the metadata store and
+ /// Iterates [`MultiWorkspace::project_group_keys`] to determine project
+ /// groups, then populates thread entries from the metadata store and
/// merges live thread info from active agent panels.
///
/// Aim for a single forward pass over workspaces and threads plus an
@@ -764,11 +786,6 @@ impl Sidebar {
let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
let mut project_header_indices: Vec<usize> = Vec::new();
- // Use ProjectGroupBuilder to canonically group workspaces by their
- // main git repository. This replaces the manual absorbed-workspace
- // detection that was here before.
- let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
-
let has_open_projects = workspaces
.iter()
.any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
@@ -785,38 +802,28 @@ impl Sidebar {
(icon, icon_from_external_svg)
};
- for (group_name, group) in project_groups.groups() {
- let path_list = group_name.path_list().clone();
+ for (group_key, group_workspaces) in mw.project_groups(cx) {
+ let path_list = group_key.path_list().clone();
if path_list.paths().is_empty() {
continue;
}
- let label = group_name.display_name();
+ let label = group_key.display_name();
let is_collapsed = self.collapsed_groups.contains(&path_list);
let should_load_threads = !is_collapsed || !query.is_empty();
let is_active = active_workspace
.as_ref()
- .is_some_and(|active| group.workspaces.contains(active));
-
- // Pick a representative workspace for the group: prefer the active
- // workspace if it belongs to this group, otherwise use the main
- // repo workspace (not a linked worktree).
- let representative_workspace = active_workspace
- .as_ref()
- .filter(|_| is_active)
- .unwrap_or_else(|| group.main_workspace(cx));
+ .is_some_and(|active| group_workspaces.contains(active));
// Collect live thread infos from all workspaces in this group.
- let live_infos: Vec<_> = group
- .workspaces
+ let live_infos: Vec<_> = group_workspaces
.iter()
.flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
.collect();
let mut threads: Vec<ThreadEntry> = Vec::new();
- let mut threadless_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeInfo>)> = Vec::new();
let mut has_running_threads = false;
let mut waiting_thread_count: usize = 0;
@@ -824,61 +831,88 @@ impl Sidebar {
let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
let thread_store = ThreadMetadataStore::global(cx);
- // Load threads from each workspace in the group.
- for workspace in &group.workspaces {
- let ws_path_list = workspace_path_list(workspace, cx);
- let mut workspace_rows = thread_store
- .read(cx)
- .entries_for_path(&ws_path_list)
- .cloned()
- .peekable();
- if workspace_rows.peek().is_none() {
- let worktrees =
- worktree_info_from_thread_paths(&ws_path_list, &project_groups);
- threadless_workspaces.push((workspace.clone(), worktrees));
+ // Build a lookup from workspace root paths to their workspace
+ // entity, used to assign ThreadEntryWorkspace::Open for threads
+ // whose folder_paths match an open workspace.
+ let workspace_by_path_list: HashMap<PathList, &Entity<Workspace>> =
+ group_workspaces
+ .iter()
+ .map(|ws| (workspace_path_list(ws, cx), ws))
+ .collect();
+
+ // Resolve a ThreadEntryWorkspace for a thread row. If any open
+ // workspace's root paths match the thread's folder_paths, use
+ // Open; otherwise use Closed.
+ let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace {
+ workspace_by_path_list
+ .get(&row.folder_paths)
+ .map(|ws| ThreadEntryWorkspace::Open((*ws).clone()))
+ .unwrap_or_else(|| ThreadEntryWorkspace::Closed(row.folder_paths.clone()))
+ };
+
+ // 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, &group_key);
+ 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(),
}
- for row in workspace_rows {
- if !seen_session_ids.insert(row.session_id.clone()) {
- continue;
- }
- let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
- let worktrees =
- worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
- threads.push(ThreadEntry {
- metadata: row,
- icon,
- icon_from_external_svg,
- status: AgentThreadStatus::default(),
- workspace: ThreadEntryWorkspace::Open(workspace.clone()),
- 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
+ // points to the group's canonical paths regardless of which
+ // linked worktree the thread was opened in.
+ for row in thread_store
+ .read(cx)
+ .entries_for_main_worktree_path(&path_list)
+ .cloned()
+ {
+ if !seen_session_ids.insert(row.session_id.clone()) {
+ continue;
}
+ let workspace = resolve_workspace(&row);
+ threads.push(make_thread_entry(row, workspace));
}
- // Load threads from linked git worktrees whose
- // canonical paths belong to this group.
- let linked_worktree_queries = group
- .workspaces
- .iter()
- .flat_map(|ws| root_repository_snapshots(ws, cx))
- .filter(|snapshot| !snapshot.is_linked_worktree())
- .flat_map(|snapshot| {
- snapshot
- .linked_worktrees()
- .iter()
- .filter(|wt| {
- project_groups.group_owns_worktree(group, &path_list, &wt.path)
- })
- .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
- .collect::<Vec<_>>()
- });
+ // Legacy threads did not have `main_worktree_paths` populated, so they
+ // must be queried by their `folder_paths`.
+
+ // Load any legacy threads for the main worktrees of this project group.
+ for row in thread_store.read(cx).entries_for_path(&path_list).cloned() {
+ if !seen_session_ids.insert(row.session_id.clone()) {
+ continue;
+ }
+ let workspace = resolve_workspace(&row);
+ threads.push(make_thread_entry(row, workspace));
+ }
- for worktree_path_list in linked_worktree_queries {
+ // Load any legacy threads for any single linked wortree of this project group.
+ let mut linked_worktree_paths = HashSet::new();
+ for workspace in &group_workspaces {
+ if workspace.read(cx).visible_worktrees(cx).count() != 1 {
+ continue;
+ }
+ for snapshot in root_repository_snapshots(workspace, cx) {
+ for linked_worktree in snapshot.linked_worktrees() {
+ linked_worktree_paths.insert(linked_worktree.path.clone());
+ }
+ }
+ }
+ for path in linked_worktree_paths {
+ let worktree_path_list = PathList::new(std::slice::from_ref(&path));
for row in thread_store
.read(cx)
.entries_for_path(&worktree_path_list)
@@ -887,67 +921,10 @@ impl Sidebar {
if !seen_session_ids.insert(row.session_id.clone()) {
continue;
}
- let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
- let worktrees =
- worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
- threads.push(ThreadEntry {
- metadata: row,
- icon,
- icon_from_external_svg,
- status: AgentThreadStatus::default(),
- workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
- is_live: false,
- is_background: false,
- is_title_generating: false,
- highlight_positions: Vec::new(),
- worktrees,
- diff_stats: DiffStats::default(),
- });
- }
- }
-
- // Load threads from main worktrees when a workspace in this
- // group is itself a linked worktree checkout.
- let main_repo_queries: Vec<PathList> = group
- .workspaces
- .iter()
- .flat_map(|ws| root_repository_snapshots(ws, cx))
- .filter(|snapshot| snapshot.is_linked_worktree())
- .map(|snapshot| {
- PathList::new(std::slice::from_ref(&snapshot.original_repo_abs_path))
- })
- .collect();
-
- for main_repo_path_list in main_repo_queries {
- let folder_path_matches = thread_store
- .read(cx)
- .entries_for_path(&main_repo_path_list)
- .cloned();
- let main_worktree_path_matches = thread_store
- .read(cx)
- .entries_for_main_worktree_path(&main_repo_path_list)
- .cloned();
-
- for row in folder_path_matches.chain(main_worktree_path_matches) {
- if !seen_session_ids.insert(row.session_id.clone()) {
- continue;
- }
- let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
- let worktrees =
- worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
- threads.push(ThreadEntry {
- metadata: row,
- icon,
- icon_from_external_svg,
- status: AgentThreadStatus::default(),
- workspace: ThreadEntryWorkspace::Closed(main_repo_path_list.clone()),
- is_live: false,
- is_background: false,
- is_title_generating: false,
- highlight_positions: Vec::new(),
- worktrees,
- diff_stats: DiffStats::default(),
- });
+ threads.push(make_thread_entry(
+ row,
+ ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
+ ));
}
}
@@ -1051,9 +1028,8 @@ impl Sidebar {
project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader {
- path_list: path_list.clone(),
+ key: group_key.clone(),
label,
- workspace: representative_workspace.clone(),
highlight_positions: workspace_highlight_positions,
has_running_threads,
waiting_thread_count,
@@ -1065,15 +1041,13 @@ impl Sidebar {
entries.push(thread.into());
}
} else {
- let is_draft_for_workspace = is_active
- && matches!(&self.active_entry, Some(ActiveEntry::Draft(_)))
- && self.active_entry_workspace() == Some(representative_workspace);
+ let is_draft_for_group = is_active
+ && matches!(&self.active_entry, Some(ActiveEntry::Draft(ws)) if group_workspaces.contains(ws));
project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader {
- path_list: path_list.clone(),
+ key: group_key.clone(),
label,
- workspace: representative_workspace.clone(),
highlight_positions: Vec::new(),
has_running_threads,
waiting_thread_count,
@@ -1084,25 +1058,61 @@ impl Sidebar {
continue;
}
- // Emit "New Thread" entries for threadless workspaces
- // and active drafts, right after the header.
- for (workspace, worktrees) in &threadless_workspaces {
- entries.push(ListEntry::NewThread {
- path_list: path_list.clone(),
- workspace: workspace.clone(),
- worktrees: worktrees.clone(),
- });
+ // Emit a DraftThread entry when the active draft belongs to this group.
+ if is_draft_for_group {
+ if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry {
+ let ws_path_list = workspace_path_list(draft_ws, cx);
+ let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key);
+ entries.push(ListEntry::DraftThread { worktrees });
+ }
}
- if is_draft_for_workspace
- && !threadless_workspaces
- .iter()
- .any(|(ws, _)| ws == representative_workspace)
+
+ // Emit a NewThread entry when:
+ // 1. The group has zero threads (convenient affordance).
+ // 2. The active workspace has linked worktrees but no threads
+ // for the active workspace's specific set of worktrees.
+ let group_has_no_threads = threads.is_empty() && !group_workspaces.is_empty();
+ let active_ws_has_threadless_linked_worktrees = is_active
+ && !is_draft_for_group
+ && active_workspace.as_ref().is_some_and(|active_ws| {
+ let ws_path_list = workspace_path_list(active_ws, cx);
+ let has_linked_worktrees =
+ !worktree_info_from_thread_paths(&ws_path_list, &group_key).is_empty();
+ if !has_linked_worktrees {
+ return false;
+ }
+ let thread_store = ThreadMetadataStore::global(cx);
+ let has_threads_for_ws = thread_store
+ .read(cx)
+ .entries_for_path(&ws_path_list)
+ .next()
+ .is_some()
+ || thread_store
+ .read(cx)
+ .entries_for_main_worktree_path(&ws_path_list)
+ .next()
+ .is_some();
+ !has_threads_for_ws
+ });
+
+ if !is_draft_for_group
+ && (group_has_no_threads || active_ws_has_threadless_linked_worktrees)
{
- let ws_path_list = workspace_path_list(representative_workspace, cx);
- let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups);
+ let worktrees = if active_ws_has_threadless_linked_worktrees {
+ active_workspace
+ .as_ref()
+ .map(|ws| {
+ worktree_info_from_thread_paths(
+ &workspace_path_list(ws, cx),
+ &group_key,
+ )
+ })
+ .unwrap_or_default()
+ } else {
+ Vec::new()
+ };
entries.push(ListEntry::NewThread {
- path_list: path_list.clone(),
- workspace: representative_workspace.clone(),
+ key: group_key.clone(),
worktrees,
});
}
@@ -1148,7 +1158,7 @@ impl Sidebar {
if total > DEFAULT_THREADS_SHOWN {
entries.push(ListEntry::ViewMore {
- path_list: path_list.clone(),
+ key: group_key.clone(),
is_fully_expanded,
});
}
@@ -1236,9 +1246,8 @@ impl Sidebar {
let rendered = match entry {
ListEntry::ProjectHeader {
- path_list,
+ key,
label,
- workspace,
highlight_positions,
has_running_threads,
waiting_thread_count,
@@ -1246,9 +1255,8 @@ impl Sidebar {
} => self.render_project_header(
ix,
false,
- path_list,
+ key,
label,
- workspace,
highlight_positions,
*has_running_threads,
*waiting_thread_count,
@@ -1258,22 +1266,15 @@ impl Sidebar {
),
ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
ListEntry::ViewMore {
- path_list,
+ key,
is_fully_expanded,
- } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
- ListEntry::NewThread {
- path_list,
- workspace,
- worktrees,
- } => self.render_new_thread(
- ix,
- path_list,
- workspace,
- is_active,
- worktrees,
- is_selected,
- cx,
- ),
+ } => 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 { key, worktrees, .. } => {
+ self.render_new_thread(ix, key, worktrees, is_selected, cx)
+ }
};
if is_group_header_after_first {
@@ -1291,13 +1292,9 @@ impl Sidebar {
fn render_remote_project_icon(
&self,
ix: usize,
- workspace: &Entity<Workspace>,
- cx: &mut Context<Self>,
+ host: Option<&RemoteConnectionOptions>,
) -> Option<AnyElement> {
- let project = workspace.read(cx).project().read(cx);
- let remote_connection_options = project.remote_connection_options(cx)?;
-
- let remote_icon_per_type = match remote_connection_options {
+ let remote_icon_per_type = match host? {
RemoteConnectionOptions::Wsl(_) => IconName::Linux,
RemoteConnectionOptions::Docker(_) => IconName::Box,
_ => IconName::Server,
@@ -1320,9 +1317,8 @@ impl Sidebar {
&self,
ix: usize,
is_sticky: bool,
- path_list: &PathList,
+ key: &ProjectGroupKey,
label: &SharedString,
- workspace: &Entity<Workspace>,
highlight_positions: &[usize],
has_running_threads: bool,
waiting_thread_count: usize,
@@ -1330,6 +1326,9 @@ impl Sidebar {
is_focused: bool,
cx: &mut Context<Self>,
) -> AnyElement {
+ let path_list = key.path_list();
+ let host = key.host();
+
let id_prefix = if is_sticky { "sticky-" } else { "" };
let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
@@ -1342,16 +1341,15 @@ 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 { .. }));
+ 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_for_remove = workspace.clone();
- let workspace_for_menu = workspace.clone();
- let workspace_for_open = workspace.clone();
+ 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();
@@ -1408,7 +1406,7 @@ impl Sidebar {
)
.child(label)
.when_some(
- self.render_remote_project_icon(ix, workspace, cx),
+ self.render_remote_project_icon(ix, host.as_ref()),
|this, icon| this.child(icon),
)
.when(is_collapsed, |this| {
@@ -1452,13 +1450,13 @@ impl Sidebar {
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
- .child(self.render_project_header_menu(
- ix,
- id_prefix,
- &workspace_for_menu,
- &workspace_for_remove,
- cx,
- ))
+ .when_some(workspace, |this, workspace| {
+ this.child(
+ self.render_project_header_menu(
+ ix, id_prefix, &workspace, &workspace, cx,
+ ),
+ )
+ })
.when(view_more_expanded && !is_collapsed, |this| {
this.child(
IconButton::new(
@@ -1480,52 +1478,56 @@ impl Sidebar {
})),
)
})
- .when(show_new_thread_button, |this| {
- this.child(
- IconButton::new(
- SharedString::from(format!(
- "{id_prefix}project-header-new-thread-{ix}",
+ .when(
+ show_new_thread_button && workspace_for_new_thread.is_some(),
+ |this| {
+ let workspace_for_new_thread =
+ workspace_for_new_thread.clone().unwrap();
+ let path_list_for_new_thread = path_list_for_new_thread.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_for_new_thread);
+ this.selection = None;
+ this.create_new_thread(
+ &workspace_for_new_thread,
+ window,
+ cx,
+ );
+ },
)),
- IconName::Plus,
)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("New Thread"))
- .on_click(cx.listener({
- let workspace_for_new_thread = workspace_for_new_thread.clone();
- let path_list_for_new_thread = path_list_for_new_thread.clone();
- move |this, _, window, cx| {
- // Uncollapse the group if collapsed so
- // the new-thread entry becomes visible.
- this.collapsed_groups.remove(&path_list_for_new_thread);
- this.selection = None;
- this.create_new_thread(&workspace_for_new_thread, window, cx);
- }
- })),
- )
- })
+ },
+ )
})
.when(!is_active, |this| {
+ let path_list_for_open = path_list.clone();
this.cursor_pointer()
.hover(|s| s.bg(hover_color))
- .tooltip(Tooltip::text("Activate Workspace"))
- .on_click(cx.listener({
- move |this, _, window, cx| {
- this.active_entry =
- Some(ActiveEntry::Draft(workspace_for_open.clone()));
+ .tooltip(Tooltip::text("Open Workspace"))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ if let Some(workspace) = this.workspace_for_group(&path_list_for_open, 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_for_open.clone(),
- window,
- cx,
- );
+ multi_workspace.activate(workspace.clone(), window, cx);
});
}
- if AgentPanel::is_visible(&workspace_for_open, cx) {
- workspace_for_open.update(cx, |workspace, cx| {
+ if AgentPanel::is_visible(&workspace, cx) {
+ workspace.update(cx, |workspace, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
});
}
+ } else {
+ this.open_workspace_for_group(&path_list_for_open, window, cx);
}
}))
})
@@ -1720,9 +1722,8 @@ impl Sidebar {
}
let ListEntry::ProjectHeader {
- path_list,
+ key,
label,
- workspace,
highlight_positions,
has_running_threads,
waiting_thread_count,
@@ -1738,9 +1739,8 @@ impl Sidebar {
let header_element = self.render_project_header(
header_idx,
true,
- &path_list,
+ key,
&label,
- workspace,
&highlight_positions,
*has_running_threads,
*waiting_thread_count,
@@ -1961,8 +1961,8 @@ impl Sidebar {
};
match entry {
- ListEntry::ProjectHeader { path_list, .. } => {
- let path_list = path_list.clone();
+ ListEntry::ProjectHeader { key, .. } => {
+ let path_list = key.path_list().clone();
self.toggle_collapse(&path_list, window, cx);
}
ListEntry::Thread(thread) => {
@@ -1983,11 +1983,11 @@ impl Sidebar {
}
}
ListEntry::ViewMore {
- path_list,
+ key,
is_fully_expanded,
..
} => {
- let path_list = path_list.clone();
+ let path_list = key.path_list().clone();
if *is_fully_expanded {
self.expanded_groups.remove(&path_list);
} else {
@@ -1997,9 +1997,16 @@ impl Sidebar {
self.serialize(cx);
self.update_entries(cx);
}
- ListEntry::NewThread { workspace, .. } => {
- let workspace = workspace.clone();
- self.create_new_thread(&workspace, window, cx);
+ ListEntry::DraftThread { .. } => {
+ // Already active — nothing to do.
+ }
+ ListEntry::NewThread { key, .. } => {
+ let path_list = key.path_list().clone();
+ if let Some(workspace) = self.workspace_for_group(&path_list, cx) {
+ self.create_new_thread(&workspace, window, cx);
+ } else {
+ self.open_workspace_for_group(&path_list, window, cx);
+ }
}
}
}
@@ -2251,9 +2258,9 @@ impl Sidebar {
let Some(ix) = self.selection else { return };
match self.contents.entries.get(ix) {
- Some(ListEntry::ProjectHeader { path_list, .. }) => {
- if self.collapsed_groups.contains(path_list) {
- let path_list = path_list.clone();
+ Some(ListEntry::ProjectHeader { key, .. }) => {
+ if self.collapsed_groups.contains(key.path_list()) {
+ let path_list = key.path_list().clone();
self.collapsed_groups.remove(&path_list);
self.update_entries(cx);
} else if ix + 1 < self.contents.entries.len() {
@@ -2275,23 +2282,23 @@ impl Sidebar {
let Some(ix) = self.selection else { return };
match self.contents.entries.get(ix) {
- Some(ListEntry::ProjectHeader { path_list, .. }) => {
- if !self.collapsed_groups.contains(path_list) {
- let path_list = path_list.clone();
- self.collapsed_groups.insert(path_list);
+ Some(ListEntry::ProjectHeader { key, .. }) => {
+ if !self.collapsed_groups.contains(key.path_list()) {
+ self.collapsed_groups.insert(key.path_list().clone());
self.update_entries(cx);
}
}
Some(
- ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
+ ListEntry::Thread(_)
+ | ListEntry::ViewMore { .. }
+ | ListEntry::NewThread { .. }
+ | ListEntry::DraftThread { .. },
) => {
for i in (0..ix).rev() {
- if let Some(ListEntry::ProjectHeader { path_list, .. }) =
- self.contents.entries.get(i)
+ if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
{
- let path_list = path_list.clone();
self.selection = Some(i);
- self.collapsed_groups.insert(path_list);
+ self.collapsed_groups.insert(key.path_list().clone());
self.update_entries(cx);
break;
}
@@ -2313,7 +2320,10 @@ impl Sidebar {
let header_ix = match self.contents.entries.get(ix) {
Some(ListEntry::ProjectHeader { .. }) => Some(ix),
Some(
- ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
+ ListEntry::Thread(_)
+ | ListEntry::ViewMore { .. }
+ | ListEntry::NewThread { .. }
+ | ListEntry::DraftThread { .. },
) => (0..ix).rev().find(|&i| {
matches!(
self.contents.entries.get(i),
@@ -2324,15 +2334,14 @@ impl Sidebar {
};
if let Some(header_ix) = header_ix {
- if let Some(ListEntry::ProjectHeader { path_list, .. }) =
- self.contents.entries.get(header_ix)
+ if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
{
- let path_list = path_list.clone();
- if self.collapsed_groups.contains(&path_list) {
- self.collapsed_groups.remove(&path_list);
+ let path_list = key.path_list();
+ if self.collapsed_groups.contains(path_list) {
+ self.collapsed_groups.remove(path_list);
} else {
self.selection = Some(header_ix);
- self.collapsed_groups.insert(path_list);
+ self.collapsed_groups.insert(path_list.clone());
}
self.update_entries(cx);
}
@@ -2346,8 +2355,8 @@ impl Sidebar {
cx: &mut Context<Self>,
) {
for entry in &self.contents.entries {
- if let ListEntry::ProjectHeader { path_list, .. } = entry {
- self.collapsed_groups.insert(path_list.clone());
+ if let ListEntry::ProjectHeader { key, .. } = entry {
+ self.collapsed_groups.insert(key.path_list().clone());
}
}
self.update_entries(cx);
@@ -2402,17 +2411,18 @@ impl Sidebar {
});
// Find the workspace that owns this thread's project group by
- // walking backwards to the nearest ProjectHeader. We must use
- // *this* workspace (not the active workspace) because the user
- // might be archiving a thread in a non-active group.
+ // walking backwards to the nearest ProjectHeader and looking up
+ // an open workspace for that group's path_list.
let group_workspace = current_pos.and_then(|pos| {
- self.contents.entries[..pos]
- .iter()
- .rev()
- .find_map(|e| match e {
- ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
- _ => None,
- })
+ let path_list =
+ self.contents.entries[..pos]
+ .iter()
+ .rev()
+ .find_map(|e| match e {
+ ListEntry::ProjectHeader { key, .. } => Some(key.path_list()),
+ _ => None,
+ })?;
+ self.workspace_for_group(path_list, cx)
});
let next_thread = current_pos.and_then(|pos| {
@@ -2527,28 +2537,26 @@ impl Sidebar {
.insert(session_id.clone(), Utc::now());
}
- fn mru_threads_for_switcher(&self, _cx: &App) -> Vec<ThreadSwitcherEntry> {
+ fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
let mut current_header_label: Option<SharedString> = None;
- let mut current_header_workspace: Option<Entity<Workspace>> = None;
+ let mut current_header_path_list: Option<PathList> = None;
let mut entries: Vec<ThreadSwitcherEntry> = self
.contents
.entries
.iter()
.filter_map(|entry| match entry {
- ListEntry::ProjectHeader {
- label, workspace, ..
- } => {
+ ListEntry::ProjectHeader { label, key, .. } => {
current_header_label = Some(label.clone());
- current_header_workspace = Some(workspace.clone());
+ current_header_path_list = Some(key.path_list().clone());
None
}
ListEntry::Thread(thread) => {
let workspace = match &thread.workspace {
- ThreadEntryWorkspace::Open(workspace) => workspace.clone(),
- ThreadEntryWorkspace::Closed(_) => {
- current_header_workspace.as_ref()?.clone()
- }
- };
+ ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
+ ThreadEntryWorkspace::Closed(_) => current_header_path_list
+ .as_ref()
+ .and_then(|pl| self.workspace_for_group(pl, cx)),
+ }?;
let notified = self
.contents
.is_thread_notified(&thread.metadata.session_id);
@@ -88,14 +88,18 @@ fn setup_sidebar(
sidebar
}
-async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext) {
+async fn save_n_test_threads(
+ count: u32,
+ project: &Entity<project::Project>,
+ cx: &mut gpui::VisualTestContext,
+) {
for i in 0..count {
save_thread_metadata(
acp::SessionId::new(Arc::from(format!("thread-{}", i))),
format!("Thread {}", i + 1).into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
None,
- path_list.clone(),
+ project,
cx,
)
}
@@ -104,7 +108,7 @@ async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::Vi
async fn save_test_thread_metadata(
session_id: &acp::SessionId,
- path_list: PathList,
+ project: &Entity<project::Project>,
cx: &mut TestAppContext,
) {
save_thread_metadata(
@@ -112,7 +116,7 @@ async fn save_test_thread_metadata(
"Test".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ project,
cx,
)
}
@@ -120,7 +124,7 @@ async fn save_test_thread_metadata(
async fn save_named_thread_metadata(
session_id: &str,
title: &str,
- path_list: &PathList,
+ project: &Entity<project::Project>,
cx: &mut gpui::VisualTestContext,
) {
save_thread_metadata(
@@ -128,7 +132,7 @@ async fn save_named_thread_metadata(
SharedString::from(title.to_string()),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list.clone(),
+ project,
cx,
);
cx.run_until_parked();
@@ -139,21 +143,31 @@ fn save_thread_metadata(
title: SharedString,
updated_at: DateTime<Utc>,
created_at: Option<DateTime<Utc>>,
- path_list: PathList,
+ project: &Entity<project::Project>,
cx: &mut TestAppContext,
) {
- let metadata = ThreadMetadata {
- session_id,
- agent_id: agent::ZED_AGENT_ID.clone(),
- title,
- updated_at,
- created_at,
- folder_paths: path_list,
- main_worktree_paths: PathList::default(),
- archived: false,
- };
cx.update(|cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx))
+ let (folder_paths, main_worktree_paths) = {
+ let project_ref = project.read(cx);
+ let paths: Vec<Arc<Path>> = project_ref
+ .visible_worktrees(cx)
+ .map(|worktree| worktree.read(cx).abs_path())
+ .collect();
+ let folder_paths = PathList::new(&paths);
+ let main_worktree_paths = project_ref.project_group_key(cx).path_list().clone();
+ (folder_paths, main_worktree_paths)
+ };
+ let metadata = ThreadMetadata {
+ session_id,
+ agent_id: agent::ZED_AGENT_ID.clone(),
+ title,
+ updated_at,
+ created_at,
+ folder_paths,
+ main_worktree_paths,
+ archived: false,
+ };
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
});
cx.run_until_parked();
}
@@ -193,11 +207,11 @@ fn visible_entries_as_strings(
match entry {
ListEntry::ProjectHeader {
label,
- path_list,
+ key,
highlight_positions: _,
..
} => {
- let icon = if sidebar.collapsed_groups.contains(path_list) {
+ let icon = if sidebar.collapsed_groups.contains(key.path_list()) {
">"
} else {
"v"
@@ -248,6 +262,22 @@ fn visible_entries_as_strings(
format!(" + View More{}", selected)
}
}
+ ListEntry::DraftThread { worktrees, .. } => {
+ let worktree = if worktrees.is_empty() {
+ String::new()
+ } else {
+ let mut seen = Vec::new();
+ let mut chips = Vec::new();
+ for wt in worktrees {
+ if !seen.contains(&wt.name) {
+ seen.push(wt.name.clone());
+ chips.push(format!("{{{}}}", wt.name));
+ }
+ }
+ format!(" {}", chips.join(", "))
+ };
+ format!(" [~ Draft{}]{}", worktree, selected)
+ }
ListEntry::NewThread { worktrees, .. } => {
let worktree = if worktrees.is_empty() {
String::new()
@@ -274,11 +304,14 @@ fn visible_entries_as_strings(
async fn test_serialization_round_trip(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(3, &path_list, cx).await;
+ save_n_test_threads(3, &project, cx).await;
+
+ let path_list = project.read_with(cx, |project, cx| {
+ project.project_group_key(cx).path_list().clone()
+ });
// Set a custom width, collapse the group, and expand "View More".
sidebar.update_in(cx, |sidebar, window, cx| {
@@ -437,17 +470,15 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-1")),
"Fix crash in project panel".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
);
@@ -456,7 +487,7 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
"Add inline diff view".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -478,18 +509,16 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
let project = init_test_project("/project-a", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
// Single workspace with a thread
- let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-a1")),
"Thread A1".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -530,11 +559,10 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
async fn test_view_more_pagination(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(12, &path_list, cx).await;
+ save_n_test_threads(12, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -557,12 +585,15 @@ async fn test_view_more_pagination(cx: &mut TestAppContext) {
async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
// Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
- save_n_test_threads(17, &path_list, cx).await;
+ save_n_test_threads(17, &project, cx).await;
+
+ let path_list = project.read_with(cx, |project, cx| {
+ project.project_group_key(cx).path_list().clone()
+ });
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -629,11 +660,14 @@ async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
+
+ let path_list = project.read_with(cx, |project, cx| {
+ project.project_group_key(cx).path_list().clone()
+ });
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -685,9 +719,8 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
s.contents.entries = vec![
// Expanded project header
ListEntry::ProjectHeader {
- path_list: expanded_path.clone(),
+ key: project::ProjectGroupKey::new(None, expanded_path.clone()),
label: "expanded-project".into(),
- workspace: workspace.clone(),
highlight_positions: Vec::new(),
has_running_threads: false,
waiting_thread_count: 0,
@@ -809,14 +842,13 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
}),
// View More entry
ListEntry::ViewMore {
- path_list: expanded_path.clone(),
+ key: project::ProjectGroupKey::new(None, expanded_path.clone()),
is_fully_expanded: false,
},
// Collapsed project header
ListEntry::ProjectHeader {
- path_list: collapsed_path.clone(),
+ key: project::ProjectGroupKey::new(None, collapsed_path.clone()),
label: "collapsed-project".into(),
- workspace: workspace.clone(),
highlight_positions: Vec::new(),
has_running_threads: false,
waiting_thread_count: 0,
@@ -872,11 +904,10 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(3, &path_list, cx).await;
+ save_n_test_threads(3, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -932,11 +963,10 @@ async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(3, &path_list, cx).await;
+ save_n_test_threads(3, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -987,11 +1017,10 @@ async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext)
async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1029,11 +1058,10 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(8, &path_list, cx).await;
+ save_n_test_threads(8, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1064,11 +1092,10 @@ async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1109,11 +1136,10 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1177,11 +1203,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1254,15 +1279,13 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
// Open thread A and keep it generating.
let connection = StubAgentConnection::new();
open_thread_with_connection(&panel, connection.clone(), cx);
send_message(&panel, cx);
let session_id_a = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await;
+ save_test_thread_metadata(&session_id_a, &project, cx).await;
cx.update(|_, cx| {
connection.send_update(
@@ -1281,7 +1304,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
send_message(&panel, cx);
let session_id_b = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await;
+ save_test_thread_metadata(&session_id_b, &project, cx).await;
cx.run_until_parked();
@@ -1300,15 +1323,13 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
// Open thread on workspace A and keep it generating.
let connection_a = StubAgentConnection::new();
open_thread_with_connection(&panel_a, connection_a.clone(), cx);
send_message(&panel_a, cx);
let session_id_a = active_session_id(&panel_a, cx);
- save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
+ save_test_thread_metadata(&session_id_a, &project_a, cx).await;
cx.update(|_, cx| {
connection_a.send_update(
@@ -1358,11 +1379,9 @@ fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualT
async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
for (id, title, hour) in [
("t-1", "Fix crash in project panel", 3),
("t-2", "Add inline diff view", 2),
@@ -1373,7 +1392,7 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext)
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
);
}
@@ -1411,17 +1430,15 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
// Search should match case-insensitively so they can still find it.
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-1")),
"Fix Crash In Project Panel".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -1453,18 +1470,16 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
// to dismiss the filter and see the full list again.
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
save_thread_metadata(
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
)
}
@@ -1502,11 +1517,9 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
let project_a = init_test_project("/project-a", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
for (id, title, hour) in [
("a1", "Fix bug in sidebar", 2),
("a2", "Add tests for editor", 1),
@@ -1516,7 +1529,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list_a.clone(),
+ &project_a,
cx,
)
}
@@ -1527,7 +1540,8 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
});
cx.run_until_parked();
- let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
+ let project_b =
+ multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone());
for (id, title, hour) in [
("b1", "Refactor sidebar layout", 3),
@@ -1538,7 +1552,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list_b.clone(),
+ &project_b,
cx,
)
}
@@ -1584,11 +1598,9 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
let project_a = init_test_project("/alpha-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
-
for (id, title, hour) in [
("a1", "Fix bug in sidebar", 2),
("a2", "Add tests for editor", 1),
@@ -1598,7 +1610,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list_a.clone(),
+ &project_a,
cx,
)
}
@@ -1609,7 +1621,8 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
});
cx.run_until_parked();
- let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
+ let project_b =
+ multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone());
for (id, title, hour) in [
("b1", "Refactor sidebar layout", 3),
@@ -1620,7 +1633,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list_b.clone(),
+ &project_b,
cx,
)
}
@@ -1686,11 +1699,9 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
// Create 8 threads. The oldest one has a unique name and will be
// behind View More (only 5 shown by default).
for i in 0..8u32 {
@@ -1704,7 +1715,7 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
)
}
@@ -1738,17 +1749,15 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-1")),
"Important thread".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -1779,11 +1788,9 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte
async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
for (id, title, hour) in [
("t-1", "Fix crash in panel", 3),
("t-2", "Fix lint warnings", 2),
@@ -1794,7 +1801,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
)
}
@@ -1841,7 +1848,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
multi_workspace.update_in(cx, |mw, window, cx| {
@@ -1849,14 +1856,12 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
});
cx.run_until_parked();
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("hist-1")),
"Historical Thread".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -1899,17 +1904,15 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("t-1")),
"Thread A".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
);
@@ -1918,7 +1921,7 @@ async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppCo
"Thread B".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
@@ -1966,8 +1969,6 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Hi there!".into()),
@@ -1976,7 +1977,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
send_message(&panel, cx);
let session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
+ save_test_thread_metadata(&session_id, &project, cx).await;
cx.run_until_parked();
assert_eq!(
@@ -2014,8 +2015,6 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
// Save a thread so it appears in the list.
let connection_a = StubAgentConnection::new();
connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2024,7 +2023,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
open_thread_with_connection(&panel_a, connection_a, cx);
send_message(&panel_a, cx);
let session_id_a = active_session_id(&panel_a, cx);
- save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
+ save_test_thread_metadata(&session_id_a, &project_a, cx).await;
// Add a second workspace with its own agent panel.
let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
@@ -2099,8 +2098,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
open_thread_with_connection(&panel_b, connection_b, cx);
send_message(&panel_b, cx);
let session_id_b = active_session_id(&panel_b, cx);
- let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
- save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await;
+ save_test_thread_metadata(&session_id_b, &project_b, cx).await;
cx.run_until_parked();
// Workspace A is currently active. Click a thread in workspace B,
@@ -2161,7 +2159,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
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, path_list_b.clone(), cx).await;
+ 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
@@ -2243,8 +2241,6 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
// Start a thread and send a message so it has history.
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2253,7 +2249,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
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, path_list_a.clone(), cx).await;
+ save_test_thread_metadata(&session_id, &project, cx).await;
cx.run_until_parked();
// Verify the thread appears in the sidebar.
@@ -2287,9 +2283,15 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
// 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!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a, project-b]", " Hello *",]
+ vec![
+ "v [project-a, project-b]", //
+ " Hello *",
+ "v [project-a]",
+ ]
);
// The "New Thread" button must still be clickable (not stuck in
@@ -2334,8 +2336,6 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
// Create a non-empty thread (has messages).
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2345,7 +2345,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
send_message(&panel, cx);
let session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
+ save_test_thread_metadata(&session_id, &project, cx).await;
cx.run_until_parked();
assert_eq!(
@@ -2365,8 +2365,8 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Hello *"],
- "After Cmd-N the sidebar should show a highlighted New Thread entry"
+ vec!["v [my-project]", " [~ Draft]", " Hello *"],
+ "After Cmd-N the sidebar should show a highlighted Draft entry"
);
sidebar.read_with(cx, |sidebar, _cx| {
@@ -2385,8 +2385,6 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
// Create a saved thread so the workspace has history.
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2395,7 +2393,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
open_thread_with_connection(&panel, connection, cx);
send_message(&panel, cx);
let saved_session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&saved_session_id, path_list.clone(), cx).await;
+ save_test_thread_metadata(&saved_session_id, &project, cx).await;
cx.run_until_parked();
assert_eq!(
@@ -2412,8 +2410,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]", " [+ New Thread]", " Hello *"],
- "Draft with a server session should still show as [+ New Thread]"
+ vec!["v [my-project]", " [~ Draft]", " Hello *"],
);
let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
@@ -2503,17 +2500,12 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
send_message(&worktree_panel, cx);
let session_id = active_session_id(&worktree_panel, cx);
- let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
- save_test_thread_metadata(&session_id, wt_path_list, cx).await;
+ save_test_thread_metadata(&session_id, &worktree_project, cx).await;
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " Hello {wt-feature-a} *"
- ]
+ vec!["v [project]", " Hello {wt-feature-a} *"]
);
// Simulate Cmd-N in the worktree workspace.
@@ -2529,12 +2521,11 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
visible_entries_as_strings(&sidebar, cx),
vec![
"v [project]",
- " [+ New Thread]",
- " [+ New Thread {wt-feature-a}]",
+ " [~ Draft {wt-feature-a}]",
" Hello {wt-feature-a} *"
],
"After Cmd-N in an absorbed worktree, the sidebar should show \
- a highlighted New Thread entry under the main repo header"
+ a highlighted Draft entry under the main repo header"
);
sidebar.read_with(cx, |sidebar, _cx| {
@@ -2586,14 +2577,17 @@ async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
.update(cx, |project, cx| project.git_scans_complete(cx))
.await;
+ let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
+ worktree_project
+ .update(cx, |p, cx| p.git_scans_complete(cx))
+ .await;
+
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
- let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
- save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await;
- save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await;
+ save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
+ save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2615,13 +2609,17 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
.update(cx, |project, cx| project.git_scans_complete(cx))
.await;
+ let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
+ worktree_project
+ .update(cx, |p, cx| p.git_scans_complete(cx))
+ .await;
+
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
// Save a thread against a worktree path that doesn't exist yet.
- let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
- save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
+ 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();
@@ -2650,11 +2648,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " Worktree Thread {rosewood}",
- ]
+ vec!["v [project]", " Worktree Thread {rosewood}",]
);
}
@@ -2714,10 +2708,8 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
});
let sidebar = setup_sidebar(&multi_workspace, cx);
- let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
- let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
- save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
- save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await;
+ save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
+ save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2748,7 +2740,6 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
visible_entries_as_strings(&sidebar, cx),
vec![
"v [project]",
- " [+ New Thread]",
" Thread A {wt-feature-a}",
" Thread B {wt-feature-b}",
]
@@ -2813,8 +2804,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
let sidebar = setup_sidebar(&multi_workspace, cx);
// Only save a thread for workspace A.
- let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
- save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
+ save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2894,11 +2884,7 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext
let sidebar = setup_sidebar(&multi_workspace, cx);
// Save a thread under the same paths as the workspace roots.
- let thread_paths = PathList::new(&[
- std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
- std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"),
- ]);
- save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await;
+ save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2971,11 +2957,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext
let sidebar = setup_sidebar(&multi_workspace, cx);
// Thread with roots in both repos' "olivetti" worktrees.
- let thread_paths = PathList::new(&[
- std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
- std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"),
- ]);
- save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await;
+ save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -3070,8 +3052,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
let session_id = active_session_id(&worktree_panel, cx);
// Save metadata so the sidebar knows about this thread.
- let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
- save_test_thread_metadata(&session_id, wt_paths, cx).await;
+ save_test_thread_metadata(&session_id, &worktree_project, cx).await;
// Keep the thread generating by sending a chunk without ending
// the turn.
@@ -3091,7 +3072,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
entries,
vec![
"v [project]",
- " [+ New Thread]",
+ " [~ Draft]",
" Hello {wt-feature-a} * (running)",
]
);
@@ -474,6 +474,26 @@ impl MultiWorkspace {
self.project_group_keys.iter()
}
+ /// Returns the project groups, ordered by most recently added.
+ pub fn project_groups(
+ &self,
+ cx: &App,
+ ) -> impl Iterator<Item = (ProjectGroupKey, Vec<Entity<Workspace>>)> {
+ let mut groups = self
+ .project_group_keys
+ .iter()
+ .rev()
+ .map(|key| (key.clone(), Vec::new()))
+ .collect::<Vec<_>>();
+ for workspace in &self.workspaces {
+ let key = workspace.read(cx).project_group_key(cx);
+ if let Some((_, workspaces)) = groups.iter_mut().find(|(k, _)| k == &key) {
+ workspaces.push(workspace.clone());
+ }
+ }
+ groups.into_iter()
+ }
+
pub fn workspace(&self) -> &Entity<Workspace> {
&self.workspaces[self.active_workspace_index]
}