Detailed changes
@@ -17,7 +17,7 @@ use ui::{
prelude::*,
};
use util::ResultExt;
-use workspace::{ModalView, MultiWorkspace, Workspace};
+use workspace::{ModalView, MultiWorkspace, PathList, Workspace};
use crate::{
Agent, AgentPanel,
@@ -500,6 +500,7 @@ fn collect_importable_threads(
updated_at: session.updated_at.unwrap_or_else(|| Utc::now()),
created_at: session.created_at,
folder_paths,
+ main_worktree_paths: PathList::default(),
archived: true,
});
}
@@ -66,6 +66,7 @@ fn migrate_thread_metadata(cx: &mut App) {
updated_at: entry.updated_at,
created_at: entry.created_at,
folder_paths: entry.folder_paths,
+ main_worktree_paths: PathList::default(),
archived: true,
})
})
@@ -126,6 +127,7 @@ pub struct ThreadMetadata {
pub updated_at: DateTime<Utc>,
pub created_at: Option<DateTime<Utc>>,
pub folder_paths: PathList,
+ pub main_worktree_paths: PathList,
pub archived: bool,
}
@@ -149,6 +151,7 @@ pub struct ThreadMetadataStore {
db: ThreadMetadataDb,
threads: HashMap<acp::SessionId, ThreadMetadata>,
threads_by_paths: HashMap<PathList, HashSet<acp::SessionId>>,
+ threads_by_main_paths: HashMap<PathList, HashSet<acp::SessionId>>,
reload_task: Option<Shared<Task<()>>>,
session_subscriptions: HashMap<acp::SessionId, Subscription>,
pending_thread_ops_tx: smol::channel::Sender<DbOperation>,
@@ -238,6 +241,21 @@ impl ThreadMetadataStore {
.filter(|s| !s.archived)
}
+ /// Returns threads whose `main_worktree_paths` matches the given path list,
+ /// excluding archived threads. This finds threads that were opened in a
+ /// linked worktree but are associated with the given main worktree.
+ pub fn entries_for_main_worktree_path(
+ &self,
+ path_list: &PathList,
+ ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
+ self.threads_by_main_paths
+ .get(path_list)
+ .into_iter()
+ .flatten()
+ .filter_map(|s| self.threads.get(s))
+ .filter(|s| !s.archived)
+ }
+
fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
let db = self.db.clone();
self.reload_task.take();
@@ -254,12 +272,19 @@ impl ThreadMetadataStore {
this.update(cx, |this, cx| {
this.threads.clear();
this.threads_by_paths.clear();
+ this.threads_by_main_paths.clear();
for row in rows {
this.threads_by_paths
.entry(row.folder_paths.clone())
.or_default()
.insert(row.session_id.clone());
+ if !row.main_worktree_paths.is_empty() {
+ this.threads_by_main_paths
+ .entry(row.main_worktree_paths.clone())
+ .or_default()
+ .insert(row.session_id.clone());
+ }
this.threads.insert(row.session_id.clone(), row);
}
@@ -298,12 +323,22 @@ impl ThreadMetadataStore {
}
fn save_internal(&mut self, metadata: ThreadMetadata) {
- // If the folder paths have changed, we need to clear the old entry
- if let Some(thread) = self.threads.get(&metadata.session_id)
- && thread.folder_paths != metadata.folder_paths
- && let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths)
- {
- session_ids.remove(&metadata.session_id);
+ 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) {
+ session_ids.remove(&metadata.session_id);
+ }
+ }
+ 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)
+ {
+ session_ids.remove(&metadata.session_id);
+ }
+ }
}
self.threads
@@ -314,6 +349,13 @@ impl ThreadMetadataStore {
.or_default()
.insert(metadata.session_id.clone());
+ if !metadata.main_worktree_paths.is_empty() {
+ self.threads_by_main_paths
+ .entry(metadata.main_worktree_paths.clone())
+ .or_default()
+ .insert(metadata.session_id.clone());
+ }
+
self.pending_thread_ops_tx
.try_send(DbOperation::Upsert(metadata))
.log_err();
@@ -370,10 +412,18 @@ impl ThreadMetadataStore {
return;
}
- if let Some(thread) = self.threads.get(&session_id)
- && let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths)
- {
- session_ids.remove(&session_id);
+ if let Some(thread) = self.threads.get(&session_id) {
+ 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 let Some(session_ids) = self
+ .threads_by_main_paths
+ .get_mut(&thread.main_worktree_paths)
+ {
+ session_ids.remove(&session_id);
+ }
+ }
}
self.threads.remove(&session_id);
self.pending_thread_ops_tx
@@ -449,6 +499,7 @@ impl ThreadMetadataStore {
db,
threads: HashMap::default(),
threads_by_paths: HashMap::default(),
+ threads_by_main_paths: HashMap::default(),
reload_task: None,
session_subscriptions: HashMap::default(),
pending_thread_ops_tx: tx,
@@ -517,6 +568,20 @@ impl ThreadMetadataStore {
PathList::new(&paths)
};
+ let main_worktree_paths = {
+ let project = thread_ref.project().read(cx);
+ let mut main_paths: Vec<Arc<Path>> = Vec::new();
+ for repo in project.repositories(cx).values() {
+ let snapshot = repo.read(cx).snapshot();
+ if snapshot.is_linked_worktree() {
+ main_paths.push(snapshot.original_repo_abs_path.clone());
+ }
+ }
+ main_paths.sort();
+ main_paths.dedup();
+ PathList::new(&main_paths)
+ };
+
// Threads without a folder path (e.g. started in an empty
// window) are archived by default so they don't get lost,
// because they won't show up in the sidebar. Users can reload
@@ -532,6 +597,7 @@ impl ThreadMetadataStore {
created_at: Some(created_at),
updated_at,
folder_paths,
+ main_worktree_paths,
archived,
};
@@ -567,6 +633,8 @@ impl Domain for ThreadMetadataDb {
) STRICT;
),
sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
+ sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT),
+ sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT),
];
}
@@ -583,7 +651,7 @@ impl ThreadMetadataDb {
/// List all sidebar thread metadata, ordered by updated_at descending.
pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
self.select::<ThreadMetadata>(
- "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived \
+ "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order \
FROM sidebar_threads \
ORDER BY updated_at DESC"
)?()
@@ -606,11 +674,18 @@ impl ThreadMetadataDb {
} 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 archived = row.archived;
self.write(move |conn| {
- let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived) \
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
+ let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order) \
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
ON CONFLICT(session_id) DO UPDATE SET \
agent_id = excluded.agent_id, \
title = excluded.title, \
@@ -618,7 +693,9 @@ impl ThreadMetadataDb {
created_at = excluded.created_at, \
folder_paths = excluded.folder_paths, \
folder_paths_order = excluded.folder_paths_order, \
- archived = excluded.archived";
+ archived = excluded.archived, \
+ main_worktree_paths = excluded.main_worktree_paths, \
+ main_worktree_paths_order = excluded.main_worktree_paths_order";
let mut stmt = Statement::prepare(conn, sql)?;
let mut i = stmt.bind(&id, 1)?;
i = stmt.bind(&agent_id, i)?;
@@ -627,7 +704,9 @@ impl ThreadMetadataDb {
i = stmt.bind(&created_at, i)?;
i = stmt.bind(&folder_paths, i)?;
i = stmt.bind(&folder_paths_order, i)?;
- stmt.bind(&archived, i)?;
+ i = stmt.bind(&archived, i)?;
+ i = stmt.bind(&main_worktree_paths, i)?;
+ stmt.bind(&main_worktree_paths_order, i)?;
stmt.exec()
})
.await
@@ -657,6 +736,10 @@ impl Column for ThreadMetadata {
let (folder_paths_order_str, next): (Option<String>, i32) =
Column::column(statement, next)?;
let (archived, next): (bool, i32) = Column::column(statement, next)?;
+ let (main_worktree_paths_str, next): (Option<String>, i32) =
+ Column::column(statement, next)?;
+ let (main_worktree_paths_order_str, next): (Option<String>, i32) =
+ Column::column(statement, next)?;
let agent_id = agent_id
.map(|id| AgentId::new(id))
@@ -678,6 +761,15 @@ impl Column for ThreadMetadata {
})
.unwrap_or_default();
+ let main_worktree_paths = main_worktree_paths_str
+ .map(|paths| {
+ PathList::deserialize(&util::path_list::SerializedPathList {
+ paths,
+ order: main_worktree_paths_order_str.unwrap_or_default(),
+ })
+ })
+ .unwrap_or_default();
+
Ok((
ThreadMetadata {
session_id: acp::SessionId::new(id),
@@ -686,6 +778,7 @@ impl Column for ThreadMetadata {
updated_at,
created_at,
folder_paths,
+ main_worktree_paths,
archived,
},
next,
@@ -742,6 +835,7 @@ mod tests {
updated_at,
created_at: Some(updated_at),
folder_paths,
+ main_worktree_paths: PathList::default(),
}
}
@@ -957,6 +1051,7 @@ mod tests {
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(),
archived: false,
};
@@ -1066,6 +1161,7 @@ mod tests {
updated_at: existing_updated_at,
created_at: Some(existing_updated_at),
folder_paths: project_paths.clone(),
+ main_worktree_paths: PathList::default(),
archived: false,
};
@@ -906,6 +906,51 @@ impl Sidebar {
}
}
+ // 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(),
+ });
+ }
+ }
+
// Build a lookup from live_infos and compute running/waiting
// counts in a single pass.
let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
@@ -149,6 +149,7 @@ fn save_thread_metadata(
updated_at,
created_at,
folder_paths: path_list,
+ main_worktree_paths: PathList::default(),
archived: false,
};
cx.update(|cx| {
@@ -697,6 +698,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
session_id: acp::SessionId::new(Arc::from("t-1")),
agent_id: AgentId::new("zed-agent"),
folder_paths: PathList::default(),
+ main_worktree_paths: PathList::default(),
title: "Completed thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
@@ -719,6 +721,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
session_id: acp::SessionId::new(Arc::from("t-2")),
agent_id: AgentId::new("zed-agent"),
folder_paths: PathList::default(),
+ main_worktree_paths: PathList::default(),
title: "Running thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
@@ -741,6 +744,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
session_id: acp::SessionId::new(Arc::from("t-3")),
agent_id: AgentId::new("zed-agent"),
folder_paths: PathList::default(),
+ main_worktree_paths: PathList::default(),
title: "Error thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
@@ -763,6 +767,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
session_id: acp::SessionId::new(Arc::from("t-4")),
agent_id: AgentId::new("zed-agent"),
folder_paths: PathList::default(),
+ main_worktree_paths: PathList::default(),
title: "Waiting thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
@@ -785,6 +790,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
session_id: acp::SessionId::new(Arc::from("t-5")),
agent_id: AgentId::new("zed-agent"),
folder_paths: PathList::default(),
+ main_worktree_paths: PathList::default(),
title: "Notified thread".into(),
updated_at: Utc::now(),
created_at: Some(Utc::now()),
@@ -2052,6 +2058,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
updated_at: Utc::now(),
created_at: None,
folder_paths: PathList::default(),
+ main_worktree_paths: PathList::default(),
archived: false,
},
&workspace_a,
@@ -2107,6 +2114,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
updated_at: Utc::now(),
created_at: None,
folder_paths: PathList::default(),
+ main_worktree_paths: PathList::default(),
archived: false,
},
&workspace_b,
@@ -3571,6 +3579,7 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works
updated_at: Utc::now(),
created_at: None,
folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+ main_worktree_paths: PathList::default(),
archived: false,
},
window,
@@ -3633,6 +3642,7 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
updated_at: Utc::now(),
created_at: None,
folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]),
+ main_worktree_paths: PathList::default(),
archived: false,
},
window,
@@ -3695,6 +3705,7 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
updated_at: Utc::now(),
created_at: None,
folder_paths: PathList::default(),
+ main_worktree_paths: PathList::default(),
archived: false,
},
window,
@@ -3749,6 +3760,7 @@ async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut
updated_at: Utc::now(),
created_at: None,
folder_paths: path_list_b,
+ main_worktree_paths: PathList::default(),
archived: false,
},
window,
@@ -3798,6 +3810,7 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &m
updated_at: Utc::now(),
created_at: None,
folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+ main_worktree_paths: PathList::default(),
archived: false,
},
window,
@@ -3874,6 +3887,7 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_t
updated_at: Utc::now(),
created_at: None,
folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+ main_worktree_paths: PathList::default(),
archived: false,
},
window,
@@ -3949,6 +3963,7 @@ async fn test_activate_archived_thread_prefers_current_window_for_matching_paths
updated_at: Utc::now(),
created_at: None,
folder_paths: PathList::new(&[PathBuf::from("/project-a")]),
+ main_worktree_paths: PathList::default(),
archived: false,
},
window,
@@ -4688,12 +4703,96 @@ async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppCon
});
}
+#[gpui::test]
+async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
+ // When only a linked worktree workspace is open (not the main repo),
+ // threads saved against the main repo should still appear in the sidebar.
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+
+ // Create the main repo with a linked worktree.
+ 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;
+
+ cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+ // Only open the linked worktree as a workspace — NOT the main repo.
+ let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".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(worktree_project.clone(), window, cx)
+ });
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+
+ // Save a thread against the MAIN repo path.
+ let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
+ save_named_thread_metadata("main-thread", "Main Repo Thread", &main_paths, cx).await;
+
+ // Save a thread against the linked worktree path.
+ let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
+ save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
+
+ multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+ cx.run_until_parked();
+
+ // Both threads should be visible: the worktree thread by direct lookup,
+ // and the main repo thread because the workspace is a linked worktree
+ // and we also query the main repo path.
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ assert!(
+ entries.iter().any(|e| e.contains("Main Repo Thread")),
+ "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
+ );
+ assert!(
+ entries.iter().any(|e| e.contains("Worktree Thread")),
+ "expected worktree thread to be visible, got: {entries:?}"
+ );
+}
+
mod property_test {
use super::*;
use gpui::EntityId;
struct UnopenedWorktree {
path: String,
+ main_workspace_path: String,
}
struct TestState {
@@ -4834,6 +4933,34 @@ mod property_test {
save_thread_metadata(session_id, title, updated_at, None, path_list, cx);
}
+ fn save_thread_to_path_with_main(
+ state: &mut TestState,
+ path_list: PathList,
+ main_worktree_paths: PathList,
+ cx: &mut gpui::VisualTestContext,
+ ) {
+ let session_id = state.next_thread_id();
+ let title: SharedString = format!("Thread {}", session_id).into();
+ let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
+ .unwrap()
+ + chrono::Duration::seconds(state.thread_counter as i64);
+ let metadata = ThreadMetadata {
+ session_id,
+ agent_id: agent::ZED_AGENT_ID.clone(),
+ title,
+ updated_at,
+ created_at: None,
+ folder_paths: path_list,
+ main_worktree_paths,
+ archived: false,
+ };
+ cx.update(|_, cx| {
+ ThreadMetadataStore::global(cx)
+ .update(cx, |store, cx| store.save_manually(metadata, cx))
+ });
+ cx.run_until_parked();
+ }
+
async fn perform_operation(
operation: Operation,
state: &mut TestState,
@@ -4852,7 +4979,9 @@ mod property_test {
Operation::SaveWorktreeThread { worktree_index } => {
let worktree = &state.unopened_worktrees[worktree_index];
let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
- save_thread_to_path(state, path_list, cx);
+ let main_worktree_paths =
+ PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
+ save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
}
Operation::DeleteThread { index } => {
let session_id = state.remove_thread(index);
@@ -5004,6 +5133,7 @@ mod property_test {
state.unopened_worktrees.push(UnopenedWorktree {
path: worktree_path,
+ main_workspace_path: main_path.clone(),
});
}
}
@@ -5108,6 +5238,19 @@ mod property_test {
metadata_thread_ids.insert(metadata.session_id.clone());
}
}
+ if snapshot.is_linked_worktree() {
+ let main_path_list =
+ PathList::new(std::slice::from_ref(&snapshot.original_repo_abs_path));
+ for metadata in thread_store.read(cx).entries_for_path(&main_path_list) {
+ metadata_thread_ids.insert(metadata.session_id.clone());
+ }
+ for metadata in thread_store
+ .read(cx)
+ .entries_for_main_worktree_path(&main_path_list)
+ {
+ metadata_thread_ids.insert(metadata.session_id.clone());
+ }
+ }
}
}