From 9b0f454995374eee71d7729a42475610085b5827 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:11:17 +0100 Subject: [PATCH] sidebar: Make sure workspace has git state loaded when opening a new one (#52233) ## Context This fixes a bug where switching to a worktree workspace sometimes briefly flashes it as a top-level project in the sidebar. This happened because the sidebar would rebuild its entries whenever the multi workspace emits the added workspace event. The fix is checking from the root workspace git linked worktree list as well to see if newly added workspaces are linked. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 198 ++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 019f4975c41174337593a31b892ee6bdde0dc151..53eebc4d2604e91caa4fc0fec57f27345becbae0 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -670,6 +670,19 @@ impl Sidebar { let mut absorbed: HashMap = HashMap::new(); let mut pending: HashMap, Vec<(usize, SharedString, Arc)>> = HashMap::new(); let mut absorbed_workspace_by_path: HashMap, usize> = HashMap::new(); + let workspace_indices_by_path: HashMap, Vec> = workspaces + .iter() + .enumerate() + .flat_map(|(index, workspace)| { + let paths = workspace_path_list(workspace, cx).paths().to_vec(); + paths + .into_iter() + .map(move |path| (Arc::from(path.as_path()), index)) + }) + .fold(HashMap::new(), |mut map, (path, index)| { + map.entry(path).or_default().push(index); + map + }); for (i, workspace) in workspaces.iter().enumerate() { for snapshot in root_repository_snapshots(workspace, cx) { @@ -677,6 +690,29 @@ impl Sidebar { main_repo_workspace .entry(snapshot.work_directory_abs_path.clone()) .or_insert(i); + + for git_worktree in snapshot.linked_worktrees() { + let worktree_path: Arc = Arc::from(git_worktree.path.as_path()); + if let Some(worktree_indices) = + workspace_indices_by_path.get(worktree_path.as_ref()) + { + for &worktree_idx in worktree_indices { + if worktree_idx == i { + continue; + } + + let worktree_name = linked_worktree_short_name( + &snapshot.original_repo_abs_path, + &git_worktree.path, + ) + .unwrap_or_default(); + absorbed.insert(worktree_idx, (i, worktree_name.clone())); + absorbed_workspace_by_path + .insert(worktree_path.clone(), worktree_idx); + } + } + } + if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { for (ws_idx, name, ws_path) in waiting { absorbed.insert(ws_idx, (i, name)); @@ -2120,6 +2156,7 @@ impl Sidebar { cx.spawn_in(window, async move |this, cx| { let workspace = open_task.await?; + this.update_in(cx, |this, window, cx| { this.activate_thread(agent, session_info, &workspace, window, cx); })?; @@ -6128,6 +6165,167 @@ mod tests { ); } + #[gpui::test] + async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project( + 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: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + 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); + + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " WT Thread {wt-feature-a}"], + ); + + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(1); + }); + + let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { + let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| { + if let ListEntry::ProjectHeader { label, .. } = entry { + Some(label.as_ref()) + } else { + None + } + }); + + let Some(project_header) = project_headers.next() else { + panic!("expected exactly one sidebar project header named `project`, found none"); + }; + assert_eq!( + project_header, "project", + "expected the only sidebar project header to be `project`" + ); + if let Some(unexpected_header) = project_headers.next() { + panic!( + "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`" + ); + } + + let mut saw_expected_thread = false; + for entry in &sidebar.contents.entries { + match entry { + ListEntry::ProjectHeader { label, .. } => { + assert_eq!( + label.as_ref(), + "project", + "expected the only sidebar project header to be `project`" + ); + } + ListEntry::Thread(thread) + if thread + .session_info + .title + .as_ref() + .map(|title| title.as_ref()) + == Some("WT Thread") + && thread.worktree_name.as_ref().map(|name| name.as_ref()) + == Some("wt-feature-a") => + { + saw_expected_thread = true; + } + ListEntry::Thread(thread) => { + let title = thread + .session_info + .title + .as_ref() + .map(|title| title.as_ref()) + .unwrap_or("Untitled"); + let worktree_name = thread + .worktree_name + .as_ref() + .map(|name| name.as_ref()) + .unwrap_or(""); + panic!( + "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" + ); + } + ListEntry::ViewMore { .. } => { + panic!("unexpected `View More` entry while opening linked worktree thread"); + } + ListEntry::NewThread { .. } => { + panic!( + "unexpected `New Thread` entry while opening linked worktree thread" + ); + } + } + } + + assert!( + saw_expected_thread, + "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`" + ); + }; + + sidebar + .update(cx, |_, cx| cx.observe_self(assert_sidebar_state)) + .detach(); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Confirm.boxed_clone(), cx); + }) + .unwrap(); + + cx.run_until_parked(); + + sidebar.update(cx, assert_sidebar_state); + } + #[gpui::test] async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( cx: &mut TestAppContext,