diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index f5fc89d3df4991ff5186e2af6d73ad6a840c09a1..5402b1c74353b73a522a068aa32dfd0a9dc85c60 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -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, }); } diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 4c66d57bcfafe98432319a173e7736a581f1d986..410403ba8d8ce618583d81c72205ee268d7b62f6 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -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, pub created_at: Option>, pub folder_paths: PathList, + pub main_worktree_paths: PathList, pub archived: bool, } @@ -149,6 +151,7 @@ pub struct ThreadMetadataStore { db: ThreadMetadataDb, threads: HashMap, threads_by_paths: HashMap>, + threads_by_main_paths: HashMap>, reload_task: Option>>, session_subscriptions: HashMap, pending_thread_ops_tx: smol::channel::Sender, @@ -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 + '_ { + 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) -> Shared> { 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> = 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> { self.select::( - "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, i32) = Column::column(statement, next)?; let (archived, next): (bool, i32) = Column::column(statement, next)?; + let (main_worktree_paths_str, next): (Option, i32) = + Column::column(statement, next)?; + let (main_worktree_paths_order_str, next): (Option, 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, }; diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index cd9bfb4d7ab5e66dafd088999e484513c5074411..a9664a048123253d617a08507cfe4288914d0e9e 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -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 = 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> = diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 72f0bbdc18aaeead26de62164fa64ebf3bb64e8d..1499fc48a9fd094b07d181701866ab941c5968f3 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -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| ::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()); + } + } } }