diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index bab0060186a7cae2d79aaead61946a15bf109a5a..2443a719639c2dc4b83b26d1213db214e13d70f7 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -166,24 +166,26 @@ impl ProjectGroupBuilder { .unwrap_or(path) } - /// Whether the given group should load threads for a linked worktree at - /// `worktree_path`. Returns `false` if the worktree already has an open - /// workspace in the group (its threads are loaded via the workspace loop) - /// or if the worktree's canonical path list doesn't match `group_path_list`. + /// Whether the given group should load threads for a linked worktree + /// at `worktree_path`. Returns `false` if the worktree already has an + /// open workspace in the group (its threads are loaded via the + /// workspace loop) or if the worktree's canonical path list doesn't + /// match `group_path_list`. pub fn group_owns_worktree( &self, group: &ProjectGroup, group_path_list: &PathList, worktree_path: &Path, ) -> bool { - let worktree_arc: Arc = Arc::from(worktree_path); - if group.covered_paths.contains(&worktree_arc) { + if group.covered_paths.contains(worktree_path) { return false; } let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path])); canonical == *group_path_list } + /// Canonicalizes every path in a [`PathList`] using the builder's + /// directory mappings. fn canonicalize_path_list(&self, path_list: &PathList) -> PathList { let paths: Vec<_> = path_list .paths() diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4ec50a1f52840930e61c23e5463878a173cfaa89..a5c26a0b3c56c3ad3483d5f445daa87d3cd8ae6f 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -30,7 +30,8 @@ use std::rc::Rc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, - PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, + PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; @@ -102,6 +103,13 @@ enum ThreadEntryWorkspace { Closed(PathList), } +#[derive(Clone)] +struct WorktreeInfo { + name: SharedString, + full_path: SharedString, + highlight_positions: Vec, +} + #[derive(Clone)] struct ThreadEntry { agent: Agent, @@ -114,9 +122,7 @@ struct ThreadEntry { is_background: bool, is_title_generating: bool, highlight_positions: Vec, - worktree_name: Option, - worktree_full_path: Option, - worktree_highlight_positions: Vec, + worktrees: Vec, diff_stats: DiffStats, } @@ -229,6 +235,33 @@ 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` that canonicalizes to a +/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`] +/// with the short worktree name and full path. +fn worktree_info_from_thread_paths( + folder_paths: &PathList, + project_groups: &ProjectGroupBuilder, +) -> Vec { + folder_paths + .paths() + .iter() + .filter_map(|path| { + let canonical = project_groups.canonicalize_path(path); + if canonical != path.as_path() { + Some(WorktreeInfo { + name: linked_worktree_short_name(canonical, path).unwrap_or_default(), + full_path: SharedString::from(path.display().to_string()), + highlight_positions: Vec::new(), + }) + } else { + None + } + }) + .collect() +} + /// The sidebar re-derives its entire entry list from scratch on every /// change via `update_entries` → `rebuild_contents`. Avoid adding /// incremental or inter-event coordination state — if something can @@ -693,39 +726,21 @@ impl Sidebar { for workspace in &group.workspaces { let ws_path_list = workspace_path_list(workspace, cx); - // Determine if this workspace covers a git worktree (its - // path canonicalizes to the main repo, not itself). If so, - // threads from it get a worktree chip in the sidebar. - let worktree_info: Option<(SharedString, SharedString)> = - ws_path_list.paths().first().and_then(|path| { - let canonical = project_groups.canonicalize_path(path); - if canonical != path.as_path() { - let name = - linked_worktree_short_name(canonical, path).unwrap_or_default(); - let full_path: SharedString = path.display().to_string().into(); - Some((name, full_path)) - } else { - None - } - }); - - let workspace_threads: Vec<_> = thread_store - .read(cx) - .entries_for_path(&ws_path_list) - .collect(); - for thread in workspace_threads { - if !seen_session_ids.insert(thread.session_id.clone()) { + for row in thread_store.read(cx).entries_for_path(&ws_path_list) { + if !seen_session_ids.insert(row.session_id.clone()) { continue; } - let (agent, icon, icon_from_external_svg) = resolve_agent(&thread); + let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + let worktrees = + worktree_info_from_thread_paths(&row.folder_paths, &project_groups); threads.push(ThreadEntry { agent, session_info: acp_thread::AgentSessionInfo { - session_id: thread.session_id.clone(), + session_id: row.session_id.clone(), work_dirs: None, - title: Some(thread.title.clone()), - updated_at: Some(thread.updated_at), - created_at: thread.created_at, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, meta: None, }, icon, @@ -736,20 +751,15 @@ impl Sidebar { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()), - worktree_full_path: worktree_info - .as_ref() - .map(|(_, path)| path.clone()), - worktree_highlight_positions: Vec::new(), + worktrees, diff_stats: DiffStats::default(), }); } } - // Load threads from linked git worktrees that don't have an - // open workspace in this group. Only include worktrees that - // belong to this group (not shared with another group). - let linked_worktree_path_lists = group + // Load threads from linked git worktrees whose + // canonical paths belong to this group. + let linked_worktree_queries = group .workspaces .iter() .flat_map(|ws| root_repository_snapshots(ws, cx)) @@ -765,23 +775,14 @@ impl Sidebar { .collect::>() }); - for worktree_path_list in linked_worktree_path_lists { + for worktree_path_list in linked_worktree_queries { for row in thread_store.read(cx).entries_for_path(&worktree_path_list) { if !seen_session_ids.insert(row.session_id.clone()) { continue; } - let worktree_info = row.folder_paths.paths().first().and_then(|path| { - let canonical = project_groups.canonicalize_path(path); - if canonical != path.as_path() { - let name = - linked_worktree_short_name(canonical, path).unwrap_or_default(); - let full_path: SharedString = path.display().to_string().into(); - Some((name, full_path)) - } else { - None - } - }); let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + let worktrees = + worktree_info_from_thread_paths(&row.folder_paths, &project_groups); threads.push(ThreadEntry { agent, session_info: acp_thread::AgentSessionInfo { @@ -795,14 +796,12 @@ impl Sidebar { icon, icon_from_external_svg, status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Closed(row.folder_paths.clone()), + workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()), is_live: false, is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()), - worktree_full_path: worktree_info.map(|(_, path)| path), - worktree_highlight_positions: Vec::new(), + worktrees, diff_stats: DiffStats::default(), }); } @@ -882,12 +881,13 @@ impl Sidebar { if let Some(positions) = fuzzy_match_positions(&query, title) { thread.highlight_positions = positions; } - if let Some(worktree_name) = &thread.worktree_name { - if let Some(positions) = fuzzy_match_positions(&query, worktree_name) { - thread.worktree_highlight_positions = positions; + let mut worktree_matched = false; + for worktree in &mut thread.worktrees { + if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) { + worktree.highlight_positions = positions; + worktree_matched = true; } } - let worktree_matched = !thread.worktree_highlight_positions.is_empty(); if workspace_matched || !thread.highlight_positions.is_empty() || worktree_matched @@ -2437,14 +2437,17 @@ impl Sidebar { .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) - .when_some(thread.worktree_name.clone(), |this, name| { - let this = this.worktree(name); - match thread.worktree_full_path.clone() { - Some(path) => this.worktree_full_path(path), - None => this, - } - }) - .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) + .worktrees( + thread + .worktrees + .iter() + .map(|wt| ThreadItemWorktreeInfo { + name: wt.name.clone(), + full_path: wt.full_path.clone(), + highlight_positions: wt.highlight_positions.clone(), + }) + .collect(), + ) .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .title_generating(thread.is_title_generating) @@ -3400,11 +3403,19 @@ mod tests { } else { "" }; - let worktree = thread - .worktree_name - .as_ref() - .map(|name| format!(" {{{}}}", name)) - .unwrap_or_default(); + let worktree = if thread.worktrees.is_empty() { + String::new() + } else { + let mut seen = Vec::new(); + let mut chips = Vec::new(); + for wt in &thread.worktrees { + if !seen.contains(&wt.name) { + seen.push(wt.name.clone()); + chips.push(format!("{{{}}}", wt.name)); + } + } + format!(" {}", chips.join(", ")) + }; format!( " {}{}{}{}{}{}", title, worktree, active, status_str, notified, selected @@ -3777,9 +3788,7 @@ mod tests { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Running status @@ -3801,9 +3810,7 @@ mod tests { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Error status @@ -3825,9 +3832,7 @@ mod tests { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // Thread with WaitingForConfirmation status, not active @@ -3849,9 +3854,7 @@ mod tests { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // Background thread that completed (should show notification) @@ -3873,9 +3876,7 @@ mod tests { is_background: true, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // View More entry @@ -5817,6 +5818,227 @@ mod tests { ); } + #[gpui::test] + async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) { + // A thread created in a workspace with roots from different git + // worktrees should show a chip for each distinct worktree name. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two main repos. + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkouts. + for (repo, branch) in &[ + ("project_a", "olivetti"), + ("project_a", "selectric"), + ("project_b", "olivetti"), + ("project_b", "selectric"), + ] { + let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + } + + // Register linked worktrees. + for repo in &["project_a", "project_b"] { + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + for branch in &["olivetti", "selectric"] { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!( + "/worktrees/{repo}/{branch}/{repo}" + )), + ref_name: Some(format!("refs/heads/{branch}").into()), + sha: "aaa".into(), + }); + } + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open a workspace with the worktree checkout paths as roots + // (this is the workspace the thread was created in). + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/selectric/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the same paths as the workspace roots. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show two distinct worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Cross Worktree Thread {olivetti}, {selectric}", + ] + ); + } + + #[gpui::test] + async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) { + // When a thread's roots span multiple repos but share the same + // worktree name (e.g. both in "olivetti"), only one chip should + // appear. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + + for repo in &["project_a", "project_b"] { + let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), + ref_name: Some("refs/heads/olivetti".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/olivetti/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Thread with roots in both repos' "olivetti" worktrees. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Both worktree paths have the name "olivetti", so only one chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Same Branch Thread {olivetti}", + ] + ); + } + #[gpui::test] async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { // When a worktree workspace is absorbed under the main repo, a @@ -6251,7 +6473,7 @@ mod tests { .as_ref() .map(|title| title.as_ref()) == Some("WT Thread") - && thread.worktree_name.as_ref().map(|name| name.as_ref()) + && thread.worktrees.first().map(|wt| wt.name.as_ref()) == Some("wt-feature-a") => { saw_expected_thread = true; @@ -6264,9 +6486,9 @@ mod tests { .map(|title| title.as_ref()) .unwrap_or("Untitled"); let worktree_name = thread - .worktree_name - .as_ref() - .map(|name| name.as_ref()) + .worktrees + .first() + .map(|wt| wt.name.as_ref()) .unwrap_or(""); panic!( "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" @@ -7070,6 +7292,7 @@ mod tests { init_test(cx); let fs = FakeFs::new(cx.executor()); + // Two independent repos, each with their own git history. fs.insert_tree( "/project", serde_json::json!({ @@ -7102,6 +7325,7 @@ mod tests { ) .await; + // Register the linked worktree in the main repo. fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { state.worktrees.push(git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), @@ -7113,11 +7337,13 @@ mod tests { cx.update(|cx| ::set_global(fs.clone(), cx)); + // Workspace 1: just /project. let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; project_only .update(cx, |p, cx| p.git_scans_complete(cx)) .await; + // Workspace 2: /other and /project together (multi-root). let multi_root = project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; multi_root @@ -7132,12 +7358,15 @@ mod tests { }); let sidebar = setup_sidebar(&multi_workspace, cx); + // Save a thread under the linked worktree path. let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); + // The thread should appear only under [project] (the dedicated + // group for the /project repo), not under [other, project]. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 95244a382b988380339d649473c35fcac66f6d7a..b20692740cb1399a562d160c80297a076f7d4516 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -18,6 +18,13 @@ pub enum AgentThreadStatus { Error, } +#[derive(Clone)] +pub struct ThreadItemWorktreeInfo { + pub name: SharedString, + pub full_path: SharedString, + pub highlight_positions: Vec, +} + #[derive(IntoElement, RegisterComponent)] pub struct ThreadItem { id: ElementId, @@ -37,9 +44,7 @@ pub struct ThreadItem { hovered: bool, added: Option, removed: Option, - worktree: Option, - worktree_full_path: Option, - worktree_highlight_positions: Vec, + worktrees: Vec, on_click: Option>, on_hover: Box, action_slot: Option, @@ -66,9 +71,7 @@ impl ThreadItem { hovered: false, added: None, removed: None, - worktree: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), on_click: None, on_hover: Box::new(|_, _, _| {}), action_slot: None, @@ -146,18 +149,8 @@ impl ThreadItem { self } - pub fn worktree(mut self, worktree: impl Into) -> Self { - self.worktree = Some(worktree.into()); - self - } - - pub fn worktree_full_path(mut self, worktree_full_path: impl Into) -> Self { - self.worktree_full_path = Some(worktree_full_path.into()); - self - } - - pub fn worktree_highlight_positions(mut self, positions: Vec) -> Self { - self.worktree_highlight_positions = positions; + pub fn worktrees(mut self, worktrees: Vec) -> Self { + self.worktrees = worktrees; self } @@ -319,7 +312,7 @@ impl RenderOnce for ThreadItem { let added_count = self.added.unwrap_or(0); let removed_count = self.removed.unwrap_or(0); - let has_worktree = self.worktree.is_some(); + let has_worktree = !self.worktrees.is_empty(); let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; @@ -376,48 +369,67 @@ impl RenderOnce for ThreadItem { }), ) .when(has_worktree || has_diff_stats || has_timestamp, |this| { - let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default(); - let worktree_label = self.worktree.map(|worktree| { - let positions = self.worktree_highlight_positions; - if positions.is_empty() { - Label::new(worktree) + // 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 = if self.worktrees.len() > 1 { + "Thread Running in Local Git Worktrees" + } else { + "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_chips: Vec = Vec::new(); + for wt in self.worktrees { + if seen_names.contains(&wt.name) { + 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(worktree, positions) + 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_chips.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(worktree_label, |this, label| { - this.child( - h_flex() - .id(format!("{}-worktree", self.id.clone())) - .gap_0p5() - .child( - Icon::new(IconName::GitWorktree) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(label) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "Thread Running in a Local Git Worktree", - None, - worktree_full_path.clone(), - cx, - ) - }), - ) - }) + .children(worktree_chips) .when(has_worktree && (has_diff_stats || has_timestamp), |this| { this.child(dot_separator()) }) @@ -526,7 +538,11 @@ impl Component for ThreadItem { ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) .timestamp("2w") - .worktree("link-agent-panel"), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "link-agent-panel".into(), + full_path: "link-agent-panel".into(), + highlight_positions: Vec::new(), + }]), ) .into_any_element(), ), @@ -548,7 +564,11 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-5b", "Full metadata example") .icon(IconName::AiClaude) - .worktree("my-project") + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-project".into(), + full_path: "my-project".into(), + highlight_positions: Vec::new(), + }]) .added(42) .removed(17) .timestamp("3w"), @@ -623,8 +643,11 @@ impl Component for ThreadItem { ThreadItem::new("ti-11", "Search in worktree name") .icon(IconName::AiClaude) .timestamp("3mo") - .worktree("my-project-name") - .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-project-name".into(), + full_path: "my-project-name".into(), + highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11], + }]), ) .into_any_element(), ),