sidebar: Show branch name after worktree name (#53900)

<img width="297" height="505" alt="Screenshot 2026-04-14 at 3 20 23 PM"
src="https://github.com/user-attachments/assets/90366ccd-86db-497e-9c81-af94b4144ebe"
/>


Display the git branch name alongside the worktree name in the sidebar's
thread list (or without the worktree name, if it's a main worktree).

Release Notes:

- Added branch name display next to worktree names in the agent sidebar
thread list.
This commit is contained in:
Richard Feldman authored and GitHub committed 2026-04-15 00:42:28 -04:00
1 parent 4a41cc4bf0
commit 21f09886d0
6 files changed
+723 -198

No files matched your search

+102 -6
View File
@@ -21,10 +21,10 @@ use db::{
use fs::Fs;
use futures::{FutureExt, future::Shared};
use gpui::{AppContext as _, Entity, Global, Subscription, Task};
use project::AgentId;
pub use project::WorktreePaths;
use project::{AgentId, linked_worktree_short_name};
use remote::{RemoteConnectionOptions, same_remote_connection_identity};
use ui::{App, Context, SharedString};
use ui::{App, Context, SharedString, ThreadItemWorktreeInfo, WorktreeKind};
use util::ResultExt as _;
use workspace::{PathList, SerializedWorkspaceLocation, WorkspaceDb};
@@ -314,6 +314,73 @@ impl ThreadMetadata {
}
}
/// Derives worktree display info from a thread's stored path list.
///
/// For each path in the thread's `folder_paths`, produces a
/// [`ThreadItemWorktreeInfo`] with a short display name, full path, and whether
/// the worktree is the main checkout or a linked git worktree. When
/// multiple main paths exist and a linked worktree's short name alone
/// wouldn't identify which main project it belongs to, the main project
/// name is prefixed for disambiguation (e.g. `project:feature`).
pub fn worktree_info_from_thread_paths<S: std::hash::BuildHasher>(
worktree_paths: &WorktreePaths,
branch_names: &std::collections::HashMap<PathBuf, SharedString, S>,
) -> Vec<ThreadItemWorktreeInfo> {
let mut infos: Vec<ThreadItemWorktreeInfo> = Vec::new();
let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new();
let mut unique_main_count = HashSet::default();
for (main_path, folder_path) in worktree_paths.ordered_pairs() {
unique_main_count.insert(main_path.clone());
let is_linked = main_path != folder_path;
if is_linked {
let short_name = linked_worktree_short_name(main_path, folder_path).unwrap_or_default();
let project_name = main_path
.file_name()
.map(|n| SharedString::from(n.to_string_lossy().to_string()))
.unwrap_or_default();
linked_short_names.push((short_name.clone(), project_name));
infos.push(ThreadItemWorktreeInfo {
name: short_name,
full_path: SharedString::from(folder_path.display().to_string()),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: branch_names.get(folder_path).cloned(),
});
} else {
let Some(name) = folder_path.file_name() else {
continue;
};
infos.push(ThreadItemWorktreeInfo {
name: SharedString::from(name.to_string_lossy().to_string()),
full_path: SharedString::from(folder_path.display().to_string()),
highlight_positions: Vec::new(),
kind: WorktreeKind::Main,
branch_name: branch_names.get(folder_path).cloned(),
});
}
}
// When the group has multiple main worktree paths and the thread's
// folder paths don't all share the same short name, prefix each
// linked worktree chip with its main project name so the user knows
// which project it belongs to.
let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name);
if unique_main_count.len() > 1 && !all_same_name {
for (info, (_short_name, project_name)) in infos
.iter_mut()
.filter(|i| i.kind == WorktreeKind::Linked)
.zip(linked_short_names.iter())
{
info.name = SharedString::from(format!("{}:{}", project_name, info.name));
}
}
infos
}
impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
fn from(meta: &ThreadMetadata) -> Self {
let session_id = meta
@@ -938,6 +1005,14 @@ impl ThreadMetadataStore {
})
}
pub fn get_all_archived_branch_names(
&self,
cx: &App,
) -> Task<anyhow::Result<HashMap<ThreadId, HashMap<PathBuf, String>>>> {
let db = self.db.clone();
cx.background_spawn(async move { db.get_all_archived_branch_names() })
}
fn update_archived(&mut self, thread_id: ThreadId, archived: bool, cx: &mut Context<Self>) {
if let Some(thread) = self.threads.get(&thread_id) {
self.save_internal(ThreadMetadata {
@@ -1094,10 +1169,10 @@ impl ThreadMetadataStore {
)
} else {
let project = thread_ref.project().read(cx);
(
project.worktree_paths(cx),
project.remote_connection_options(cx),
)
let worktree_paths = project.worktree_paths(cx);
let remote_connection = project.remote_connection_options(cx);
(worktree_paths, remote_connection)
};
// Threads without a folder path (e.g. started in an empty
@@ -1406,6 +1481,27 @@ impl ThreadMetadataDb {
)?(archived_worktree_id)
.map(|count| count.unwrap_or(0) > 0)
}
pub fn get_all_archived_branch_names(
&self,
) -> anyhow::Result<HashMap<ThreadId, HashMap<PathBuf, String>>> {
let rows = self.select::<(ThreadId, String, String)>(
"SELECT t.thread_id, a.worktree_path, a.branch_name \
FROM thread_archived_worktrees t \
JOIN archived_git_worktrees a ON a.id = t.archived_worktree_id \
WHERE a.branch_name IS NOT NULL \
ORDER BY a.id ASC",
)?()?;
let mut result: HashMap<ThreadId, HashMap<PathBuf, String>> = HashMap::default();
for (thread_id, worktree_path, branch_name) in rows {
result
.entry(thread_id)
.or_default()
.insert(PathBuf::from(worktree_path), branch_name);
}
Ok(result)
}
}
impl Column for ThreadMetadata {
+59 -2
View File
@@ -1,15 +1,19 @@
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use crate::agent_connection_store::AgentConnectionStore;
use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
use crate::thread_metadata_store::{
ThreadId, ThreadMetadata, ThreadMetadataStore, worktree_info_from_thread_paths,
};
use crate::{Agent, DEFAULT_THREAD_TITLE, RemoveSelectedThread};
use agent::ThreadStore;
use agent_client_protocol as acp;
use agent_settings::AgentSettings;
use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
use collections::HashMap;
use editor::Editor;
use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
@@ -133,6 +137,9 @@ pub struct ThreadsArchiveView {
agent_connection_store: WeakEntity<AgentConnectionStore>,
agent_server_store: WeakEntity<AgentServerStore>,
restoring: HashSet<ThreadId>,
archived_thread_ids: HashSet<ThreadId>,
archived_branch_names: HashMap<ThreadId, HashMap<PathBuf, String>>,
_load_branch_names_task: Task<()>,
}
impl ThreadsArchiveView {
@@ -175,6 +182,7 @@ impl ThreadsArchiveView {
&ThreadMetadataStore::global(cx),
|this: &mut Self, _, cx| {
this.update_items(cx);
this.reload_branch_names_if_threads_changed(cx);
},
);
@@ -202,9 +210,13 @@ impl ThreadsArchiveView {
agent_connection_store,
agent_server_store,
restoring: HashSet::default(),
archived_thread_ids: HashSet::default(),
archived_branch_names: HashMap::default(),
_load_branch_names_task: Task::ready(()),
};
this.update_items(cx);
this.reload_branch_names_if_threads_changed(cx);
this
}
@@ -331,6 +343,37 @@ impl ThreadsArchiveView {
cx.notify();
}
fn reload_branch_names_if_threads_changed(&mut self, cx: &mut Context<Self>) {
let current_ids: HashSet<ThreadId> = self
.items
.iter()
.filter_map(|item| match item {
ArchiveListItem::Entry { thread, .. } => Some(thread.thread_id),
_ => None,
})
.collect();
if current_ids != self.archived_thread_ids {
self.archived_thread_ids = current_ids;
self.load_archived_branch_names(cx);
}
}
fn load_archived_branch_names(&mut self, cx: &mut Context<Self>) {
let task = ThreadMetadataStore::global(cx)
.read(cx)
.get_all_archived_branch_names(cx);
self._load_branch_names_task = cx.spawn(async move |this, cx| {
if let Some(branch_names) = task.await.log_err() {
this.update(cx, |this, cx| {
this.archived_branch_names = branch_names;
cx.notify();
})
.log_err();
}
});
}
fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.filter_editor.update(cx, |editor, cx| {
editor.set_text("", window, cx);
@@ -537,6 +580,20 @@ impl ThreadsArchiveView {
let is_restoring = self.restoring.contains(&thread.thread_id);
let branch_names_for_thread: HashMap<PathBuf, SharedString> = self
.archived_branch_names
.get(&thread.thread_id)
.map(|map| {
map.iter()
.map(|(k, v)| (k.clone(), SharedString::from(v.clone())))
.collect()
})
.unwrap_or_default();
let worktrees = worktree_info_from_thread_paths(
&thread.worktree_paths,
&branch_names_for_thread,
);
let base = ThreadItem::new(id, thread.display_title())
.icon(icon)
.when_some(icon_from_external_svg, |this, svg| {
@@ -544,7 +601,7 @@ impl ThreadsArchiveView {
})
.timestamp(timestamp)
.highlight_positions(highlight_positions.clone())
.project_paths(thread.folder_paths().paths_owned())
.worktrees(worktrees)
.focused(is_focused)
.hovered(is_hovered)
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
+37 -95
View File
@@ -4,7 +4,9 @@ use acp_thread::ThreadStatus;
use action_log::DiffStats;
use agent_client_protocol::{self as acp};
use agent_settings::AgentSettings;
use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore, WorktreePaths};
use agent_ui::thread_metadata_store::{
ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths,
};
use agent_ui::thread_worktree_archive;
use agent_ui::threads_archive_view::{
ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
@@ -23,9 +25,7 @@ use gpui::{
use menu::{
Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
};
use project::{
AgentId, AgentRegistryStore, Event as ProjectEvent, WorktreeId, linked_worktree_short_name,
};
use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, WorktreeId};
use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
use remote::{RemoteConnectionOptions, same_remote_connection_identity};
use ui::utils::platform_title_bar_height;
@@ -36,6 +36,7 @@ use std::collections::{HashMap, HashSet};
use std::mem;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use theme::ActiveTheme;
use ui::{
AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel,
@@ -181,14 +182,6 @@ impl ThreadEntryWorkspace {
}
}
#[derive(Clone)]
struct WorktreeInfo {
name: SharedString,
full_path: SharedString,
highlight_positions: Vec<usize>,
kind: ui::WorktreeKind,
}
#[derive(Clone)]
struct ThreadEntry {
metadata: ThreadMetadata,
@@ -201,7 +194,7 @@ struct ThreadEntry {
is_title_generating: bool,
is_draft: bool,
highlight_positions: Vec<usize>,
worktrees: Vec<WorktreeInfo>,
worktrees: Vec<ThreadItemWorktreeInfo>,
diff_stats: DiffStats,
}
@@ -336,69 +329,6 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
PathList::new(&workspace.read(cx).root_paths(cx))
}
/// Derives worktree display info from a thread's stored path list.
///
/// 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. When
/// multiple main paths exist and a linked worktree's short name alone
/// wouldn't identify which main project it belongs to, the main project
/// name is prefixed for disambiguation (e.g. `project:feature`).
///
fn worktree_info_from_thread_paths(worktree_paths: &WorktreePaths) -> Vec<WorktreeInfo> {
let mut infos: Vec<WorktreeInfo> = Vec::new();
let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new();
let mut unique_main_count = HashSet::new();
for (main_path, folder_path) in worktree_paths.ordered_pairs() {
unique_main_count.insert(main_path.clone());
let is_linked = main_path != folder_path;
if is_linked {
let short_name = linked_worktree_short_name(main_path, folder_path).unwrap_or_default();
let project_name = main_path
.file_name()
.map(|n| SharedString::from(n.to_string_lossy().to_string()))
.unwrap_or_default();
linked_short_names.push((short_name.clone(), project_name));
infos.push(WorktreeInfo {
name: short_name,
full_path: SharedString::from(folder_path.display().to_string()),
highlight_positions: Vec::new(),
kind: ui::WorktreeKind::Linked,
});
} else {
let Some(name) = folder_path.file_name() else {
continue;
};
infos.push(WorktreeInfo {
name: SharedString::from(name.to_string_lossy().to_string()),
full_path: SharedString::from(folder_path.display().to_string()),
highlight_positions: Vec::new(),
kind: ui::WorktreeKind::Main,
});
}
}
// When the group has multiple main worktree paths and the thread's
// folder paths don't all share the same short name, prefix each
// linked worktree chip with its main project name so the user knows
// which project it belongs to.
let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name);
if unique_main_count.len() > 1 && !all_same_name {
for (info, (_short_name, project_name)) in infos
.iter_mut()
.filter(|i| i.kind == ui::WorktreeKind::Linked)
.zip(linked_short_names.iter())
{
info.name = SharedString::from(format!("{}:{}", project_name, info.name));
}
}
infos
}
/// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
/// an SSH connection. Suitable for passing to
/// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote`
@@ -636,7 +566,8 @@ impl Sidebar {
event,
project::git_store::GitStoreEvent::RepositoryUpdated(
_,
project::git_store::RepositoryEvent::GitWorktreeListChanged,
project::git_store::RepositoryEvent::GitWorktreeListChanged
| project::git_store::RepositoryEvent::HeadChanged,
_,
)
) {
@@ -1080,6 +1011,28 @@ impl Sidebar {
let path_detail_map: HashMap<PathBuf, usize> =
all_paths.into_iter().zip(path_details).collect();
let mut branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
for ws in &workspaces {
let project = ws.read(cx).project().read(cx);
for repo in project.repositories(cx).values() {
let snapshot = repo.read(cx).snapshot();
if let Some(branch) = &snapshot.branch {
branch_by_path.insert(
snapshot.work_directory_abs_path.to_path_buf(),
SharedString::from(Arc::<str>::from(branch.name())),
);
}
for linked_wt in snapshot.linked_worktrees() {
if let Some(branch) = linked_wt.branch_name() {
branch_by_path.insert(
linked_wt.path.clone(),
SharedString::from(Arc::<str>::from(branch)),
);
}
}
}
}
for group in &groups {
let group_key = &group.key;
let group_workspaces = &group.workspaces;
@@ -1136,7 +1089,8 @@ impl Sidebar {
let make_thread_entry =
|row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry {
let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
let worktrees = worktree_info_from_thread_paths(&row.worktree_paths);
let worktrees =
worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path);
let is_draft = row.is_draft();
ThreadEntry {
metadata: row,
@@ -3546,11 +3500,10 @@ impl Sidebar {
worktrees: thread
.worktrees
.iter()
.map(|wt| ThreadItemWorktreeInfo {
name: wt.name.clone(),
full_path: wt.full_path.clone(),
highlight_positions: Vec::new(),
kind: wt.kind,
.cloned()
.map(|mut wt| {
wt.highlight_positions = Vec::new();
wt
})
.collect(),
diff_stats: thread.diff_stats,
@@ -3815,18 +3768,7 @@ impl Sidebar {
.when_some(thread.icon_from_external_svg.clone(), |this, svg| {
this.custom_icon_from_external_svg(svg)
})
.worktrees(
thread
.worktrees
.iter()
.map(|wt| ThreadItemWorktreeInfo {
name: wt.name.clone(),
full_path: wt.full_path.clone(),
highlight_positions: wt.highlight_positions.clone(),
kind: wt.kind,
})
.collect(),
)
.worktrees(thread.worktrees.clone())
.timestamp(timestamp)
.highlight_positions(thread.highlight_positions.to_vec())
.title_generating(thread.is_title_generating)
+55 -1
View File
@@ -356,7 +356,7 @@ fn request_test_tool_authorization(
cx.run_until_parked();
}
fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
let mut seen = Vec::new();
let mut chips = Vec::new();
for wt in worktrees {
@@ -10888,3 +10888,57 @@ async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &m
"feature-b file should have been closed"
);
}
#[test]
fn test_worktree_info_branch_names_for_main_worktrees() {
let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
let branch_by_path: HashMap<PathBuf, SharedString> =
[(PathBuf::from("/projects/myapp"), "feature-x".into())]
.into_iter()
.collect();
let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
assert_eq!(infos[0].name, SharedString::from("myapp"));
}
#[test]
fn test_worktree_info_branch_names_for_linked_worktrees() {
let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
let worktree_paths =
WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
let branch_by_path: HashMap<PathBuf, SharedString> = [(
PathBuf::from("/projects/myapp-feature"),
"feature-branch".into(),
)]
.into_iter()
.collect();
let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
assert_eq!(
infos[0].branch_name,
Some(SharedString::from("feature-branch"))
);
}
#[test]
fn test_worktree_info_missing_branch_returns_none() {
let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
assert_eq!(infos[0].branch_name, None);
assert_eq!(infos[0].name, SharedString::from("myapp"));
}
+200 -94
View File
@@ -29,6 +29,7 @@ pub struct ThreadItemWorktreeInfo {
pub full_path: SharedString,
pub highlight_positions: Vec<usize>,
pub kind: WorktreeKind,
pub branch_name: Option<SharedString>,
}
#[derive(IntoElement, RegisterComponent)]
@@ -374,13 +375,119 @@ 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
.iter()
.any(|wt| wt.kind == WorktreeKind::Linked);
let has_timestamp = !self.timestamp.is_empty();
let timestamp = self.timestamp;
let visible_worktree_count = self
.worktrees
.iter()
.filter(|wt| !(wt.kind == WorktreeKind::Main && wt.branch_name.is_none()))
.count();
let worktree_tooltip_title = match (self.is_remote, visible_worktree_count > 1) {
(true, true) => "Thread Running in Remote Git Worktrees",
(true, false) => "Thread Running in a Remote Git Worktree",
(false, true) => "Thread Running in Local Git Worktrees",
(false, false) => "Thread Running in a Local Git Worktree",
};
let mut worktree_labels: Vec<AnyElement> = Vec::new();
let slash_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.4));
for wt in self.worktrees {
match (wt.kind, wt.branch_name) {
(WorktreeKind::Main, None) => continue,
(WorktreeKind::Main, Some(branch)) => {
let chip_index = worktree_labels.len();
let tooltip_title = worktree_tooltip_title;
let full_path = wt.full_path.clone();
worktree_labels.push(
h_flex()
.id(format!("{}-worktree-{chip_index}", self.id.clone()))
.min_w_0()
.when(visible_worktree_count > 1, |this| {
this.child(
Label::new(wt.name)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate(),
)
.child(
Label::new("/")
.size(LabelSize::Small)
.color(slash_color)
.flex_shrink_0(),
)
})
.child(
Label::new(branch)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate(),
)
.tooltip(move |_, cx| {
Tooltip::with_meta(tooltip_title, None, full_path.clone(), cx)
})
.into_any_element(),
);
}
(WorktreeKind::Linked, branch) => {
let chip_index = worktree_labels.len();
let tooltip_title = worktree_tooltip_title;
let full_path = wt.full_path.clone();
let label = if wt.highlight_positions.is_empty() {
Label::new(wt.name)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
.into_any_element()
} else {
HighlightedLabel::new(wt.name, wt.highlight_positions)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
.into_any_element()
};
worktree_labels.push(
h_flex()
.id(format!("{}-worktree-{chip_index}", self.id.clone()))
.min_w_0()
.gap_0p5()
.child(
Icon::new(IconName::GitWorktree)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(label)
.when_some(branch, |this, branch| {
this.child(
Label::new("/")
.size(LabelSize::Small)
.color(slash_color)
.flex_shrink_0(),
)
.child(
Label::new(branch)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate(),
)
})
.tooltip(move |_, cx| {
Tooltip::with_meta(tooltip_title, None, full_path.clone(), cx)
})
.into_any_element(),
);
}
}
}
let has_worktree = !worktree_labels.is_empty();
v_flex()
.id(self.id.clone())
.cursor_pointer()
@@ -441,102 +548,41 @@ impl RenderOnce for ThreadItem {
|| has_diff_stats
|| has_timestamp,
|this| {
// Collect all full paths for the shared tooltip.
let worktree_tooltip: SharedString = self
.worktrees
.iter()
.map(|wt| wt.full_path.as_ref())
.collect::<Vec<_>>()
.join("\n")
.into();
let worktree_tooltip_title = match (self.is_remote, self.worktrees.len() > 1) {
(true, true) => "Thread Running in Remote Git Worktrees",
(true, false) => "Thread Running in a Remote Git Worktree",
(false, true) => "Thread Running in Local Git Worktrees",
(false, false) => "Thread Running in a Local Git Worktree",
};
// Deduplicate chips by name — e.g. two paths both named
// "olivetti" produce a single chip. Highlight positions
// come from the first occurrence.
let mut seen_names: Vec<SharedString> = Vec::new();
let mut worktree_labels: Vec<AnyElement> = Vec::new();
for wt in self.worktrees {
if seen_names.contains(&wt.name) {
continue;
}
if wt.kind == WorktreeKind::Main {
continue;
}
let chip_index = seen_names.len();
seen_names.push(wt.name.clone());
let label = if wt.highlight_positions.is_empty() {
Label::new(wt.name)
.size(LabelSize::Small)
.color(Color::Muted)
.into_any_element()
} else {
HighlightedLabel::new(wt.name, wt.highlight_positions)
.size(LabelSize::Small)
.color(Color::Muted)
.into_any_element()
};
let tooltip_title = worktree_tooltip_title;
let tooltip_meta = worktree_tooltip.clone();
worktree_labels.push(
h_flex()
.id(format!("{}-worktree-{chip_index}", self.id.clone()))
.gap_0p5()
.child(
Icon::new(IconName::GitWorktree)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(label)
.tooltip(move |_, cx| {
Tooltip::with_meta(
tooltip_title,
None,
tooltip_meta.clone(),
cx,
)
})
.into_any_element(),
);
}
this.child(
h_flex()
.min_w_0()
.gap_1p5()
.child(icon_container()) // Icon Spacing
.when_some(self.project_name, |this, name| {
this.child(
Label::new(name).size(LabelSize::Small).color(Color::Muted),
)
})
.when(
has_project_name && (has_project_paths || has_worktree),
|this| this.child(dot_separator()),
.child(
h_flex()
.min_w_0()
.flex_shrink()
.overflow_hidden()
.gap_1p5()
.when_some(self.project_name, |this, name| {
this.child(
Label::new(name)
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.when(
has_project_name && (has_project_paths || has_worktree),
|this| this.child(dot_separator()),
)
.when_some(project_paths, |this, paths| {
this.child(
Label::new(paths)
.size(LabelSize::Small)
.color(Color::Muted)
.into_any_element(),
)
})
.when(has_project_paths && has_worktree, |this| {
this.child(dot_separator())
})
.children(worktree_labels),
)
.when_some(project_paths, |this, paths| {
this.child(
Label::new(paths)
.size(LabelSize::Small)
.color(Color::Muted)
.into_any_element(),
)
})
.when(has_project_paths && has_worktree, |this| {
this.child(dot_separator())
})
.children(worktree_labels)
.when(
(has_project_name || has_project_paths || has_worktree)
&& (has_diff_stats || has_timestamp),
@@ -648,6 +694,7 @@ impl Component for ThreadItem {
full_path: "link-agent-panel".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: None,
}]),
)
.into_any_element(),
@@ -675,6 +722,7 @@ impl Component for ThreadItem {
full_path: "my-project".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: None,
}])
.added(42)
.removed(17)
@@ -682,6 +730,63 @@ impl Component for ThreadItem {
)
.into_any_element(),
),
single_example(
"Worktree + Branch + Changes + Timestamp",
container()
.child(
ThreadItem::new("ti-5c", "Full metadata with branch")
.icon(IconName::AiClaude)
.worktrees(vec![ThreadItemWorktreeInfo {
name: "my-project".into(),
full_path: "/worktrees/my-project/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("feature-branch".into()),
}])
.added(42)
.removed(17)
.timestamp("3w"),
)
.into_any_element(),
),
single_example(
"Long Branch + Changes (truncation)",
container()
.child(
ThreadItem::new("ti-5d", "Metadata overflow with long branch name")
.icon(IconName::AiClaude)
.worktrees(vec![ThreadItemWorktreeInfo {
name: "my-project".into(),
full_path: "/worktrees/my-project/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("fix-very-long-branch-name-here".into()),
}])
.added(108)
.removed(53)
.timestamp("2d"),
)
.into_any_element(),
),
single_example(
"Main Branch + Changes + Timestamp",
container()
.child(
ThreadItem::new("ti-5e", "Main worktree branch with diff stats")
.icon(IconName::ZedAgent)
.worktrees(vec![ThreadItemWorktreeInfo {
name: "zed".into(),
full_path: "/projects/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Main,
branch_name: Some("sidebar-show-branch-name".into()),
}])
.added(23)
.removed(8)
.timestamp("5m"),
)
.into_any_element(),
),
single_example(
"Selected Item",
container()
@@ -755,6 +860,7 @@ impl Component for ThreadItem {
full_path: "my-project-name".into(),
highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11],
kind: WorktreeKind::Linked,
branch_name: None,
}]),
)
.into_any_element(),
+270
View File
@@ -446,6 +446,23 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
}
}
// Run Test: ThreadItem branch names visual test
println!("\n--- Test: thread_item_branch_names ---");
match run_thread_item_branch_name_visual_tests(app_state.clone(), &mut cx, update_baseline) {
Ok(TestResult::Passed) => {
println!("✓ thread_item_branch_names: PASSED");
passed += 1;
}
Ok(TestResult::BaselineUpdated(_)) => {
println!("✓ thread_item_branch_names: Baseline updated");
updated += 1;
}
Err(e) => {
eprintln!("✗ thread_item_branch_names: FAILED - {}", e);
failed += 1;
}
}
// Run Test 3: Multi-workspace sidebar visual tests
println!("\n--- Test 3: multi_workspace_sidebar ---");
match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) {
@@ -2849,6 +2866,259 @@ impl gpui::Render for ErrorWrappingTestView {
}
}
#[cfg(target_os = "macos")]
struct ThreadItemBranchNameTestView;
#[cfg(target_os = "macos")]
impl gpui::Render for ThreadItemBranchNameTestView {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
use ui::{
IconName, Label, LabelSize, ThreadItem, ThreadItemWorktreeInfo, WorktreeKind,
prelude::*,
};
let section_label = |text: &str| {
Label::new(text.to_string())
.size(LabelSize::Small)
.color(Color::Muted)
};
let container = || {
v_flex()
.w_80()
.border_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().panel_background)
};
v_flex()
.size_full()
.bg(cx.theme().colors().background)
.p_4()
.gap_3()
.child(
Label::new("ThreadItem Branch Names")
.size(LabelSize::Large)
.color(Color::Default),
)
.child(section_label(
"Linked worktree with branch (worktree / branch)",
))
.child(
container().child(
ThreadItem::new("ti-linked-branch", "Fix scrolling behavior")
.icon(IconName::AiClaude)
.timestamp("5m")
.worktrees(vec![ThreadItemWorktreeInfo {
name: "jade-glen".into(),
full_path: "/worktrees/jade-glen/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("fix-scrolling".into()),
}]),
),
)
.child(section_label(
"Linked worktree without branch (detached HEAD)",
))
.child(
container().child(
ThreadItem::new("ti-linked-no-branch", "Review worktree cleanup")
.icon(IconName::AiClaude)
.timestamp("1h")
.worktrees(vec![ThreadItemWorktreeInfo {
name: "focal-arrow".into(),
full_path: "/worktrees/focal-arrow/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: None,
}]),
),
)
.child(section_label(
"Main worktree with branch (branch only, no icon)",
))
.child(
container().child(
ThreadItem::new("ti-main-branch", "Request for Long Classic Poem")
.icon(IconName::ZedAgent)
.timestamp("2d")
.worktrees(vec![ThreadItemWorktreeInfo {
name: "zed".into(),
full_path: "/projects/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Main,
branch_name: Some("main".into()),
}]),
),
)
.child(section_label(
"Main worktree without branch (nothing shown)",
))
.child(
container().child(
ThreadItem::new("ti-main-no-branch", "Simple greeting thread")
.icon(IconName::ZedAgent)
.timestamp("3d")
.worktrees(vec![ThreadItemWorktreeInfo {
name: "zed".into(),
full_path: "/projects/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Main,
branch_name: None,
}]),
),
)
.child(section_label("Linked worktree where name matches branch"))
.child(
container().child(
ThreadItem::new("ti-same-name", "Implement feature")
.icon(IconName::AiClaude)
.timestamp("6d")
.worktrees(vec![ThreadItemWorktreeInfo {
name: "stoic-reed".into(),
full_path: "/worktrees/stoic-reed/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("stoic-reed".into()),
}]),
),
)
.child(section_label(
"Manually opened linked worktree (main_path resolves to original repo)",
))
.child(
container().child(
ThreadItem::new("ti-manual-linked", "Robust Git Worktree Rollback")
.icon(IconName::ZedAgent)
.timestamp("40m")
.worktrees(vec![ThreadItemWorktreeInfo {
name: "focal-arrow".into(),
full_path: "/worktrees/focal-arrow/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("persist-worktree-3-wiring".into()),
}]),
),
)
.child(section_label(
"Linked worktree + branch + diff stats + timestamp",
))
.child(
container().child(
ThreadItem::new("ti-linked-full", "Full metadata with diff stats")
.icon(IconName::AiClaude)
.timestamp("3w")
.added(42)
.removed(17)
.worktrees(vec![ThreadItemWorktreeInfo {
name: "jade-glen".into(),
full_path: "/worktrees/jade-glen/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("feature-branch".into()),
}]),
),
)
.child(section_label("Long branch name truncation with diff stats"))
.child(
container().child(
ThreadItem::new("ti-long-branch", "Overflow test with very long branch")
.icon(IconName::AiClaude)
.timestamp("2d")
.added(108)
.removed(53)
.worktrees(vec![ThreadItemWorktreeInfo {
name: "my-project".into(),
full_path: "/worktrees/my-project/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some(
"fix-very-long-branch-name-that-should-truncate".into(),
),
}]),
),
)
.child(section_label("Main branch + diff stats + timestamp"))
.child(
container().child(
ThreadItem::new("ti-main-full", "Main worktree with everything")
.icon(IconName::ZedAgent)
.timestamp("5m")
.added(23)
.removed(8)
.worktrees(vec![ThreadItemWorktreeInfo {
name: "zed".into(),
full_path: "/projects/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Main,
branch_name: Some("sidebar-show-branch-name".into()),
}]),
),
)
}
}
#[cfg(target_os = "macos")]
fn run_thread_item_branch_name_visual_tests(
_app_state: Arc<AppState>,
cx: &mut VisualTestAppContext,
update_baseline: bool,
) -> Result<TestResult> {
let window_size = size(px(400.0), px(1150.0));
let bounds = Bounds {
origin: point(px(0.0), px(0.0)),
size: window_size,
};
let window = cx
.update(|cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
focus: false,
show: false,
..Default::default()
},
|_window, cx| cx.new(|_| ThreadItemBranchNameTestView),
)
})
.context("Failed to open thread item branch name test window")?;
cx.run_until_parked();
cx.update_window(window.into(), |_, window, _cx| {
window.refresh();
})?;
cx.run_until_parked();
let test_result = run_visual_test(
"thread_item_branch_names",
window.into(),
cx,
update_baseline,
)?;
cx.update_window(window.into(), |_, window, _cx| {
window.remove_window();
})
.log_err();
cx.run_until_parked();
for _ in 0..15 {
cx.advance_clock(Duration::from_millis(100));
cx.run_until_parked();
}
Ok(test_result)
}
#[cfg(target_os = "macos")]
struct ThreadItemIconDecorationsTestView;