sidebar: Fixes around multi-root projects (#53276)

When a project mixes main repo worktrees (e.g. `extensions`) with linked
git worktrees (e.g. `zed4`, a linked worktree of `zed`), two things were
broken:

1. **Threads appeared under the wrong sidebar heading.** A thread
created in a workspace with both `extensions` and `zed4` would show
under the "zed" group instead of the "extensions, zed" group. This
happened because `main_worktree_paths` in `ThreadMetadata` was only
populated with paths from linked worktrees, omitting regular repos
entirely. The fix uses `project_group_key()` to normalize all visible
worktrees to their main repo paths — the same normalization the sidebar
uses for group headers.

2. **The worktree chip tooltip only showed linked worktree paths**,
missing main repo paths like `extensions`. This happened because
`worktree_info_from_thread_paths` filtered out main worktree paths
entirely. The fix introduces a `WorktreeKind` enum (`Main` / `Linked`)
on `ThreadItemWorktreeInfo`, so all worktrees are included in the data
model. Chips still only render for `Linked` worktrees (main worktrees
are redundant with the group header), but the tooltip now shows all
paths.

Release Notes:

- N/A
This commit is contained in:
Eric Holk 2026-04-07 10:19:45 -07:00 committed by GitHub
parent 70d6c2bdc4
commit 082950d878
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 79 additions and 75 deletions

View file

@ -567,19 +567,12 @@ 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)
};
let main_worktree_paths = thread_ref
.project()
.read(cx)
.project_group_key(cx)
.path_list()
.clone();
// Threads without a folder path (e.g. started in an empty
// window) are archived by default so they don't get lost,

View file

@ -163,6 +163,7 @@ struct WorktreeInfo {
name: SharedString,
full_path: SharedString,
highlight_positions: Vec<usize>,
kind: ui::WorktreeKind,
}
#[derive(Clone)]
@ -307,23 +308,25 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
/// Derives worktree display info from a thread's stored path list.
///
/// For each path in the thread's `folder_paths` that is not one of the
/// group's main paths (i.e. it's a git linked worktree), produces a
/// [`WorktreeInfo`] with the short worktree name and full path.
/// For each path in the thread's `folder_paths`, produces a
/// [`WorktreeInfo`] with a short display name, full path, and whether
/// the worktree is the main checkout or a linked git worktree.
fn worktree_info_from_thread_paths(
folder_paths: &PathList,
group_key: &project::ProjectGroupKey,
) -> Vec<WorktreeInfo> {
) -> impl Iterator<Item = WorktreeInfo> {
let main_paths = group_key.path_list().paths();
folder_paths
.paths()
.iter()
.filter_map(|path| {
if main_paths.iter().any(|mp| mp.as_path() == path.as_path()) {
return None;
}
// Find the main path whose file name matches this linked
// worktree's file name, falling back to the first main path.
folder_paths.paths().iter().filter_map(|path| {
let is_main = main_paths.iter().any(|mp| mp.as_path() == path.as_path());
if is_main {
let name = path.file_name()?.to_string_lossy().to_string();
Some(WorktreeInfo {
name: SharedString::from(name),
full_path: SharedString::from(path.display().to_string()),
highlight_positions: Vec::new(),
kind: ui::WorktreeKind::Main,
})
} else {
let main_path = main_paths
.iter()
.find(|mp| mp.file_name() == path.file_name())
@ -332,9 +335,10 @@ fn worktree_info_from_thread_paths(
name: linked_worktree_short_name(main_path, path).unwrap_or_default(),
full_path: SharedString::from(path.display().to_string()),
highlight_positions: Vec::new(),
kind: ui::WorktreeKind::Linked,
})
})
.collect()
}
})
}
/// The sidebar re-derives its entire entry list from scratch on every
@ -851,7 +855,8 @@ impl Sidebar {
workspace: ThreadEntryWorkspace|
-> ThreadEntry {
let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
let worktrees = worktree_info_from_thread_paths(&row.folder_paths, &group_key);
let worktrees: Vec<WorktreeInfo> =
worktree_info_from_thread_paths(&row.folder_paths, &group_key).collect();
ThreadEntry {
metadata: row,
icon,
@ -1059,7 +1064,9 @@ impl Sidebar {
if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry {
let ws_path_list = workspace_path_list(draft_ws, cx);
let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key);
entries.push(ListEntry::DraftThread { worktrees });
entries.push(ListEntry::DraftThread {
worktrees: worktrees.collect(),
});
}
}
@ -1073,7 +1080,8 @@ impl Sidebar {
&& active_workspace.as_ref().is_some_and(|active_ws| {
let ws_path_list = workspace_path_list(active_ws, cx);
let has_linked_worktrees =
!worktree_info_from_thread_paths(&ws_path_list, &group_key).is_empty();
worktree_info_from_thread_paths(&ws_path_list, &group_key)
.any(|wt| wt.kind == ui::WorktreeKind::Linked);
if !has_linked_worktrees {
return false;
}
@ -1102,6 +1110,7 @@ impl Sidebar {
&workspace_path_list(ws, cx),
&group_key,
)
.collect()
})
.unwrap_or_default()
} else {
@ -2545,6 +2554,7 @@ impl Sidebar {
name: wt.name.clone(),
full_path: wt.full_path.clone(),
highlight_positions: Vec::new(),
kind: wt.kind,
})
.collect(),
diff_stats: thread.diff_stats,
@ -2817,6 +2827,7 @@ impl Sidebar {
name: wt.name.clone(),
full_path: wt.full_path.clone(),
highlight_positions: wt.highlight_positions.clone(),
kind: wt.kind,
})
.collect(),
)
@ -3095,6 +3106,7 @@ impl Sidebar {
name: wt.name.clone(),
full_path: wt.full_path.clone(),
highlight_positions: wt.highlight_positions.clone(),
kind: wt.kind,
})
.collect(),
)
@ -3132,6 +3144,7 @@ impl Sidebar {
name: wt.name.clone(),
full_path: wt.full_path.clone(),
highlight_positions: wt.highlight_positions.clone(),
kind: wt.kind,
})
.collect(),
)

View file

@ -191,6 +191,25 @@ fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
cx.run_until_parked();
}
fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
let mut seen = Vec::new();
let mut chips = Vec::new();
for wt in worktrees {
if wt.kind == ui::WorktreeKind::Main {
continue;
}
if !seen.contains(&wt.name) {
seen.push(wt.name.clone());
chips.push(format!("{{{}}}", wt.name));
}
}
if chips.is_empty() {
String::new()
} else {
format!(" {}", chips.join(", "))
}
}
fn visible_entries_as_strings(
sidebar: &Entity<Sidebar>,
cx: &mut gpui::VisualTestContext,
@ -238,23 +257,8 @@ fn visible_entries_as_strings(
} else {
""
};
let worktree = if thread.worktrees.is_empty() {
String::new()
} else {
let mut seen = Vec::new();
let mut chips = Vec::new();
for wt in &thread.worktrees {
if !seen.contains(&wt.name) {
seen.push(wt.name.clone());
chips.push(format!("{{{}}}", wt.name));
}
}
format!(" {}", chips.join(", "))
};
format!(
" {}{}{}{}{}{}",
title, worktree, active, status_str, notified, selected
)
let worktree = format_linked_worktree_chips(&thread.worktrees);
format!(" {title}{worktree}{active}{status_str}{notified}{selected}")
}
ListEntry::ViewMore {
is_fully_expanded, ..
@ -266,35 +270,11 @@ fn visible_entries_as_strings(
}
}
ListEntry::DraftThread { worktrees, .. } => {
let worktree = if worktrees.is_empty() {
String::new()
} else {
let mut seen = Vec::new();
let mut chips = Vec::new();
for wt in worktrees {
if !seen.contains(&wt.name) {
seen.push(wt.name.clone());
chips.push(format!("{{{}}}", wt.name));
}
}
format!(" {}", chips.join(", "))
};
let worktree = format_linked_worktree_chips(worktrees);
format!(" [~ Draft{}]{}", worktree, selected)
}
ListEntry::NewThread { worktrees, .. } => {
let worktree = if worktrees.is_empty() {
String::new()
} else {
let mut seen = Vec::new();
let mut chips = Vec::new();
for wt in worktrees {
if !seen.contains(&wt.name) {
seen.push(wt.name.clone());
chips.push(format!("{{{}}}", wt.name));
}
}
format!(" {}", chips.join(", "))
};
let worktree = format_linked_worktree_chips(worktrees);
format!(" [+ New Thread{}]{}", worktree, selected)
}
}

View file

@ -16,11 +16,19 @@ pub enum AgentThreadStatus {
Error,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum WorktreeKind {
#[default]
Main,
Linked,
}
#[derive(Clone)]
pub struct ThreadItemWorktreeInfo {
pub name: SharedString,
pub full_path: SharedString,
pub highlight_positions: Vec<usize>,
pub kind: WorktreeKind,
}
#[derive(IntoElement, RegisterComponent)]
@ -359,7 +367,10 @@ impl RenderOnce for ThreadItem {
let has_project_name = self.project_name.is_some();
let has_project_paths = project_paths.is_some();
let has_worktree = !self.worktrees.is_empty();
let has_worktree = self
.worktrees
.iter()
.any(|wt| wt.kind == WorktreeKind::Linked);
let has_timestamp = !self.timestamp.is_empty();
let timestamp = self.timestamp;
@ -449,6 +460,10 @@ impl RenderOnce for ThreadItem {
continue;
}
if wt.kind == WorktreeKind::Main {
continue;
}
let chip_index = seen_names.len();
seen_names.push(wt.name.clone());
@ -624,6 +639,7 @@ impl Component for ThreadItem {
name: "link-agent-panel".into(),
full_path: "link-agent-panel".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
}]),
)
.into_any_element(),
@ -650,6 +666,7 @@ impl Component for ThreadItem {
name: "my-project".into(),
full_path: "my-project".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
}])
.added(42)
.removed(17)
@ -729,6 +746,7 @@ impl Component for ThreadItem {
name: "my-project-name".into(),
full_path: "my-project-name".into(),
highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11],
kind: WorktreeKind::Linked,
}]),
)
.into_any_element(),