Detailed changes
@@ -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 {
@@ -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| {
@@ -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)
@@ -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"));
+}
@@ -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(),
@@ -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;