@@ -64,8 +64,7 @@ fn migrate_thread_metadata(cx: &mut App) -> Task<anyhow::Result<()>> {
title: entry.title,
updated_at: entry.updated_at,
created_at: entry.created_at,
- folder_paths: entry.folder_paths,
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::from_folder_paths(&entry.folder_paths),
remote_connection: None,
archived: true,
})
@@ -82,11 +81,11 @@ fn migrate_thread_metadata(cx: &mut App) -> Task<anyhow::Result<()>> {
if is_first_migration {
let mut per_project: HashMap<PathList, Vec<&mut ThreadMetadata>> = HashMap::default();
for entry in &mut to_migrate {
- if entry.folder_paths.is_empty() {
+ if entry.worktree_paths.is_empty() {
continue;
}
per_project
- .entry(entry.folder_paths.clone())
+ .entry(entry.worktree_paths.folder_path_list().clone())
.or_default()
.push(entry);
}
@@ -164,8 +163,8 @@ fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task<anyhow::
}
if let Some(remote_connection) = remote_path_lists
- .get(&metadata.folder_paths)
- .or_else(|| remote_path_lists.get(&metadata.main_worktree_paths))
+ .get(metadata.folder_paths())
+ .or_else(|| remote_path_lists.get(metadata.main_worktree_paths()))
{
db.save(ThreadMetadata {
remote_connection: Some(remote_connection.clone()),
@@ -195,6 +194,135 @@ fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task<anyhow::
struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
impl Global for GlobalThreadMetadataStore {}
+/// Paired worktree paths for a thread. Each folder path has a corresponding
+/// main worktree path at the same position. The two lists are always the
+/// same length and are modified together via `add_path` / `remove_main_path`.
+///
+/// For non-linked worktrees, the main path and folder path are identical.
+/// For linked worktrees, the main path is the original repo and the folder
+/// path is the linked worktree location.
+///
+/// Internally stores two `PathList`s with matching insertion order so that
+/// `ordered_paths()` on both yields positionally-paired results.
+#[derive(Default, Debug, Clone)]
+pub struct ThreadWorktreePaths {
+ folder_paths: PathList,
+ main_worktree_paths: PathList,
+}
+
+impl PartialEq for ThreadWorktreePaths {
+ fn eq(&self, other: &Self) -> bool {
+ self.folder_paths == other.folder_paths
+ && self.main_worktree_paths == other.main_worktree_paths
+ }
+}
+
+impl ThreadWorktreePaths {
+ /// Build from a project's current state. Each visible worktree is paired
+ /// with its main repo path (resolved via git), falling back to the
+ /// worktree's own path if no git repo is found.
+ pub fn from_project(project: &project::Project, cx: &App) -> Self {
+ let (mains, folders): (Vec<PathBuf>, Vec<PathBuf>) = project
+ .visible_worktrees(cx)
+ .map(|worktree| {
+ let snapshot = worktree.read(cx).snapshot();
+ let folder_path = snapshot.abs_path().to_path_buf();
+ let main_path = snapshot
+ .root_repo_common_dir()
+ .and_then(|dir| Some(dir.parent()?.to_path_buf()))
+ .unwrap_or_else(|| folder_path.clone());
+ (main_path, folder_path)
+ })
+ .unzip();
+ Self {
+ folder_paths: PathList::new(&folders),
+ main_worktree_paths: PathList::new(&mains),
+ }
+ }
+
+ /// Build from two parallel `PathList`s that already share the same
+ /// insertion order. Used for deserialization from DB.
+ ///
+ /// Returns an error if the two lists have different lengths, which
+ /// indicates corrupted data from a prior migration bug.
+ pub fn from_path_lists(
+ main_worktree_paths: PathList,
+ folder_paths: PathList,
+ ) -> anyhow::Result<Self> {
+ anyhow::ensure!(
+ main_worktree_paths.paths().len() == folder_paths.paths().len(),
+ "main_worktree_paths has {} entries but folder_paths has {}",
+ main_worktree_paths.paths().len(),
+ folder_paths.paths().len(),
+ );
+ Ok(Self {
+ folder_paths,
+ main_worktree_paths,
+ })
+ }
+
+ /// Build for non-linked worktrees where main == folder for every path.
+ pub fn from_folder_paths(folder_paths: &PathList) -> Self {
+ Self {
+ folder_paths: folder_paths.clone(),
+ main_worktree_paths: folder_paths.clone(),
+ }
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.folder_paths.is_empty()
+ }
+
+ /// The folder paths (for workspace matching / `threads_by_paths` index).
+ pub fn folder_path_list(&self) -> &PathList {
+ &self.folder_paths
+ }
+
+ /// The main worktree paths (for group key / `threads_by_main_paths` index).
+ pub fn main_worktree_path_list(&self) -> &PathList {
+ &self.main_worktree_paths
+ }
+
+ /// Iterate the (main_worktree_path, folder_path) pairs in insertion order.
+ pub fn ordered_pairs(&self) -> impl Iterator<Item = (&PathBuf, &PathBuf)> {
+ self.main_worktree_paths
+ .ordered_paths()
+ .zip(self.folder_paths.ordered_paths())
+ }
+
+ /// Add a new path pair. If the exact (main, folder) pair already exists,
+ /// this is a no-op. Rebuilds both internal `PathList`s to maintain
+ /// consistent ordering.
+ pub fn add_path(&mut self, main_path: &Path, folder_path: &Path) {
+ let already_exists = self
+ .ordered_pairs()
+ .any(|(m, f)| m.as_path() == main_path && f.as_path() == folder_path);
+ if already_exists {
+ return;
+ }
+ let (mut mains, mut folders): (Vec<PathBuf>, Vec<PathBuf>) = self
+ .ordered_pairs()
+ .map(|(m, f)| (m.clone(), f.clone()))
+ .unzip();
+ mains.push(main_path.to_path_buf());
+ folders.push(folder_path.to_path_buf());
+ self.main_worktree_paths = PathList::new(&mains);
+ self.folder_paths = PathList::new(&folders);
+ }
+
+ /// Remove all pairs whose main worktree path matches the given path.
+ /// This removes the corresponding entries from both lists.
+ pub fn remove_main_path(&mut self, main_path: &Path) {
+ let (mains, folders): (Vec<PathBuf>, Vec<PathBuf>) = self
+ .ordered_pairs()
+ .filter(|(m, _)| m.as_path() != main_path)
+ .map(|(m, f)| (m.clone(), f.clone()))
+ .unzip();
+ self.main_worktree_paths = PathList::new(&mains);
+ self.folder_paths = PathList::new(&folders);
+ }
+}
+
/// Lightweight metadata for any thread (native or ACP), enough to populate
/// the sidebar list and route to the correct load path when clicked.
#[derive(Debug, Clone, PartialEq)]
@@ -204,17 +332,25 @@ pub struct ThreadMetadata {
pub title: SharedString,
pub updated_at: DateTime<Utc>,
pub created_at: Option<DateTime<Utc>>,
- pub folder_paths: PathList,
- pub main_worktree_paths: PathList,
+ pub worktree_paths: ThreadWorktreePaths,
pub remote_connection: Option<RemoteConnectionOptions>,
pub archived: bool,
}
+impl ThreadMetadata {
+ pub fn folder_paths(&self) -> &PathList {
+ self.worktree_paths.folder_path_list()
+ }
+ pub fn main_worktree_paths(&self) -> &PathList {
+ self.worktree_paths.main_worktree_path_list()
+ }
+}
+
impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
fn from(meta: &ThreadMetadata) -> Self {
Self {
session_id: meta.session_id.clone(),
- work_dirs: Some(meta.folder_paths.clone()),
+ work_dirs: Some(meta.folder_paths().clone()),
title: Some(meta.title.clone()),
updated_at: Some(meta.updated_at),
created_at: meta.created_at,
@@ -398,12 +534,12 @@ impl ThreadMetadataStore {
for row in rows {
this.threads_by_paths
- .entry(row.folder_paths.clone())
+ .entry(row.folder_paths().clone())
.or_default()
.insert(row.session_id.clone());
- if !row.main_worktree_paths.is_empty() {
+ if !row.main_worktree_paths().is_empty() {
this.threads_by_main_paths
- .entry(row.main_worktree_paths.clone())
+ .entry(row.main_worktree_paths().clone())
.or_default()
.insert(row.session_id.clone());
}
@@ -438,17 +574,17 @@ impl ThreadMetadataStore {
fn save_internal(&mut self, metadata: ThreadMetadata) {
if let Some(thread) = self.threads.get(&metadata.session_id) {
- if thread.folder_paths != metadata.folder_paths {
- if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
+ if thread.folder_paths() != metadata.folder_paths() {
+ if let Some(session_ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
session_ids.remove(&metadata.session_id);
}
}
- if thread.main_worktree_paths != metadata.main_worktree_paths
- && !thread.main_worktree_paths.is_empty()
+ if thread.main_worktree_paths() != metadata.main_worktree_paths()
+ && !thread.main_worktree_paths().is_empty()
{
if let Some(session_ids) = self
.threads_by_main_paths
- .get_mut(&thread.main_worktree_paths)
+ .get_mut(thread.main_worktree_paths())
{
session_ids.remove(&metadata.session_id);
}
@@ -459,13 +595,13 @@ impl ThreadMetadataStore {
.insert(metadata.session_id.clone(), metadata.clone());
self.threads_by_paths
- .entry(metadata.folder_paths.clone())
+ .entry(metadata.folder_paths().clone())
.or_default()
.insert(metadata.session_id.clone());
- if !metadata.main_worktree_paths.is_empty() {
+ if !metadata.main_worktree_paths().is_empty() {
self.threads_by_main_paths
- .entry(metadata.main_worktree_paths.clone())
+ .entry(metadata.main_worktree_paths().clone())
.or_default()
.insert(metadata.session_id.clone());
}
@@ -483,7 +619,11 @@ impl ThreadMetadataStore {
) {
if let Some(thread) = self.threads.get(session_id) {
self.save_internal(ThreadMetadata {
- folder_paths: work_dirs,
+ worktree_paths: ThreadWorktreePaths::from_path_lists(
+ thread.main_worktree_paths().clone(),
+ work_dirs.clone(),
+ )
+ .unwrap_or_else(|_| ThreadWorktreePaths::from_folder_paths(&work_dirs)),
..thread.clone()
});
cx.notify();
@@ -524,7 +664,7 @@ impl ThreadMetadataStore {
cx: &mut Context<Self>,
) {
if let Some(thread) = self.threads.get(session_id).cloned() {
- let mut paths: Vec<PathBuf> = thread.folder_paths.paths().to_vec();
+ let mut paths: Vec<PathBuf> = thread.folder_paths().paths().to_vec();
for (old_path, new_path) in path_replacements {
if let Some(pos) = paths.iter().position(|p| p == old_path) {
paths[pos] = new_path.clone();
@@ -532,7 +672,11 @@ impl ThreadMetadataStore {
}
let new_folder_paths = PathList::new(&paths);
self.save_internal(ThreadMetadata {
- folder_paths: new_folder_paths,
+ worktree_paths: ThreadWorktreePaths::from_path_lists(
+ thread.main_worktree_paths().clone(),
+ new_folder_paths.clone(),
+ )
+ .unwrap_or_else(|_| ThreadWorktreePaths::from_folder_paths(&new_folder_paths)),
..thread
});
cx.notify();
@@ -546,7 +690,7 @@ impl ThreadMetadataStore {
cx: &mut Context<Self>,
) {
if let Some(thread) = self.threads.get(session_id).cloned() {
- let mut paths: Vec<PathBuf> = thread.folder_paths.paths().to_vec();
+ let mut paths: Vec<PathBuf> = thread.folder_paths().paths().to_vec();
for (old_path, new_path) in path_replacements {
for path in &mut paths {
if path == old_path {
@@ -556,13 +700,69 @@ impl ThreadMetadataStore {
}
let new_folder_paths = PathList::new(&paths);
self.save_internal(ThreadMetadata {
- folder_paths: new_folder_paths,
+ worktree_paths: ThreadWorktreePaths::from_path_lists(
+ thread.main_worktree_paths().clone(),
+ new_folder_paths.clone(),
+ )
+ .unwrap_or_else(|_| ThreadWorktreePaths::from_folder_paths(&new_folder_paths)),
..thread
});
cx.notify();
}
}
+ /// Apply a mutation to the worktree paths of all threads whose current
+ /// `main_worktree_paths` matches `current_main_paths`, then re-index.
+ pub fn change_worktree_paths(
+ &mut self,
+ current_main_paths: &PathList,
+ mutate: impl Fn(&mut ThreadWorktreePaths),
+ cx: &mut Context<Self>,
+ ) {
+ let session_ids: Vec<_> = self
+ .threads_by_main_paths
+ .get(current_main_paths)
+ .into_iter()
+ .flatten()
+ .cloned()
+ .collect();
+
+ if session_ids.is_empty() {
+ return;
+ }
+
+ for session_id in &session_ids {
+ if let Some(thread) = self.threads.get_mut(session_id) {
+ if let Some(ids) = self
+ .threads_by_main_paths
+ .get_mut(thread.main_worktree_paths())
+ {
+ ids.remove(session_id);
+ }
+ if let Some(ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
+ ids.remove(session_id);
+ }
+
+ mutate(&mut thread.worktree_paths);
+
+ self.threads_by_main_paths
+ .entry(thread.main_worktree_paths().clone())
+ .or_default()
+ .insert(session_id.clone());
+ self.threads_by_paths
+ .entry(thread.folder_paths().clone())
+ .or_default()
+ .insert(session_id.clone());
+
+ self.pending_thread_ops_tx
+ .try_send(DbOperation::Upsert(thread.clone()))
+ .log_err();
+ }
+ }
+
+ cx.notify();
+ }
+
pub fn create_archived_worktree(
&self,
worktree_path: String,
@@ -655,13 +855,13 @@ impl ThreadMetadataStore {
pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
if let Some(thread) = self.threads.get(&session_id) {
- if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
+ if let Some(session_ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
session_ids.remove(&session_id);
}
- if !thread.main_worktree_paths.is_empty() {
+ if !thread.main_worktree_paths().is_empty() {
if let Some(session_ids) = self
.threads_by_main_paths
- .get_mut(&thread.main_worktree_paths)
+ .get_mut(thread.main_worktree_paths())
{
session_ids.remove(&session_id);
}
@@ -802,16 +1002,9 @@ impl ThreadMetadataStore {
let agent_id = thread_ref.connection().agent_id();
let project = thread_ref.project().read(cx);
- let folder_paths = {
- let paths: Vec<Arc<Path>> = project
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path())
- .collect();
- PathList::new(&paths)
- };
+ let worktree_paths = ThreadWorktreePaths::from_project(project, cx);
let project_group_key = project.project_group_key(cx);
- let main_worktree_paths = project_group_key.path_list().clone();
let remote_connection = project_group_key.host();
// Threads without a folder path (e.g. started in an empty
@@ -820,7 +1013,7 @@ impl ThreadMetadataStore {
// them from the archive.
let archived = existing_thread
.map(|t| t.archived)
- .unwrap_or(folder_paths.is_empty());
+ .unwrap_or(worktree_paths.is_empty());
let metadata = ThreadMetadata {
session_id,
@@ -828,8 +1021,7 @@ impl ThreadMetadataStore {
title,
created_at: Some(created_at),
updated_at,
- folder_paths,
- main_worktree_paths,
+ worktree_paths,
remote_connection,
archived,
};
@@ -919,19 +1111,19 @@ impl ThreadMetadataDb {
let title = row.title.to_string();
let updated_at = row.updated_at.to_rfc3339();
let created_at = row.created_at.map(|dt| dt.to_rfc3339());
- let serialized = row.folder_paths.serialize();
- let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
+ let serialized = row.folder_paths().serialize();
+ let (folder_paths, folder_paths_order) = if row.folder_paths().is_empty() {
(None, None)
} else {
(Some(serialized.paths), Some(serialized.order))
};
- let main_serialized = row.main_worktree_paths.serialize();
- let (main_worktree_paths, main_worktree_paths_order) = if row.main_worktree_paths.is_empty()
- {
- (None, None)
- } else {
- (Some(main_serialized.paths), Some(main_serialized.order))
- };
+ let main_serialized = row.main_worktree_paths().serialize();
+ let (main_worktree_paths, main_worktree_paths_order) =
+ if row.main_worktree_paths().is_empty() {
+ (None, None)
+ } else {
+ (Some(main_serialized.paths), Some(main_serialized.order))
+ };
let remote_connection = row
.remote_connection
.as_ref()
@@ -1136,6 +1328,10 @@ impl Column for ThreadMetadata {
.transpose()
.context("deserialize thread metadata remote connection")?;
+ let worktree_paths =
+ ThreadWorktreePaths::from_path_lists(main_worktree_paths, folder_paths)
+ .unwrap_or_else(|_| ThreadWorktreePaths::default());
+
Ok((
ThreadMetadata {
session_id: acp::SessionId::new(id),
@@ -1143,8 +1339,7 @@ impl Column for ThreadMetadata {
title: title.into(),
updated_at,
created_at,
- folder_paths,
- main_worktree_paths,
+ worktree_paths,
remote_connection,
archived,
},
@@ -1227,8 +1422,7 @@ mod tests {
title: title.to_string().into(),
updated_at,
created_at: Some(updated_at),
- folder_paths,
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::from_folder_paths(&folder_paths),
remote_connection: None,
}
}
@@ -1459,8 +1653,7 @@ mod tests {
title: "Existing Metadata".into(),
updated_at: now - chrono::Duration::seconds(10),
created_at: Some(now - chrono::Duration::seconds(10)),
- folder_paths: project_a_paths.clone(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::from_folder_paths(&project_a_paths),
remote_connection: None,
archived: false,
};
@@ -1569,8 +1762,7 @@ mod tests {
title: "Existing Metadata".into(),
updated_at: existing_updated_at,
created_at: Some(existing_updated_at),
- folder_paths: project_paths.clone(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::from_folder_paths(&project_paths),
remote_connection: None,
archived: false,
};
@@ -1747,7 +1939,7 @@ mod tests {
// Project A: 5 most recent should be unarchived, 2 oldest should be archived
let mut project_a_entries: Vec<_> = list
.iter()
- .filter(|m| m.folder_paths == project_a_paths)
+ .filter(|m| *m.folder_paths() == project_a_paths)
.collect();
assert_eq!(project_a_entries.len(), 7);
project_a_entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
@@ -1770,7 +1962,7 @@ mod tests {
// Project B: all 3 should be unarchived (under the limit)
let project_b_entries: Vec<_> = list
.iter()
- .filter(|m| m.folder_paths == project_b_paths)
+ .filter(|m| *m.folder_paths() == project_b_paths)
.collect();
assert_eq!(project_b_entries.len(), 3);
assert!(project_b_entries.iter().all(|m| !m.archived));
@@ -1934,7 +2126,7 @@ mod tests {
let without_worktree = store
.entry(&session_without_worktree)
.expect("missing metadata for thread without project association");
- assert!(without_worktree.folder_paths.is_empty());
+ assert!(without_worktree.folder_paths().is_empty());
assert!(
without_worktree.archived,
"expected thread without project association to be archived"
@@ -1944,7 +2136,7 @@ mod tests {
.entry(&session_with_worktree)
.expect("missing metadata for thread with project association");
assert_eq!(
- with_worktree.folder_paths,
+ *with_worktree.folder_paths(),
PathList::new(&[Path::new("/project-a")])
);
assert!(
@@ -2578,7 +2770,7 @@ mod tests {
store.entry(&acp::SessionId::new("session-multi")).cloned()
});
let entry = entry.unwrap();
- let paths = entry.folder_paths.paths();
+ let paths = entry.folder_paths().paths();
assert_eq!(paths.len(), 3);
assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
@@ -2623,7 +2815,7 @@ mod tests {
.cloned()
});
let entry = entry.unwrap();
- let paths = entry.folder_paths.paths();
+ let paths = entry.folder_paths().paths();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
assert!(paths.contains(&PathBuf::from("/other/path")));
@@ -2669,7 +2861,7 @@ mod tests {
store.entry(&acp::SessionId::new("session-multi")).cloned()
});
let entry = entry.unwrap();
- let paths = entry.folder_paths.paths();
+ let paths = entry.folder_paths().paths();
assert_eq!(paths.len(), 3);
assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
@@ -2714,7 +2906,7 @@ mod tests {
.cloned()
});
let entry = entry.unwrap();
- let paths = entry.folder_paths.paths();
+ let paths = entry.folder_paths().paths();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
assert!(paths.contains(&PathBuf::from("/other/path")));
@@ -2786,4 +2978,136 @@ mod tests {
assert!(paths.contains(&Path::new("/projects/worktree-a")));
assert!(paths.contains(&Path::new("/projects/worktree-b")));
}
+
+ // ── ThreadWorktreePaths tests ──────────────────────────────────────
+
+ /// Helper to build a `ThreadWorktreePaths` from (main, folder) pairs.
+ fn make_worktree_paths(pairs: &[(&str, &str)]) -> ThreadWorktreePaths {
+ let (mains, folders): (Vec<&Path>, Vec<&Path>) = pairs
+ .iter()
+ .map(|(m, f)| (Path::new(*m), Path::new(*f)))
+ .unzip();
+ ThreadWorktreePaths::from_path_lists(PathList::new(&mains), PathList::new(&folders))
+ .unwrap()
+ }
+
+ #[test]
+ fn test_thread_worktree_paths_full_add_then_remove_cycle() {
+ // Full scenario from the issue:
+ // 1. Start with linked worktree selectric → zed
+ // 2. Add cloud
+ // 3. Remove zed
+
+ let mut paths = make_worktree_paths(&[("/projects/zed", "/worktrees/selectric/zed")]);
+
+ // Step 2: add cloud
+ paths.add_path(Path::new("/projects/cloud"), Path::new("/projects/cloud"));
+
+ assert_eq!(paths.ordered_pairs().count(), 2);
+ assert_eq!(
+ paths.folder_path_list(),
+ &PathList::new(&[
+ Path::new("/worktrees/selectric/zed"),
+ Path::new("/projects/cloud"),
+ ])
+ );
+ assert_eq!(
+ paths.main_worktree_path_list(),
+ &PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/cloud"),])
+ );
+
+ // Step 3: remove zed
+ paths.remove_main_path(Path::new("/projects/zed"));
+
+ assert_eq!(paths.ordered_pairs().count(), 1);
+ assert_eq!(
+ paths.folder_path_list(),
+ &PathList::new(&[Path::new("/projects/cloud")])
+ );
+ assert_eq!(
+ paths.main_worktree_path_list(),
+ &PathList::new(&[Path::new("/projects/cloud")])
+ );
+ }
+
+ #[test]
+ fn test_thread_worktree_paths_add_is_idempotent() {
+ let mut paths = make_worktree_paths(&[("/projects/zed", "/projects/zed")]);
+
+ paths.add_path(Path::new("/projects/zed"), Path::new("/projects/zed"));
+
+ assert_eq!(paths.ordered_pairs().count(), 1);
+ }
+
+ #[test]
+ fn test_thread_worktree_paths_remove_nonexistent_is_noop() {
+ let mut paths = make_worktree_paths(&[("/projects/zed", "/worktrees/selectric/zed")]);
+
+ paths.remove_main_path(Path::new("/projects/nonexistent"));
+
+ assert_eq!(paths.ordered_pairs().count(), 1);
+ }
+
+ #[test]
+ fn test_thread_worktree_paths_from_path_lists_preserves_association() {
+ let folder = PathList::new(&[
+ Path::new("/worktrees/selectric/zed"),
+ Path::new("/projects/cloud"),
+ ]);
+ let main = PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/cloud")]);
+
+ let paths = ThreadWorktreePaths::from_path_lists(main, folder).unwrap();
+
+ let pairs: Vec<_> = paths
+ .ordered_pairs()
+ .map(|(m, f)| (m.clone(), f.clone()))
+ .collect();
+ assert_eq!(pairs.len(), 2);
+ assert!(pairs.contains(&(
+ PathBuf::from("/projects/zed"),
+ PathBuf::from("/worktrees/selectric/zed")
+ )));
+ assert!(pairs.contains(&(
+ PathBuf::from("/projects/cloud"),
+ PathBuf::from("/projects/cloud")
+ )));
+ }
+
+ #[test]
+ fn test_thread_worktree_paths_main_deduplicates_linked_worktrees() {
+ // Two linked worktrees of the same main repo: the main_worktree_path_list
+ // deduplicates because PathList stores unique sorted paths, but
+ // ordered_pairs still has both entries.
+ let paths = make_worktree_paths(&[
+ ("/projects/zed", "/worktrees/selectric/zed"),
+ ("/projects/zed", "/worktrees/feature/zed"),
+ ]);
+
+ // main_worktree_path_list has the duplicate main path twice
+ // (PathList keeps all entries from its input)
+ assert_eq!(paths.ordered_pairs().count(), 2);
+ assert_eq!(
+ paths.folder_path_list(),
+ &PathList::new(&[
+ Path::new("/worktrees/selectric/zed"),
+ Path::new("/worktrees/feature/zed"),
+ ])
+ );
+ assert_eq!(
+ paths.main_worktree_path_list(),
+ &PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/zed"),])
+ );
+ }
+
+ #[test]
+ fn test_thread_worktree_paths_mismatched_lengths_returns_error() {
+ let folder = PathList::new(&[
+ Path::new("/worktrees/selectric/zed"),
+ Path::new("/projects/cloud"),
+ ]);
+ let main = PathList::new(&[Path::new("/projects/zed")]);
+
+ let result = ThreadWorktreePaths::from_path_lists(main, folder);
+ assert!(result.is_err());
+ }
}
@@ -3,7 +3,7 @@ use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
use agent::ThreadStore;
use agent_ui::{
test_support::{active_session_id, open_thread_with_connection, send_message},
- thread_metadata_store::ThreadMetadata,
+ thread_metadata_store::{ThreadMetadata, ThreadWorktreePaths},
};
use chrono::DateTime;
use fs::{FakeFs, Fs};
@@ -226,24 +226,14 @@ fn save_thread_metadata(
cx: &mut TestAppContext,
) {
cx.update(|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 worktree_paths = ThreadWorktreePaths::from_project(project.read(cx), cx);
let metadata = ThreadMetadata {
session_id,
agent_id: agent::ZED_AGENT_ID.clone(),
title,
updated_at,
created_at,
- folder_paths,
- main_worktree_paths,
+ worktree_paths,
archived: false,
remote_connection: None,
};
@@ -252,6 +242,33 @@ fn save_thread_metadata(
cx.run_until_parked();
}
+fn save_thread_metadata_with_main_paths(
+ session_id: &str,
+ title: &str,
+ folder_paths: PathList,
+ main_worktree_paths: PathList,
+ cx: &mut TestAppContext,
+) {
+ let session_id = acp::SessionId::new(Arc::from(session_id));
+ let title = SharedString::from(title.to_string());
+ let updated_at = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap();
+ let metadata = ThreadMetadata {
+ session_id,
+ agent_id: agent::ZED_AGENT_ID.clone(),
+ title,
+ updated_at,
+ created_at: None,
+ worktree_paths: ThreadWorktreePaths::from_path_lists(main_worktree_paths, folder_paths)
+ .unwrap(),
+ archived: false,
+ remote_connection: None,
+ };
+ cx.update(|cx| {
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
+ });
+ cx.run_until_parked();
+}
+
fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
sidebar.update_in(cx, |_, window, cx| {
cx.focus_self(window);
@@ -323,6 +340,11 @@ fn visible_entries_as_strings(
} else {
""
};
+ let is_active = sidebar
+ .active_entry
+ .as_ref()
+ .is_some_and(|active| active.matches_entry(entry));
+ let active_indicator = if is_active { " (active)" } else { "" };
match entry {
ListEntry::ProjectHeader {
label,
@@ -339,7 +361,7 @@ fn visible_entries_as_strings(
}
ListEntry::Thread(thread) => {
let title = thread.metadata.title.as_ref();
- let active = if thread.is_live { " *" } else { "" };
+ let live = if thread.is_live { " *" } else { "" };
let status_str = match thread.status {
AgentThreadStatus::Running => " (running)",
AgentThreadStatus::Error => " (error)",
@@ -355,7 +377,7 @@ fn visible_entries_as_strings(
""
};
let worktree = format_linked_worktree_chips(&thread.worktrees);
- format!(" {title}{worktree}{active}{status_str}{notified}{selected}")
+ format!(" {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}")
}
ListEntry::ViewMore {
is_fully_expanded, ..
@@ -375,7 +397,7 @@ fn visible_entries_as_strings(
if workspace.is_some() {
format!(" [+ New Thread{}]{}", worktree, selected)
} else {
- format!(" [~ Draft{}]{}", worktree, selected)
+ format!(" [~ Draft{}]{}{}", worktree, active_indicator, selected)
}
}
}
@@ -544,7 +566,10 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]"]
+ vec![
+ //
+ "v [my-project]",
+ ]
);
}
@@ -580,6 +605,7 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Fix crash in project panel",
" Add inline diff view",
@@ -610,7 +636,11 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " Thread A1"]
+ vec![
+ //
+ "v [project-a]",
+ " Thread A1",
+ ]
);
// Add a second workspace
@@ -621,7 +651,11 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " Thread A1",]
+ vec![
+ //
+ "v [project-a]",
+ " Thread A1",
+ ]
);
}
@@ -640,6 +674,7 @@ async fn test_view_more_pagination(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Thread 12",
" Thread 11",
@@ -750,7 +785,11 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Thread 1"]
+ vec![
+ //
+ "v [my-project]",
+ " Thread 1",
+ ]
);
// Collapse
@@ -761,7 +800,10 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["> [my-project]"]
+ vec![
+ //
+ "> [my-project]",
+ ]
);
// Expand
@@ -772,7 +814,11 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Thread 1"]
+ vec![
+ //
+ "v [my-project]",
+ " Thread 1",
+ ]
);
}
@@ -808,8 +854,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
metadata: ThreadMetadata {
session_id: acp::SessionId::new(Arc::from("t-1")),
agent_id: AgentId::new("zed-agent"),
- folder_paths: PathList::default(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::default(),
title: "Completed thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
@@ -832,8 +877,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
metadata: ThreadMetadata {
session_id: acp::SessionId::new(Arc::from("t-2")),
agent_id: AgentId::new("zed-agent"),
- folder_paths: PathList::default(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::default(),
title: "Running thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
@@ -856,13 +900,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
metadata: ThreadMetadata {
session_id: acp::SessionId::new(Arc::from("t-3")),
agent_id: AgentId::new("zed-agent"),
- remote_connection: None,
- folder_paths: PathList::default(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::default(),
title: "Error thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
archived: false,
+ remote_connection: None,
},
icon: IconName::ZedAgent,
icon_from_external_svg: None,
@@ -881,13 +924,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
metadata: ThreadMetadata {
session_id: acp::SessionId::new(Arc::from("t-4")),
agent_id: AgentId::new("zed-agent"),
- remote_connection: None,
- folder_paths: PathList::default(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::default(),
title: "Waiting thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
archived: false,
+ remote_connection: None,
},
icon: IconName::ZedAgent,
icon_from_external_svg: None,
@@ -906,13 +948,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
metadata: ThreadMetadata {
session_id: acp::SessionId::new(Arc::from("t-5")),
agent_id: AgentId::new("zed-agent"),
- remote_connection: None,
- folder_paths: PathList::default(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::default(),
title: "Notified thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
archived: false,
+ remote_connection: None,
},
icon: IconName::ZedAgent,
icon_from_external_svg: None,
@@ -949,6 +990,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [expanded-project]",
" Completed thread",
" Running thread * (running) <== selected",
@@ -1112,10 +1154,14 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Thread 1"]
+ vec![
+ //
+ "v [my-project]",
+ " Thread 1",
+ ]
);
- // Focus the sidebar and select the header (index 0)
+ // Focus the sidebar and select the header
focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(0);
@@ -1127,7 +1173,10 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["> [my-project] <== selected"]
+ vec![
+ //
+ "> [my-project] <== selected",
+ ]
);
// Confirm again expands the group
@@ -1136,7 +1185,11 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project] <== selected", " Thread 1",]
+ vec![
+ //
+ "v [my-project] <== selected",
+ " Thread 1",
+ ]
);
}
@@ -1187,7 +1240,11 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Thread 1"]
+ vec![
+ //
+ "v [my-project]",
+ " Thread 1",
+ ]
);
// Focus sidebar and manually select the header (index 0). Press left to collapse.
@@ -1201,7 +1258,10 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["> [my-project] <== selected"]
+ vec![
+ //
+ "> [my-project] <== selected",
+ ]
);
// Press right to expand
@@ -1210,7 +1270,11 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project] <== selected", " Thread 1",]
+ vec![
+ //
+ "v [my-project] <== selected",
+ " Thread 1",
+ ]
);
// Press right again on already-expanded header moves selection down
@@ -1237,7 +1301,11 @@ async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContex
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Thread 1 <== selected",]
+ vec![
+ //
+ "v [my-project]",
+ " Thread 1 <== selected",
+ ]
);
// Pressing left on a child collapses the parent group and selects it
@@ -1247,7 +1315,10 @@ async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContex
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["> [my-project] <== selected"]
+ vec![
+ //
+ "> [my-project] <== selected",
+ ]
);
}
@@ -1261,7 +1332,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
// An empty project has only the header.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [empty-project]"]
+ vec![
+ //
+ "v [empty-project]",
+ ]
);
// Focus sidebar — focus_in does not set a selection
@@ -1393,7 +1467,12 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
entries[1..].sort();
assert_eq!(
entries,
- vec!["v [my-project]", " Hello *", " Hello * (running)",]
+ vec![
+ //
+ "v [my-project]",
+ " Hello * (active)",
+ " Hello * (running)",
+ ]
);
}
@@ -1486,7 +1565,11 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
// Thread A is still running; no notification yet.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " Hello * (running)",]
+ vec![
+ //
+ "v [project-a]",
+ " Hello * (running) (active)",
+ ]
);
// Complete thread A's turn (transition Running → Completed).
@@ -1496,7 +1579,11 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
// The completed background thread shows a notification indicator.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " Hello * (!)",]
+ vec![
+ //
+ "v [project-a]",
+ " Hello * (!) (active)",
+ ]
);
}
@@ -1536,6 +1623,7 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext)
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Fix crash in project panel",
" Add inline diff view",
@@ -1548,7 +1636,11 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext)
type_in_search(&sidebar, "diff", cx);
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Add inline diff view <== selected",]
+ vec![
+ //
+ "v [my-project]",
+ " Add inline diff view <== selected",
+ ]
);
// User changes query to something with no matches — list is empty.
@@ -1583,6 +1675,7 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Fix Crash In Project Panel <== selected",
]
@@ -1593,6 +1686,7 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Fix Crash In Project Panel <== selected",
]
@@ -1623,7 +1717,12 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
// Confirm the full list is showing.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Alpha thread", " Beta thread",]
+ vec![
+ //
+ "v [my-project]",
+ " Alpha thread",
+ " Beta thread",
+ ]
);
// User types a search query to filter down.
@@ -1631,7 +1730,11 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
type_in_search(&sidebar, "alpha", cx);
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Alpha thread <== selected",]
+ vec![
+ //
+ "v [my-project]",
+ " Alpha thread <== selected",
+ ]
);
// User presses Escape — filter clears, full list is restored.
@@ -1641,6 +1744,7 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Alpha thread <== selected",
" Beta thread",
@@ -1697,6 +1801,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [project-a]",
" Fix bug in sidebar",
" Add tests for editor",
@@ -1707,7 +1812,11 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
type_in_search(&sidebar, "sidebar", cx);
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " Fix bug in sidebar <== selected",]
+ vec![
+ //
+ "v [project-a]",
+ " Fix bug in sidebar <== selected",
+ ]
);
// "typo" only matches in the second workspace — the first header disappears.
@@ -1723,6 +1832,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [project-a]",
" Fix bug in sidebar <== selected",
" Add tests for editor",
@@ -1782,6 +1892,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [alpha-project]",
" Fix bug in sidebar <== selected",
" Add tests for editor",
@@ -1793,7 +1904,11 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
type_in_search(&sidebar, "sidebar", cx);
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [alpha-project]", " Fix bug in sidebar <== selected",]
+ vec![
+ //
+ "v [alpha-project]",
+ " Fix bug in sidebar <== selected",
+ ]
);
// "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
@@ -1803,7 +1918,11 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
type_in_search(&sidebar, "fix", cx);
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [alpha-project]", " Fix bug in sidebar <== selected",]
+ vec![
+ //
+ "v [alpha-project]",
+ " Fix bug in sidebar <== selected",
+ ]
);
// A query that matches a workspace name AND a thread in that same workspace.
@@ -1812,6 +1931,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [alpha-project]",
" Fix bug in sidebar <== selected",
" Add tests for editor",
@@ -1825,6 +1945,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [alpha-project]",
" Fix bug in sidebar <== selected",
" Add tests for editor",
@@ -1874,7 +1995,11 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
let filtered = visible_entries_as_strings(&sidebar, cx);
assert_eq!(
filtered,
- vec!["v [my-project]", " Hidden gem thread <== selected",]
+ vec![
+ //
+ "v [my-project]",
+ " Hidden gem thread <== selected",
+ ]
);
assert!(
!filtered.iter().any(|e| e.contains("View More")),
@@ -1910,14 +2035,21 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["> [my-project] <== selected"]
+ vec![
+ //
+ "> [my-project] <== selected",
+ ]
);
// User types a search — the thread appears even though its group is collapsed.
type_in_search(&sidebar, "important", cx);
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["> [my-project]", " Important thread <== selected",]
+ vec![
+ //
+ "> [my-project]",
+ " Important thread <== selected",
+ ]
);
}
@@ -1951,6 +2083,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Fix crash in panel <== selected",
" Fix lint warnings",
@@ -1963,6 +2096,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Fix crash in panel",
" Fix lint warnings <== selected",
@@ -1974,6 +2108,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
+ //
"v [my-project]",
" Fix crash in panel <== selected",
" Fix lint warnings",
@@ -2014,7 +2149,11 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Historical Thread",]
+ vec![
+ //
+ "v [my-project]",
+ " Historical Thread",
+ ]
);
// Switch to workspace 1 so we can verify the confirm switches back.
@@ -2075,7 +2214,12 @@ async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppCo
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Thread A", " Thread B",]
+ vec![
+ //
+ "v [my-project]",
+ " Thread A",
+ " Thread B",
+ ]
);
// Keyboard confirm preserves selection.
@@ -2127,7 +2271,11 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Hello *"]
+ vec![
+ //
+ "v [my-project]",
+ " Hello * (active)",
+ ]
);
// Simulate the agent generating a title. The notification chain is:
@@ -2149,7 +2297,11 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " Friendly Greeting with AI *"]
+ vec![
+ //
+ "v [my-project]",
+ " Friendly Greeting with AI * (active)",
+ ]
);
}
@@ -2202,8 +2354,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
title: "Test".into(),
updated_at: Utc::now(),
created_at: None,
- folder_paths: PathList::default(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::default(),
archived: false,
remote_connection: None,
},
@@ -2259,8 +2410,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
title: "Thread B".into(),
updated_at: Utc::now(),
created_at: None,
- folder_paths: PathList::default(),
- main_worktree_paths: PathList::default(),
+ worktree_paths: ThreadWorktreePaths::default(),
archived: false,
remote_connection: None,
},
@@ -2312,167 +2462,935 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
cx.run_until_parked();
- // Panel B is not the active workspace's panel (workspace A is
- // active), so opening a thread there should not change focused_thread.
- // This prevents running threads in background workspaces from causing
- // the selection highlight to jump around.
- sidebar.read_with(cx, |sidebar, _cx| {
- assert_active_thread(
- sidebar,
- &session_id_a,
- "Opening a thread in a non-active panel should not change focused_thread",
- );
- });
+ // Panel B is not the active workspace's panel (workspace A is
+ // active), so opening a thread there should not change focused_thread.
+ // This prevents running threads in background workspaces from causing
+ // the selection highlight to jump around.
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert_active_thread(
+ sidebar,
+ &session_id_a,
+ "Opening a thread in a non-active panel should not change focused_thread",
+ );
+ });
+
+ workspace_b.update_in(cx, |workspace, window, cx| {
+ workspace.focus_handle(cx).focus(window, cx);
+ });
+ cx.run_until_parked();
+
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert_active_thread(
+ sidebar,
+ &session_id_a,
+ "Defocusing the sidebar should not change focused_thread",
+ );
+ });
+
+ // Switching workspaces via the multi_workspace (simulates clicking
+ // a workspace header) should clear focused_thread.
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
+ if let Some(workspace) = workspace {
+ mw.activate(workspace, window, cx);
+ }
+ });
+ cx.run_until_parked();
+
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert_active_thread(
+ sidebar,
+ &session_id_b2,
+ "Switching workspace should seed focused_thread from the new active panel",
+ );
+ assert!(
+ has_thread_entry(sidebar, &session_id_b2),
+ "The seeded thread should be present in the entries"
+ );
+ });
+
+ // ── 8. Focusing the agent panel thread keeps focused_thread ────
+ // Workspace B still has session_id_b2 loaded in the agent panel.
+ // Clicking into the thread (simulated by focusing its view) should
+ // keep focused_thread since it was already seeded on workspace switch.
+ panel_b.update_in(cx, |panel, window, cx| {
+ if let Some(thread_view) = panel.active_conversation_view() {
+ thread_view.read(cx).focus_handle(cx).focus(window, cx);
+ }
+ });
+ cx.run_until_parked();
+
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert_active_thread(
+ sidebar,
+ &session_id_b2,
+ "Focusing the agent panel thread should set focused_thread",
+ );
+ assert!(
+ has_thread_entry(sidebar, &session_id_b2),
+ "The focused thread should be present in the entries"
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
+ let project = init_test_project_with_agent_panel("/project-a", cx).await;
+ let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+ // Start a thread and send a message so it has history.
+ let connection = StubAgentConnection::new();
+ connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+ acp::ContentChunk::new("Done".into()),
+ )]);
+ open_thread_with_connection(&panel, connection, cx);
+ send_message(&panel, cx);
+ let session_id = active_session_id(&panel, cx);
+ save_test_thread_metadata(&session_id, &project, cx).await;
+ cx.run_until_parked();
+
+ // Verify the thread appears in the sidebar.
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a]",
+ " Hello * (active)",
+ ]
+ );
+
+ // The "New Thread" button should NOT be in "active/draft" state
+ // because the panel has a thread with messages.
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert!(
+ matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
+ "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
+ sidebar.active_entry,
+ );
+ });
+
+ // Now add a second folder to the workspace, changing the path_list.
+ fs.as_fake()
+ .insert_tree("/project-b", serde_json::json!({ "src": {} }))
+ .await;
+ project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree("/project-b", true, cx)
+ })
+ .await
+ .expect("should add worktree");
+ cx.run_until_parked();
+
+ // The workspace path_list is now [project-a, project-b]. The active
+ // thread's metadata was re-saved with the new paths by the agent panel's
+ // project subscription. The old [project-a] key is replaced by the new
+ // key since no other workspace claims it.
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a, project-b]",
+ " Hello * (active)",
+ ]
+ );
+
+ // The "New Thread" button must still be clickable (not stuck in
+ // "active/draft" state). Verify that `active_thread_is_draft` is
+ // false — the panel still has the old thread with messages.
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert!(
+ matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
+ "After adding a folder the panel still has a thread with messages, \
+ so active_entry should be Thread, got {:?}",
+ sidebar.active_entry,
+ );
+ });
+
+ // Actually click "New Thread" by calling create_new_thread and
+ // verify a new draft is created.
+ let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.create_new_thread(&workspace, window, cx);
+ });
+ cx.run_until_parked();
+
+ // After creating a new thread, the panel should now be in draft
+ // state (no messages on the new thread).
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert_active_draft(
+ sidebar,
+ &workspace,
+ "After creating a new thread active_entry should be Draft",
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_worktree_add_and_remove_migrates_threads(cx: &mut TestAppContext) {
+ // When a worktree is added to a project, the project group key changes
+ // and all historical threads should be migrated to the new key. Removing
+ // the worktree should migrate them back.
+ let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+
+ // Save two threads against the initial project group [/project-a].
+ save_n_test_threads(2, &project, cx).await;
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a]",
+ " Thread 2",
+ " Thread 1",
+ ]
+ );
+
+ // Verify the metadata store has threads under the old key.
+ let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ assert_eq!(
+ store.entries_for_main_worktree_path(&old_key_paths).count(),
+ 2,
+ "should have 2 threads under old key before add"
+ );
+ });
+
+ // Add a second worktree to the same project.
+ project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree("/project-b", true, cx)
+ })
+ .await
+ .expect("should add worktree");
+ cx.run_until_parked();
+
+ // The project group key should now be [/project-a, /project-b].
+ let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
+
+ // Verify multi-workspace state: exactly one project group key, the new one.
+ multi_workspace.read_with(cx, |mw, _cx| {
+ let keys: Vec<_> = mw.project_group_keys().cloned().collect();
+ assert_eq!(
+ keys.len(),
+ 1,
+ "should have exactly 1 project group key after add"
+ );
+ assert_eq!(
+ keys[0].path_list(),
+ &new_key_paths,
+ "the key should be the new combined path list"
+ );
+ });
+
+ // Verify threads were migrated to the new key.
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ assert_eq!(
+ store.entries_for_main_worktree_path(&old_key_paths).count(),
+ 0,
+ "should have 0 threads under old key after migration"
+ );
+ assert_eq!(
+ store.entries_for_main_worktree_path(&new_key_paths).count(),
+ 2,
+ "should have 2 threads under new key after migration"
+ );
+ });
+
+ // Sidebar should show threads under the new header.
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a, project-b]",
+ " Thread 2",
+ " Thread 1",
+ ]
+ );
+
+ // Now remove the second worktree.
+ let worktree_id = project.read_with(cx, |project, cx| {
+ project
+ .visible_worktrees(cx)
+ .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
+ .map(|wt| wt.read(cx).id())
+ .expect("should find project-b worktree")
+ });
+ project.update(cx, |project, cx| {
+ project.remove_worktree(worktree_id, cx);
+ });
+ cx.run_until_parked();
+
+ // The key should revert to [/project-a].
+ multi_workspace.read_with(cx, |mw, _cx| {
+ let keys: Vec<_> = mw.project_group_keys().cloned().collect();
+ assert_eq!(
+ keys.len(),
+ 1,
+ "should have exactly 1 project group key after remove"
+ );
+ assert_eq!(
+ keys[0].path_list(),
+ &old_key_paths,
+ "the key should revert to the original path list"
+ );
+ });
+
+ // Threads should be migrated back to the old key.
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ assert_eq!(
+ store.entries_for_main_worktree_path(&new_key_paths).count(),
+ 0,
+ "should have 0 threads under new key after revert"
+ );
+ assert_eq!(
+ store.entries_for_main_worktree_path(&old_key_paths).count(),
+ 2,
+ "should have 2 threads under old key after revert"
+ );
+ });
+
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a]",
+ " Thread 2",
+ " Thread 1",
+ ]
+ );
+}
+
+#[gpui::test]
+async fn test_worktree_add_and_remove_preserves_thread_path_associations(cx: &mut TestAppContext) {
+ // Verifies that adding/removing folders to a project correctly updates
+ // each thread's worktree_paths (both folder_paths and main_worktree_paths)
+ // while preserving per-path associations for linked worktrees.
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ serde_json::json!({
+ ".git": {},
+ "src": {},
+ }),
+ )
+ .await;
+ fs.add_linked_worktree_for_repo(
+ Path::new("/project/.git"),
+ false,
+ git::repository::Worktree {
+ path: PathBuf::from("/wt-feature"),
+ ref_name: Some("refs/heads/feature".into()),
+ sha: "aaa".into(),
+ is_main: false,
+ },
+ )
+ .await;
+ fs.insert_tree("/other-project", serde_json::json!({ ".git": {} }))
+ .await;
+ cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+
+ // Start with a linked worktree workspace: visible root is /wt-feature,
+ // main repo is /project.
+ let project =
+ project::Project::test(fs.clone() as Arc<dyn Fs>, ["/wt-feature".as_ref()], 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. It should have folder_paths=[/wt-feature], main=[/project].
+ save_named_thread_metadata("thread-1", "Thread 1", &project, cx).await;
+
+ let session_id = acp::SessionId::new(Arc::from("thread-1"));
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ let thread = store.entry(&session_id).expect("thread should exist");
+ assert_eq!(
+ thread.folder_paths().paths(),
+ &[PathBuf::from("/wt-feature")],
+ "initial folder_paths should be the linked worktree"
+ );
+ assert_eq!(
+ thread.main_worktree_paths().paths(),
+ &[PathBuf::from("/project")],
+ "initial main_worktree_paths should be the main repo"
+ );
+ });
+
+ // Add /other-project to the workspace.
+ project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree("/other-project", true, cx)
+ })
+ .await
+ .expect("should add worktree");
+ cx.run_until_parked();
+
+ // Thread should now have both paths, with correct associations.
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ let thread = store.entry(&session_id).expect("thread should exist");
+ let pairs: Vec<_> = thread
+ .worktree_paths
+ .ordered_pairs()
+ .map(|(m, f)| (m.clone(), f.clone()))
+ .collect();
+ assert!(
+ pairs.contains(&(PathBuf::from("/project"), PathBuf::from("/wt-feature"))),
+ "linked worktree association should be preserved, got: {:?}",
+ pairs
+ );
+ assert!(
+ pairs.contains(&(
+ PathBuf::from("/other-project"),
+ PathBuf::from("/other-project")
+ )),
+ "new folder should have main == folder, got: {:?}",
+ pairs
+ );
+ });
+
+ // Remove /other-project.
+ let worktree_id = project.read_with(cx, |project, cx| {
+ project
+ .visible_worktrees(cx)
+ .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/other-project"))
+ .map(|wt| wt.read(cx).id())
+ .expect("should find other-project worktree")
+ });
+ project.update(cx, |project, cx| {
+ project.remove_worktree(worktree_id, cx);
+ });
+ cx.run_until_parked();
+
+ // Thread should be back to original state.
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ let thread = store.entry(&session_id).expect("thread should exist");
+ assert_eq!(
+ thread.folder_paths().paths(),
+ &[PathBuf::from("/wt-feature")],
+ "folder_paths should revert to just the linked worktree"
+ );
+ assert_eq!(
+ thread.main_worktree_paths().paths(),
+ &[PathBuf::from("/project")],
+ "main_worktree_paths should revert to just the main repo"
+ );
+ let pairs: Vec<_> = thread
+ .worktree_paths
+ .ordered_pairs()
+ .map(|(m, f)| (m.clone(), f.clone()))
+ .collect();
+ assert_eq!(
+ pairs,
+ vec![(PathBuf::from("/project"), PathBuf::from("/wt-feature"))],
+ "linked worktree association should be preserved through add+remove cycle"
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_worktree_add_key_collision_removes_duplicate_workspace(cx: &mut TestAppContext) {
+ // When a worktree is added to workspace A and the resulting key matches
+ // an existing workspace B's key (and B has the same root paths), B
+ // should be removed as a true duplicate.
+ let (fs, project_a) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+
+ // Save a thread against workspace A [/project-a].
+ save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
+
+ // Create workspace B with both worktrees [/project-a, /project-b].
+ let project_b = project::Project::test(
+ fs.clone() as Arc<dyn Fs>,
+ ["/project-a".as_ref(), "/project-b".as_ref()],
+ cx,
+ )
+ .await;
+ let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_b.clone(), window, cx)
+ });
+ cx.run_until_parked();
+
+ // Switch back to workspace A so it's the active workspace when the collision happens.
+ let workspace_a =
+ multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.activate(workspace_a, window, cx);
+ });
+ cx.run_until_parked();
+
+ // Save a thread against workspace B [/project-a, /project-b].
+ save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
+
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+
+ // Both project groups should be visible.
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a, project-b]",
+ " Thread B",
+ "v [project-a]",
+ " Thread A",
+ ]
+ );
+
+ let workspace_b_id = workspace_b.entity_id();
+
+ // Now add /project-b to workspace A's project, causing a key collision.
+ project_a
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree("/project-b", true, cx)
+ })
+ .await
+ .expect("should add worktree");
+ cx.run_until_parked();
+
+ // Workspace B should have been removed (true duplicate — same root paths).
+ multi_workspace.read_with(cx, |mw, _cx| {
+ let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
+ assert!(
+ !workspace_ids.contains(&workspace_b_id),
+ "workspace B should have been removed after key collision"
+ );
+ });
+
+ // There should be exactly one project group key now.
+ let combined_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
+ multi_workspace.read_with(cx, |mw, _cx| {
+ let keys: Vec<_> = mw.project_group_keys().cloned().collect();
+ assert_eq!(
+ keys.len(),
+ 1,
+ "should have exactly 1 project group key after collision"
+ );
+ assert_eq!(
+ keys[0].path_list(),
+ &combined_paths,
+ "the remaining key should be the combined paths"
+ );
+ });
+
+ // Both threads should be visible under the merged group.
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a, project-b]",
+ " Thread A",
+ " Thread B",
+ ]
+ );
+}
+
+#[gpui::test]
+async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext) {
+ // When workspace A adds a folder that makes it collide with workspace B,
+ // and B is the *active* workspace, A (the incoming one) should be
+ // dropped so the user stays on B. A linked worktree sibling of A
+ // should migrate into B's group.
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+
+ // Set up /project-a with a linked worktree.
+ fs.insert_tree(
+ "/project-a",
+ serde_json::json!({
+ ".git": {
+ "worktrees": {
+ "feature": {
+ "commondir": "../../",
+ "HEAD": "ref: refs/heads/feature",
+ },
+ },
+ },
+ "src": {},
+ }),
+ )
+ .await;
+ fs.insert_tree(
+ "/wt-feature",
+ serde_json::json!({
+ ".git": "gitdir: /project-a/.git/worktrees/feature",
+ "src": {},
+ }),
+ )
+ .await;
+ fs.add_linked_worktree_for_repo(
+ Path::new("/project-a/.git"),
+ false,
+ git::repository::Worktree {
+ path: PathBuf::from("/wt-feature"),
+ ref_name: Some("refs/heads/feature".into()),
+ sha: "aaa".into(),
+ is_main: false,
+ },
+ )
+ .await;
+ fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
+ .await;
+ cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+ let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+ project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
+
+ // Linked worktree sibling of A.
+ let project_wt = project::Project::test(fs.clone(), ["/wt-feature".as_ref()], cx).await;
+ project_wt
+ .update(cx, |p, cx| p.git_scans_complete(cx))
+ .await;
+
+ // Workspace B has both folders already.
+ let project_b = project::Project::test(
+ fs.clone() as Arc<dyn Fs>,
+ ["/project-a".as_ref(), "/project-b".as_ref()],
+ cx,
+ )
+ .await;
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+
+ // Add agent panels to all workspaces.
+ let workspace_a_entity = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+ add_agent_panel(&workspace_a_entity, cx);
+
+ // Add the linked worktree workspace (sibling of A).
+ let workspace_wt = multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_wt.clone(), window, cx)
+ });
+ add_agent_panel(&workspace_wt, cx);
+ cx.run_until_parked();
+
+ // Add workspace B (will become active).
+ let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_b.clone(), window, cx)
+ });
+ add_agent_panel(&workspace_b, cx);
+ cx.run_until_parked();
+
+ // Save threads in each group.
+ save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
+ save_thread_metadata_with_main_paths(
+ "thread-wt",
+ "Worktree Thread",
+ PathList::new(&[PathBuf::from("/wt-feature")]),
+ PathList::new(&[PathBuf::from("/project-a")]),
+ cx,
+ );
+ save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
+
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+
+ // B is active, A and wt-feature are in one group, B in another.
+ assert_eq!(
+ multi_workspace.read_with(cx, |mw, _| mw.workspace().entity_id()),
+ workspace_b.entity_id(),
+ "workspace B should be active"
+ );
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(mw.project_group_keys().count(), 2, "should have 2 groups");
+ assert_eq!(mw.workspaces().count(), 3, "should have 3 workspaces");
+ });
+
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a, project-b]",
+ " [~ Draft] (active)",
+ " Thread B",
+ "v [project-a]",
+ " Thread A",
+ " Worktree Thread {wt-feature}",
+ ]
+ );
+
+ let workspace_a = multi_workspace.read_with(cx, |mw, _| {
+ mw.workspaces()
+ .find(|ws| {
+ ws.entity_id() != workspace_b.entity_id()
+ && ws.entity_id() != workspace_wt.entity_id()
+ })
+ .unwrap()
+ .clone()
+ });
+
+ // Add /project-b to workspace A's project, causing a collision with B.
+ project_a
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree("/project-b", true, cx)
+ })
+ .await
+ .expect("should add worktree");
+ cx.run_until_parked();
+
+ // Workspace A (the incoming duplicate) should have been dropped.
+ multi_workspace.read_with(cx, |mw, _cx| {
+ let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
+ assert!(
+ !workspace_ids.contains(&workspace_a.entity_id()),
+ "workspace A should have been dropped"
+ );
+ });
+
+ // The active workspace should still be B.
+ assert_eq!(
+ multi_workspace.read_with(cx, |mw, _| mw.workspace().entity_id()),
+ workspace_b.entity_id(),
+ "workspace B should still be active"
+ );
+
+ // The linked worktree sibling should have migrated into B's group
+ // (it got the folder add and now shares the same key).
+ multi_workspace.read_with(cx, |mw, _cx| {
+ let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
+ assert!(
+ workspace_ids.contains(&workspace_wt.entity_id()),
+ "linked worktree workspace should still exist"
+ );
+ assert_eq!(
+ mw.project_group_keys().count(),
+ 1,
+ "should have 1 group after merge"
+ );
+ assert_eq!(
+ mw.workspaces().count(),
+ 2,
+ "should have 2 workspaces (B + linked worktree)"
+ );
+ });
+
+ // The linked worktree workspace should have gotten the new folder.
+ let wt_worktree_count =
+ project_wt.read_with(cx, |project, cx| project.visible_worktrees(cx).count());
+ assert_eq!(
+ wt_worktree_count, 2,
+ "linked worktree project should have gotten /project-b"
+ );
+
+ // After: everything merged under one group. Thread A migrated,
+ // worktree thread shows its chip, B's thread and draft remain.
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [project-a, project-b]",
+ " [~ Draft] (active)",
+ " Thread A",
+ " Worktree Thread {project-a:wt-feature}",
+ " Thread B",
+ ]
+ );
+}
+
+#[gpui::test]
+async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext) {
+ // When a worktree is added to the main workspace, a linked worktree
+ // sibling (different root paths, same project group key) should also
+ // get the new folder added to its project.
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+
+ fs.insert_tree(
+ "/project",
+ serde_json::json!({
+ ".git": {
+ "worktrees": {
+ "feature": {
+ "commondir": "../../",
+ "HEAD": "ref: refs/heads/feature",
+ },
+ },
+ },
+ "src": {},
+ }),
+ )
+ .await;
+
+ fs.insert_tree(
+ "/wt-feature",
+ serde_json::json!({
+ ".git": "gitdir: /project/.git/worktrees/feature",
+ "src": {},
+ }),
+ )
+ .await;
+
+ fs.add_linked_worktree_for_repo(
+ Path::new("/project/.git"),
+ false,
+ git::repository::Worktree {
+ path: PathBuf::from("/wt-feature"),
+ ref_name: Some("refs/heads/feature".into()),
+ sha: "aaa".into(),
+ is_main: false,
+ },
+ )
+ .await;
+
+ // Create a second independent project to add as a folder later.
+ fs.insert_tree(
+ "/other-project",
+ serde_json::json!({ ".git": {}, "src": {} }),
+ )
+ .await;
+
+ cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
- workspace_b.update_in(cx, |workspace, window, cx| {
- workspace.focus_handle(cx).focus(window, cx);
- });
- cx.run_until_parked();
+ let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+ let worktree_project = project::Project::test(fs.clone(), ["/wt-feature".as_ref()], cx).await;
- sidebar.read_with(cx, |sidebar, _cx| {
- assert_active_thread(
- sidebar,
- &session_id_a,
- "Defocusing the sidebar should not change focused_thread",
- );
- });
+ main_project
+ .update(cx, |p, cx| p.git_scans_complete(cx))
+ .await;
+ worktree_project
+ .update(cx, |p, cx| p.git_scans_complete(cx))
+ .await;
- // Switching workspaces via the multi_workspace (simulates clicking
- // a workspace header) should clear focused_thread.
- multi_workspace.update_in(cx, |mw, window, cx| {
- let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
- if let Some(workspace) = workspace {
- mw.activate(workspace, window, cx);
- }
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+
+ // Add agent panel to the main workspace.
+ let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+ add_agent_panel(&main_workspace, cx);
+
+ // Open the linked worktree as a separate workspace.
+ let wt_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(worktree_project.clone(), window, cx)
});
+ add_agent_panel(&wt_workspace, cx);
cx.run_until_parked();
- sidebar.read_with(cx, |sidebar, _cx| {
- assert_active_thread(
- sidebar,
- &session_id_b2,
- "Switching workspace should seed focused_thread from the new active panel",
- );
- assert!(
- has_thread_entry(sidebar, &session_id_b2),
- "The seeded thread should be present in the entries"
+ // Both workspaces should share the same project group key [/project].
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(
+ mw.project_group_keys().count(),
+ 1,
+ "should have 1 project group key before add"
);
+ assert_eq!(mw.workspaces().count(), 2, "should have 2 workspaces");
});
- // ── 8. Focusing the agent panel thread keeps focused_thread ────
- // Workspace B still has session_id_b2 loaded in the agent panel.
- // Clicking into the thread (simulated by focusing its view) should
- // keep focused_thread since it was already seeded on workspace switch.
- panel_b.update_in(cx, |panel, window, cx| {
- if let Some(thread_view) = panel.active_conversation_view() {
- thread_view.read(cx).focus_handle(cx).focus(window, cx);
- }
- });
- cx.run_until_parked();
+ // Save threads against each workspace.
+ save_named_thread_metadata("main-thread", "Main Thread", &main_project, cx).await;
+ save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
- sidebar.read_with(cx, |sidebar, _cx| {
- assert_active_thread(
- sidebar,
- &session_id_b2,
- "Focusing the agent panel thread should set focused_thread",
- );
- assert!(
- has_thread_entry(sidebar, &session_id_b2),
- "The focused thread should be present in the entries"
+ // Verify both threads are under the old key [/project].
+ let old_key_paths = PathList::new(&[PathBuf::from("/project")]);
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ assert_eq!(
+ store.entries_for_main_worktree_path(&old_key_paths).count(),
+ 2,
+ "should have 2 threads under old key before add"
);
});
-}
-
-#[gpui::test]
-async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
- let project = init_test_project_with_agent_panel("/project-a", cx).await;
- let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
- let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- // Start a thread and send a message so it has history.
- let connection = StubAgentConnection::new();
- connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
- acp::ContentChunk::new("Done".into()),
- )]);
- open_thread_with_connection(&panel, connection, cx);
- send_message(&panel, cx);
- let session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id, &project, cx).await;
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
cx.run_until_parked();
- // Verify the thread appears in the sidebar.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " Hello *",]
+ vec![
+ //
+ "v [project]",
+ " [~ Draft {wt-feature}] (active)",
+ " Worktree Thread {wt-feature}",
+ " Main Thread",
+ ]
);
- // The "New Thread" button should NOT be in "active/draft" state
- // because the panel has a thread with messages.
- sidebar.read_with(cx, |sidebar, _cx| {
- assert!(
- matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
- "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
- sidebar.active_entry,
- );
- });
-
- // Now add a second folder to the workspace, changing the path_list.
- fs.as_fake()
- .insert_tree("/project-b", serde_json::json!({ "src": {} }))
- .await;
- project
+ // Add /other-project as a folder to the main workspace.
+ main_project
.update(cx, |project, cx| {
- project.find_or_create_worktree("/project-b", true, cx)
+ project.find_or_create_worktree("/other-project", true, cx)
})
.await
.expect("should add worktree");
cx.run_until_parked();
- // The workspace path_list is now [project-a, project-b]. The active
- // thread's metadata was re-saved with the new paths by the agent panel's
- // project subscription, so it stays visible under the updated group.
- // The old [project-a] group persists in the sidebar (empty) because
- // project_group_keys is append-only.
+ // The linked worktree workspace should have gotten the new folder too.
+ let wt_worktree_count =
+ worktree_project.read_with(cx, |project, cx| project.visible_worktrees(cx).count());
assert_eq!(
- visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project-a, project-b]", //
- " Hello *",
- "v [project-a]",
- ]
+ wt_worktree_count, 2,
+ "linked worktree project should have gotten the new folder"
);
- // The "New Thread" button must still be clickable (not stuck in
- // "active/draft" state). Verify that `active_thread_is_draft` is
- // false — the panel still has the old thread with messages.
- sidebar.read_with(cx, |sidebar, _cx| {
- assert!(
- matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
- "After adding a folder the panel still has a thread with messages, \
- so active_entry should be Thread, got {:?}",
- sidebar.active_entry,
+ // Both workspaces should still exist under one key.
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(mw.workspaces().count(), 2, "both workspaces should survive");
+ assert_eq!(
+ mw.project_group_keys().count(),
+ 1,
+ "should still have 1 project group key"
);
});
- // Actually click "New Thread" by calling create_new_thread and
- // verify a new draft is created.
- let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
- sidebar.update_in(cx, |sidebar, window, cx| {
- sidebar.create_new_thread(&workspace, window, cx);
+ // Threads should have been migrated to the new key.
+ let new_key_paths =
+ PathList::new(&[PathBuf::from("/other-project"), PathBuf::from("/project")]);
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ assert_eq!(
+ store.entries_for_main_worktree_path(&old_key_paths).count(),
+ 0,
+ "should have 0 threads under old key after migration"
+ );
+ assert_eq!(
+ store.entries_for_main_worktree_path(&new_key_paths).count(),
+ 2,
+ "should have 2 threads under new key after migration"
+ );
});
+
+ // Both threads should still be visible in the sidebar.
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
cx.run_until_parked();
- // After creating a new thread, the panel should now be in draft
- // state (no messages on the new thread).
- sidebar.read_with(cx, |sidebar, _cx| {
- assert_active_draft(
- sidebar,
- &workspace,
- "After creating a new thread active_entry should be Draft",
- );
- });
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ //
+ "v [other-project, project]",
+ " [~ Draft {project:wt-feature}] (active)",
+ " Worktree Thread {project:wt-feature}",
+ " Main Thread",
+ ]
+ );
}
#[gpui::test]