@@ -99,13 +99,19 @@ impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
}
}
+#[derive(Clone)]
+enum ThreadEntryWorkspace {
+ Open(Entity<Workspace>),
+ Closed(PathList),
+}
+
#[derive(Clone)]
struct ThreadEntry {
session_info: acp_thread::AgentSessionInfo,
icon: IconName,
icon_from_external_svg: Option<SharedString>,
status: AgentThreadStatus,
- workspace: Entity<Workspace>,
+ workspace: ThreadEntryWorkspace,
is_live: bool,
is_background: bool,
highlight_positions: Vec<usize>,
@@ -528,7 +534,8 @@ impl Sidebar {
// main repo's header instead of getting their own.
let mut main_repo_workspace: HashMap<Arc<Path>, usize> = HashMap::new();
let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
- let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString)>> = HashMap::new();
+ let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString, Arc<Path>)>> = HashMap::new();
+ let mut absorbed_workspace_by_path: HashMap<Arc<Path>, usize> = HashMap::new();
for (i, workspace) in workspaces.iter().enumerate() {
for snapshot in root_repository_snapshots(workspace, cx) {
@@ -537,8 +544,9 @@ impl Sidebar {
.entry(snapshot.work_directory_abs_path.clone())
.or_insert(i);
if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) {
- for (ws_idx, name) in waiting {
+ for (ws_idx, name, ws_path) in waiting {
absorbed.insert(ws_idx, (i, name));
+ absorbed_workspace_by_path.insert(ws_path, ws_idx);
}
}
} else {
@@ -553,11 +561,13 @@ impl Sidebar {
main_repo_workspace.get(&snapshot.original_repo_abs_path)
{
absorbed.insert(i, (main_idx, name));
+ absorbed_workspace_by_path
+ .insert(snapshot.work_directory_abs_path.clone(), i);
} else {
pending
.entry(snapshot.original_repo_abs_path.clone())
.or_default()
- .push((i, name));
+ .push((i, name, snapshot.work_directory_abs_path.clone()));
}
}
}
@@ -586,7 +596,7 @@ impl Sidebar {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::default(),
- workspace: workspace.clone(),
+ workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
@@ -599,7 +609,8 @@ impl Sidebar {
// Load threads from linked git worktrees of this workspace's repos.
if let Some(ref thread_store) = thread_store {
- let mut linked_worktree_queries: Vec<(PathList, SharedString)> = Vec::new();
+ let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc<Path>)> =
+ Vec::new();
for snapshot in root_repository_snapshots(workspace, cx) {
if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
continue;
@@ -614,11 +625,20 @@ impl Sidebar {
linked_worktree_queries.push((
PathList::new(std::slice::from_ref(&git_worktree.path)),
name.into(),
+ Arc::from(git_worktree.path.as_path()),
));
}
}
- for (worktree_path_list, worktree_name) in &linked_worktree_queries {
+ for (worktree_path_list, worktree_name, worktree_path) in
+ &linked_worktree_queries
+ {
+ let target_workspace =
+ match absorbed_workspace_by_path.get(worktree_path.as_ref()) {
+ Some(&idx) => ThreadEntryWorkspace::Open(workspaces[idx].clone()),
+ None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
+ };
+
for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) {
if !seen_session_ids.insert(meta.id.clone()) {
continue;
@@ -628,7 +648,7 @@ impl Sidebar {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::default(),
- workspace: workspace.clone(),
+ workspace: target_workspace.clone(),
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
@@ -1347,8 +1367,20 @@ impl Sidebar {
}
ListEntry::Thread(thread) => {
let session_info = thread.session_info.clone();
- let workspace = thread.workspace.clone();
- self.activate_thread(session_info, &workspace, window, cx);
+ match &thread.workspace {
+ ThreadEntryWorkspace::Open(workspace) => {
+ let workspace = workspace.clone();
+ self.activate_thread(session_info, &workspace, window, cx);
+ }
+ ThreadEntryWorkspace::Closed(path_list) => {
+ self.open_workspace_and_activate_thread(
+ session_info,
+ path_list.clone(),
+ window,
+ cx,
+ );
+ }
+ }
}
ListEntry::ViewMore {
path_list,
@@ -1403,6 +1435,32 @@ impl Sidebar {
}
}
+ fn open_workspace_and_activate_thread(
+ &mut self,
+ session_info: acp_thread::AgentSessionInfo,
+ path_list: PathList,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+ return;
+ };
+
+ let paths: Vec<std::path::PathBuf> =
+ path_list.paths().iter().map(|p| p.to_path_buf()).collect();
+
+ let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
+
+ cx.spawn_in(window, async move |this, cx| {
+ let workspace = open_task.await?;
+ this.update_in(cx, |this, window, cx| {
+ this.activate_thread(session_info, &workspace, window, cx);
+ })?;
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
fn expand_selected_entry(
&mut self,
_: &ExpandSelectedEntry,
@@ -1480,7 +1538,7 @@ impl Sidebar {
.clone()
.unwrap_or_else(|| "Untitled".into());
let session_info = thread.session_info.clone();
- let workspace = thread.workspace.clone();
+ let thread_workspace = thread.workspace.clone();
let id = SharedString::from(format!("thread-entry-{}", ix));
@@ -1533,7 +1591,19 @@ impl Sidebar {
.docked_right(docked_right)
.on_click(cx.listener(move |this, _, window, cx| {
this.selection = None;
- this.activate_thread(session_info.clone(), &workspace, window, cx);
+ match &thread_workspace {
+ ThreadEntryWorkspace::Open(workspace) => {
+ this.activate_thread(session_info.clone(), workspace, window, cx);
+ }
+ ThreadEntryWorkspace::Closed(path_list) => {
+ this.open_workspace_and_activate_thread(
+ session_info.clone(),
+ path_list.clone(),
+ window,
+ cx,
+ );
+ }
+ }
}))
.into_any_element()
}
@@ -2447,7 +2517,7 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Completed,
- workspace: workspace.clone(),
+ workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
@@ -2468,7 +2538,7 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Running,
- workspace: workspace.clone(),
+ workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: true,
is_background: false,
highlight_positions: Vec::new(),
@@ -2489,7 +2559,7 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Error,
- workspace: workspace.clone(),
+ workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: true,
is_background: false,
highlight_positions: Vec::new(),
@@ -2510,7 +2580,7 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::WaitingForConfirmation,
- workspace: workspace.clone(),
+ workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
@@ -2531,7 +2601,7 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Completed,
- workspace: workspace.clone(),
+ workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: true,
is_background: true,
highlight_positions: Vec::new(),
@@ -4305,4 +4375,222 @@ mod tests {
vec!["v [project]", " Thread A {wt-feature-a}",]
);
}
+
+ #[gpui::test]
+ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+
+ fs.insert_tree(
+ "/project",
+ serde_json::json!({
+ ".git": {
+ "worktrees": {
+ "feature-a": {
+ "commondir": "../../",
+ "HEAD": "ref: refs/heads/feature-a",
+ },
+ },
+ },
+ "src": {},
+ }),
+ )
+ .await;
+
+ fs.insert_tree(
+ "/wt-feature-a",
+ serde_json::json!({
+ ".git": "gitdir: /project/.git/worktrees/feature-a",
+ "src": {},
+ }),
+ )
+ .await;
+
+ fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
+ state.worktrees.push(git::repository::Worktree {
+ path: std::path::PathBuf::from("/wt-feature-a"),
+ ref_name: "refs/heads/feature-a".into(),
+ sha: "aaa".into(),
+ });
+ })
+ .unwrap();
+
+ cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+ // Only open the main repo — no workspace for the worktree.
+ let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+ main_project
+ .update(cx, |p, cx| p.git_scans_complete(cx))
+ .await;
+
+ 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);
+
+ // Save a thread for the worktree path (no workspace for it).
+ let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
+ save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
+
+ multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+ cx.run_until_parked();
+
+ // Thread should appear under the main repo with a worktree chip.
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec!["v [project]", " WT Thread {wt-feature-a}"],
+ );
+
+ // Only 1 workspace should exist.
+ assert_eq!(
+ multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
+ 1,
+ );
+
+ // Focus the sidebar and select the worktree thread.
+ open_and_focus_sidebar(&sidebar, cx);
+ sidebar.update_in(cx, |sidebar, _window, _cx| {
+ sidebar.selection = Some(1); // index 0 is header, 1 is the thread
+ });
+
+ // Confirm to open the worktree thread.
+ cx.dispatch_action(Confirm);
+ cx.run_until_parked();
+
+ // A new workspace should have been created for the worktree path.
+ let new_workspace = multi_workspace.read_with(cx, |mw, _| {
+ assert_eq!(
+ mw.workspaces().len(),
+ 2,
+ "confirming a worktree thread without a workspace should open one",
+ );
+ mw.workspaces()[1].clone()
+ });
+
+ let (new_path_list, _) = new_workspace.read_with(cx, |_, cx| {
+ workspace_path_list_and_label(&new_workspace, cx)
+ });
+ assert_eq!(
+ new_path_list,
+ PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
+ "the new workspace should have been opened for the worktree path",
+ );
+ }
+
+ #[gpui::test]
+ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+
+ fs.insert_tree(
+ "/project",
+ serde_json::json!({
+ ".git": {
+ "worktrees": {
+ "feature-a": {
+ "commondir": "../../",
+ "HEAD": "ref: refs/heads/feature-a",
+ },
+ },
+ },
+ "src": {},
+ }),
+ )
+ .await;
+
+ fs.insert_tree(
+ "/wt-feature-a",
+ serde_json::json!({
+ ".git": "gitdir: /project/.git/worktrees/feature-a",
+ "src": {},
+ }),
+ )
+ .await;
+
+ fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
+ state.worktrees.push(git::repository::Worktree {
+ path: std::path::PathBuf::from("/wt-feature-a"),
+ ref_name: "refs/heads/feature-a".into(),
+ sha: "aaa".into(),
+ });
+ })
+ .unwrap();
+
+ cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+ let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+ let worktree_project =
+ project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
+
+ main_project
+ .update(cx, |p, cx| p.git_scans_complete(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(main_project.clone(), window, cx)
+ });
+
+ let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(worktree_project.clone(), window, cx)
+ });
+
+ // Activate the main workspace before setting up the sidebar.
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.activate_index(0, window, cx);
+ });
+
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+
+ let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
+ let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
+ save_named_thread("thread-main", "Main Thread", &paths_main, cx).await;
+ save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
+
+ multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+ cx.run_until_parked();
+
+ // The worktree workspace should be absorbed under the main repo.
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ assert_eq!(entries.len(), 3);
+ assert_eq!(entries[0], "v [project]");
+ assert!(entries.contains(&" Main Thread".to_string()));
+ assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
+
+ let wt_thread_index = entries
+ .iter()
+ .position(|e| e.contains("WT Thread"))
+ .expect("should find the worktree thread entry");
+
+ assert_eq!(
+ multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+ 0,
+ "main workspace should be active initially"
+ );
+
+ // Focus the sidebar and select the absorbed worktree thread.
+ open_and_focus_sidebar(&sidebar, cx);
+ sidebar.update_in(cx, |sidebar, _window, _cx| {
+ sidebar.selection = Some(wt_thread_index);
+ });
+
+ // Confirm to activate the worktree thread.
+ cx.dispatch_action(Confirm);
+ cx.run_until_parked();
+
+ // The worktree workspace should now be active, not the main one.
+ let active_workspace = multi_workspace.read_with(cx, |mw, _| {
+ mw.workspaces()[mw.active_workspace_index()].clone()
+ });
+ assert_eq!(
+ active_workspace, worktree_workspace,
+ "clicking an absorbed worktree thread should activate the worktree workspace"
+ );
+ }
}
@@ -659,7 +659,7 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
} else {
let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx);
cx.spawn(async move |cx| {
- let (window, _) = task.await?;
+ let OpenResult { window, .. } = task.await?;
window.update(cx, |multi_workspace, window, cx| {
window.activate_window();
let workspace = multi_workspace.workspace().clone();
@@ -1752,12 +1752,7 @@ impl Workspace {
init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
activate: bool,
cx: &mut App,
- ) -> Task<
- anyhow::Result<(
- WindowHandle<MultiWorkspace>,
- Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
- )>,
- > {
+ ) -> Task<anyhow::Result<OpenResult>> {
let project_handle = Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
@@ -1997,7 +1992,11 @@ impl Workspace {
});
})
.log_err();
- Ok((window, opened_items))
+ Ok(OpenResult {
+ window,
+ workspace,
+ opened_items,
+ })
})
}
@@ -2685,7 +2684,10 @@ impl Workspace {
cx,
);
cx.spawn_in(window, async move |_vh, cx| {
- let (multi_workspace_window, _) = task.await?;
+ let OpenResult {
+ window: multi_workspace_window,
+ ..
+ } = task.await?;
multi_workspace_window.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| callback(workspace, window, cx))
@@ -2723,7 +2725,10 @@ impl Workspace {
cx,
);
cx.spawn_in(window, async move |_vh, cx| {
- let (multi_workspace_window, _) = task.await?;
+ let OpenResult {
+ window: multi_workspace_window,
+ ..
+ } = task.await?;
multi_workspace_window.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| callback(workspace, window, cx))
@@ -3102,7 +3107,7 @@ impl Workspace {
paths: Vec<PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
- ) -> Task<Result<()>> {
+ ) -> Task<Result<Entity<Workspace>>> {
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
let is_remote = self.project.read(cx).is_via_collab();
let has_worktree = self.project.read(cx).worktrees(cx).next().is_some();
@@ -3118,19 +3123,20 @@ impl Workspace {
let app_state = self.app_state.clone();
cx.spawn(async move |_, cx| {
- cx.update(|cx| {
- open_paths(
- &paths,
- app_state,
- OpenOptions {
- replace_window: window_to_replace,
- ..Default::default()
- },
- cx,
- )
- })
- .await?;
- Ok(())
+ let OpenResult { workspace, .. } = cx
+ .update(|cx| {
+ open_paths(
+ &paths,
+ app_state,
+ OpenOptions {
+ replace_window: window_to_replace,
+ ..Default::default()
+ },
+ cx,
+ )
+ })
+ .await?;
+ Ok(workspace)
})
}
@@ -8210,7 +8216,7 @@ pub async fn restore_multiworkspace(
cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx))
.await?
} else {
- let (window, _items) = cx
+ let OpenResult { window, .. } = cx
.update(|cx| {
Workspace::new_local(
first.paths.paths().to_vec(),
@@ -8503,7 +8509,10 @@ pub fn join_channel(
let mut active_window = requesting_window.or_else(|| activate_any_workspace_window(cx));
if active_window.is_none() {
// no open workspaces, make one to show the error in (blergh)
- let (window_handle, _) = cx
+ let OpenResult {
+ window: window_handle,
+ ..
+ } = cx
.update(|cx| {
Workspace::new_local(
vec![],
@@ -8759,6 +8768,14 @@ pub struct OpenOptions {
pub env: Option<HashMap<String, String>>,
}
+/// The result of opening a workspace via [`open_paths`], [`Workspace::new_local`],
+/// or [`Workspace::open_workspace_for_paths`].
+pub struct OpenResult {
+ pub window: WindowHandle<MultiWorkspace>,
+ pub workspace: Entity<Workspace>,
+ pub opened_items: Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
+}
+
/// Opens a workspace by its database ID, used for restoring empty workspaces with unsaved content.
pub fn open_workspace_by_id(
workspace_id: WorkspaceId,
@@ -8878,12 +8895,7 @@ pub fn open_paths(
app_state: Arc<AppState>,
open_options: OpenOptions,
cx: &mut App,
-) -> Task<
- anyhow::Result<(
- WindowHandle<MultiWorkspace>,
- Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
- )>,
-> {
+) -> Task<anyhow::Result<OpenResult>> {
let abs_paths = abs_paths.to_vec();
#[cfg(target_os = "windows")]
let wsl_path = abs_paths
@@ -8962,7 +8974,7 @@ pub fn open_paths(
});
});
- Ok((existing, open_task))
+ Ok(OpenResult { window: existing, workspace: target_workspace, opened_items: open_task })
} else {
let result = cx
.update(move |cx| {
@@ -8978,8 +8990,8 @@ pub fn open_paths(
})
.await;
- if let Ok((ref window_handle, _)) = result {
- window_handle
+ if let Ok(ref result) = result {
+ result.window
.update(cx, |_, window, _cx| {
window.activate_window();
})
@@ -8991,9 +9003,9 @@ pub fn open_paths(
#[cfg(target_os = "windows")]
if let Some(util::paths::WslPath{distro, path}) = wsl_path
- && let Ok((multi_workspace_window, _)) = &result
+ && let Ok(ref result) = result
{
- multi_workspace_window
+ result.window
.update(cx, move |multi_workspace, _window, cx| {
struct OpenInWsl;
let workspace = multi_workspace.workspace().clone();
@@ -9040,7 +9052,7 @@ pub fn open_new(
cx,
);
cx.spawn(async move |cx| {
- let (window, _opened_paths) = task.await?;
+ let OpenResult { window, .. } = task.await?;
window
.update(cx, |_, window, _cx| {
window.activate_window();