diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 719d3f4bef0abf28b8a5cca44a68a4d85f611776..44288fce95387ee39c02faec0f1d3374698cc279 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -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( + worktree_paths: &WorktreePaths, + branch_names: &std::collections::HashMap, +) -> Vec { + let mut infos: Vec = 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>>> { + 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) { 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>> { + 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> = 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 { diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 8e6adc4038ce4ece8cea0feea23cc865b7c71b86..69bf0761183ef3624130419564cf15c05c4a4e00 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -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, agent_server_store: WeakEntity, restoring: HashSet, + archived_thread_ids: HashSet, + archived_branch_names: HashMap>, + _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) { + let current_ids: HashSet = 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) { + 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.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 = 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| { diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 93e5d5b2b16f4724d4dba8b02a312e6507db73bf..1995af4cb4d2516e87deaf43c419f8fb2335595e 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -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, - 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, - worktrees: Vec, + worktrees: Vec, diff_stats: DiffStats, } @@ -336,69 +329,6 @@ fn workspace_path_list(workspace: &Entity, 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 { - let mut infos: Vec = 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 = all_paths.into_iter().zip(path_details).collect(); + let mut branch_by_path: HashMap = 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::::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::::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) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 10a6879c7bd9f8a0124c8571f251ba8098942b8e..fe12a58be4fb6381dddd18b1ddde95f33eb67b9d 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -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::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::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 = 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")); +} diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index c920f854081236d58b00d1ba197bd3805915cad4..7440c8391190d302f629e044bc3fbf187f6baf2f 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -29,6 +29,7 @@ pub struct ThreadItemWorktreeInfo { pub full_path: SharedString, pub highlight_positions: Vec, pub kind: WorktreeKind, + pub branch_name: Option, } #[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 = 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::>() - .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 = Vec::new(); - let mut worktree_labels: Vec = 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(), diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 58b911dbc2f1c8771cb54a7b27adcfe396457d72..ec93fe636620da65c42e637f3760c7779f1a045a 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -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, + ) -> 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, + cx: &mut VisualTestAppContext, + update_baseline: bool, +) -> Result { + 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;