mod thread_switcher; 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};use agent_ui::thread_worktree_archive;use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,};use agent_ui::{AcpThreadImportOnboarding, ThreadImportModal};use agent_ui::{ Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,};use chrono::{DateTime, Utc};use editor::Editor;use gpui::{ Action as _, AnyElement, App, Context, DismissEvent, Entity, FocusHandle, Focusable, KeyContext, ListState, Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px,};use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,};use project::{ AgentId, AgentRegistryStore, Event as ProjectEvent, ProjectGroupKey, linked_worktree_short_name,};use recent_projects::sidebar_recent_projects::SidebarRecentProjects;use remote::RemoteConnectionOptions;use ui::utils::platform_title_bar_height; use serde::{Deserialize, Serialize};use settings::Settings as _;use std::collections::{HashMap, HashSet};use std::mem;use std::path::PathBuf;use std::rc::Rc;use theme::ActiveTheme;use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*,};use util::ResultExt as _;use util::path_list::PathList;use workspace::{ AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject, NextThread, Open, PreviousProject, PreviousThread, SerializedProjectGroupKey, ShowFewerThreads, ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace, notifications::NotificationId, sidebar_side_context_menu,}; use zed_actions::OpenRecent;use zed_actions::editor::{MoveDown, MoveUp}; use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher}; use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent}; #[cfg(test)]mod sidebar_tests; gpui::actions!( agents_sidebar, [ /// Creates a new thread in the currently selected or active project group. NewThreadInGroup, /// Toggles between the thread list and the archive view. ToggleArchive, ]); gpui::actions!( dev, [ /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer. DumpWorkspaceInfo, ]); const DEFAULT_WIDTH: Pixels = px(300.0);const MIN_WIDTH: Pixels = px(200.0);const MAX_WIDTH: Pixels = px(800.0);const DEFAULT_THREADS_SHOWN: usize = 5; #[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]enum SerializedSidebarView { #[default] ThreadList, Archive,} #[derive(Default, Serialize, Deserialize)]struct SerializedSidebar { #[serde(default)] width: Option<f32>, #[serde(default)] collapsed_groups: Vec<SerializedProjectGroupKey>, #[serde(default)] expanded_groups: Vec<(SerializedProjectGroupKey, usize)>, #[serde(default)] active_view: SerializedSidebarView,} #[derive(Debug, Default)]enum SidebarView { #[default] ThreadList, Archive(Entity<ThreadsArchiveView>),} enum ArchiveWorktreeOutcome { Success, Cancelled,} #[derive(Clone, Debug)]enum ActiveEntry { Thread { session_id: acp::SessionId, workspace: Entity<Workspace>, }, Draft(Entity<Workspace>),} impl ActiveEntry { fn workspace(&self) -> &Entity<Workspace> { match self { ActiveEntry::Thread { workspace, .. } => workspace, ActiveEntry::Draft(workspace) => workspace, } } fn is_active_thread(&self, session_id: &acp::SessionId) -> bool { matches!(self, ActiveEntry::Thread { session_id: id, .. } if id == session_id) } fn matches_entry(&self, entry: &ListEntry) -> bool { match (self, entry) { (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => { thread.metadata.session_id == *session_id } ( ActiveEntry::Draft(_), ListEntry::DraftThread { workspace: None, .. }, ) => true, _ => false, } }} #[derive(Clone, Debug)]struct ActiveThreadInfo { session_id: acp::SessionId, title: SharedString, status: AgentThreadStatus, icon: IconName, icon_from_external_svg: Option<SharedString>, is_background: bool, is_title_generating: bool, diff_stats: DiffStats,} #[derive(Clone)]enum ThreadEntryWorkspace { Open(Entity<Workspace>), Closed { /// The paths this thread uses (may point to linked worktrees). folder_paths: PathList, /// The project group this thread belongs to. project_group_key: ProjectGroupKey, },} impl ThreadEntryWorkspace { fn is_remote(&self, cx: &App) -> bool { match self { ThreadEntryWorkspace::Open(workspace) => { !workspace.read(cx).project().read(cx).is_local() } ThreadEntryWorkspace::Closed { project_group_key, .. } => project_group_key.host().is_some(), } }} #[derive(Clone)]struct WorktreeInfo { name: SharedString, full_path: SharedString, highlight_positions: Vec<usize>, kind: ui::WorktreeKind,} #[derive(Clone)]struct ThreadEntry { metadata: ThreadMetadata, icon: IconName, icon_from_external_svg: Option<SharedString>, status: AgentThreadStatus, workspace: ThreadEntryWorkspace, is_live: bool, is_background: bool, is_title_generating: bool, highlight_positions: Vec<usize>, worktrees: Vec<WorktreeInfo>, diff_stats: DiffStats,} impl ThreadEntry { /// Updates this thread entry with active thread information. /// /// The existing [`ThreadEntry`] was likely deserialized from the database /// but if we have a correspond thread already loaded we want to apply the /// live information. fn apply_active_info(&mut self, info: &ActiveThreadInfo) { self.metadata.title = info.title.clone(); self.status = info.status; self.icon = info.icon; self.icon_from_external_svg = info.icon_from_external_svg.clone(); self.is_live = true; self.is_background = info.is_background; self.is_title_generating = info.is_title_generating; self.diff_stats = info.diff_stats; }} #[derive(Clone)]enum ListEntry { ProjectHeader { key: ProjectGroupKey, label: SharedString, highlight_positions: Vec<usize>, has_running_threads: bool, waiting_thread_count: usize, is_active: bool, has_threads: bool, }, Thread(ThreadEntry), ViewMore { key: ProjectGroupKey, is_fully_expanded: bool, }, /// The user's active draft thread. Shows a prefix of the currently-typed /// prompt, or "Untitled Thread" if the prompt is empty. DraftThread { key: project::ProjectGroupKey, workspace: Option<Entity<Workspace>>, worktrees: Vec<WorktreeInfo>, },} #[cfg(test)]impl ListEntry { fn session_id(&self) -> Option<&acp::SessionId> { match self { ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id), _ => None, } } fn reachable_workspaces<'a>( &'a self, multi_workspace: &'a workspace::MultiWorkspace, cx: &'a App, ) -> Vec<Entity<Workspace>> { match self { ListEntry::Thread(thread) => match &thread.workspace { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, ListEntry::DraftThread { workspace, .. } => { if let Some(ws) = workspace { vec![ws.clone()] } else { // workspace: None means this is the active draft, // which always lives on the current workspace. vec![multi_workspace.workspace().clone()] } } ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .cloned() .collect(), ListEntry::ViewMore { .. } => Vec::new(), } }} impl From<ThreadEntry> for ListEntry { fn from(thread: ThreadEntry) -> Self { ListEntry::Thread(thread) }} #[derive(Default)]struct SidebarContents { entries: Vec<ListEntry>, notified_threads: HashSet<acp::SessionId>, project_header_indices: Vec<usize>, has_open_projects: bool,} impl SidebarContents { fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool { self.notified_threads.contains(session_id) }} fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> { let mut positions = Vec::new(); let mut query_chars = query.chars().peekable(); for (byte_idx, candidate_char) in candidate.char_indices() { if let Some(&query_char) = query_chars.peek() { if candidate_char.eq_ignore_ascii_case(&query_char) { positions.push(byte_idx); query_chars.next(); } } else { break; } } if query_chars.peek().is_none() { Some(positions) } else { None }} // TODO: The mapping from workspace root paths to git repositories needs a// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,// thread persistence (which PathList is saved to the database), and thread// querying (which PathList is used to read threads back). All of these need// to agree on how repos are resolved for a given workspace, especially in// multi-root and nested-repo configurations.fn root_repository_snapshots( workspace: &Entity<Workspace>, cx: &App,) -> impl Iterator<Item = project::git_store::RepositorySnapshot> { let path_list = workspace_path_list(workspace, cx); let project = workspace.read(cx).project().read(cx); project.repositories(cx).values().filter_map(move |repo| { let snapshot = repo.read(cx).snapshot(); let is_root = path_list .paths() .iter() .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); is_root.then_some(snapshot) })} 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`).////// `linked_to_main` maps linked worktree abs paths to their main repo/// abs path, used to pick the correct prefix. Falls back to a heuristic/// when no mapping is available.fn worktree_info_from_thread_paths( folder_paths: &PathList, main_worktree_paths: &PathList, linked_to_main: &HashMap<PathBuf, PathBuf>,) -> Vec<WorktreeInfo> { let main_paths = main_worktree_paths.paths(); let mut infos: Vec<WorktreeInfo> = Vec::new(); let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new(); for path in folder_paths.paths().iter() { let is_main = main_paths.iter().any(|mp| mp.as_path() == path.as_path()); if is_main { let Some(name) = path.file_name() else { continue; }; infos.push(WorktreeInfo { name: SharedString::from(name.to_string_lossy().to_string()), full_path: SharedString::from(path.display().to_string()), highlight_positions: Vec::new(), kind: ui::WorktreeKind::Main, }); } else { let Some(main_path) = linked_to_main .get(&**path) .and_then(|main| main_paths.iter().find(|mp| mp.as_path() == main.as_path())) .or_else(|| { main_paths .iter() .find(|mp| mp.file_name() == path.file_name()) .or(main_paths.first()) }) else { continue; }; let short_name = linked_worktree_short_name(main_path, 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(path.display().to_string()), highlight_positions: Vec::new(), kind: ui::WorktreeKind::Linked, }); } } // 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 main_paths.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`/// argument.fn connect_remote( modal_workspace: Entity<Workspace>, connection_options: RemoteConnectionOptions, window: &mut Window, cx: &mut Context<MultiWorkspace>,) -> gpui::Task<anyhow::Result<Option<Entity<remote::RemoteClient>>>> { remote_connection::connect_with_modal(&modal_workspace, connection_options, window, cx)} /// 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/// be computed from the current world state, compute it in the rebuild.pub struct Sidebar { multi_workspace: WeakEntity<MultiWorkspace>, width: Pixels, focus_handle: FocusHandle, filter_editor: Entity<Editor>, list_state: ListState, contents: SidebarContents, /// The index of the list item that currently has the keyboard focus /// /// Note: This is NOT the same as the active item. selection: Option<usize>, /// Tracks which sidebar entry is currently active (highlighted). active_entry: Option<ActiveEntry>, hovered_thread_index: Option<usize>, collapsed_groups: HashSet<ProjectGroupKey>, expanded_groups: HashMap<ProjectGroupKey, usize>, /// Updated only in response to explicit user actions (clicking a /// thread, confirming in the thread switcher, etc.) — never from /// background data changes. Used to sort the thread switcher popup. thread_last_accessed: HashMap<acp::SessionId, DateTime<Utc>>, /// Updated when the user presses a key to send or queue a message. /// Used for sorting threads in the sidebar and as a secondary sort /// key in the thread switcher. thread_last_message_sent_or_queued: HashMap<acp::SessionId, DateTime<Utc>>, thread_switcher: Option<Entity<ThreadSwitcher>>, _thread_switcher_subscriptions: Vec<gpui::Subscription>, pending_remote_thread_activation: Option<acp::SessionId>, view: SidebarView, recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>, project_header_menu_ix: Option<usize>, _subscriptions: Vec<gpui::Subscription>, _draft_observation: Option<gpui::Subscription>,} impl Sidebar { pub fn new( multi_workspace: Entity<MultiWorkspace>, window: &mut Window, cx: &mut Context<Self>, ) -> Self { let focus_handle = cx.focus_handle();Lines 1–500 of 4937. Load more
mod thread_switcher; 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}; use agent_ui::thread_worktree_archive; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; use agent_ui::{AcpThreadImportOnboarding, ThreadImportModal}; use agent_ui::{ Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, }; use chrono::{DateTime, Utc}; use editor::Editor; use gpui::{ Action as _, AnyElement, App, Context, DismissEvent, Entity, FocusHandle, Focusable, KeyContext, ListState, Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, }; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; use project::{ AgentId, AgentRegistryStore, Event as ProjectEvent, ProjectGroupKey, linked_worktree_short_name, }; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use remote::RemoteConnectionOptions; use ui::utils::platform_title_bar_height; use serde::{Deserialize, Serialize}; use settings::Settings as _; use std::collections::{HashMap, HashSet}; use std::mem; use std::path::PathBuf; use std::rc::Rc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; use workspace::{ AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject, NextThread, Open, PreviousProject, PreviousThread, SerializedProjectGroupKey, ShowFewerThreads, ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace, notifications::NotificationId, sidebar_side_context_menu, }; use zed_actions::OpenRecent; use zed_actions::editor::{MoveDown, MoveUp}; use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher}; use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent}; #[cfg(test)] mod sidebar_tests; gpui::actions!( agents_sidebar, [ /// Creates a new thread in the currently selected or active project group. NewThreadInGroup, /// Toggles between the thread list and the archive view. ToggleArchive, ] ); gpui::actions!( dev, [ /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer. DumpWorkspaceInfo, ] ); const DEFAULT_WIDTH: Pixels = px(300.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; #[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum SerializedSidebarView { #[default] ThreadList, Archive, } #[derive(Default, Serialize, Deserialize)] struct SerializedSidebar { #[serde(default)] width: Option<f32>, #[serde(default)] collapsed_groups: Vec<SerializedProjectGroupKey>, #[serde(default)] expanded_groups: Vec<(SerializedProjectGroupKey, usize)>, #[serde(default)] active_view: SerializedSidebarView, } #[derive(Debug, Default)] enum SidebarView { #[default] ThreadList, Archive(Entity<ThreadsArchiveView>), } enum ArchiveWorktreeOutcome { Success, Cancelled, } #[derive(Clone, Debug)] enum ActiveEntry { Thread { session_id: acp::SessionId, workspace: Entity<Workspace>, }, Draft(Entity<Workspace>), } impl ActiveEntry { fn workspace(&self) -> &Entity<Workspace> { match self { ActiveEntry::Thread { workspace, .. } => workspace, ActiveEntry::Draft(workspace) => workspace, } } fn is_active_thread(&self, session_id: &acp::SessionId) -> bool { matches!(self, ActiveEntry::Thread { session_id: id, .. } if id == session_id) } fn matches_entry(&self, entry: &ListEntry) -> bool { match (self, entry) { (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => { thread.metadata.session_id == *session_id } ( ActiveEntry::Draft(_), ListEntry::DraftThread { workspace: None, .. }, ) => true, _ => false, } } } #[derive(Clone, Debug)] struct ActiveThreadInfo { session_id: acp::SessionId, title: SharedString, status: AgentThreadStatus, icon: IconName, icon_from_external_svg: Option<SharedString>, is_background: bool, is_title_generating: bool, diff_stats: DiffStats, } #[derive(Clone)] enum ThreadEntryWorkspace { Open(Entity<Workspace>), Closed { /// The paths this thread uses (may point to linked worktrees). folder_paths: PathList, /// The project group this thread belongs to. project_group_key: ProjectGroupKey, }, } impl ThreadEntryWorkspace { fn is_remote(&self, cx: &App) -> bool { match self { ThreadEntryWorkspace::Open(workspace) => { !workspace.read(cx).project().read(cx).is_local() } ThreadEntryWorkspace::Closed { project_group_key, .. } => project_group_key.host().is_some(), } } } #[derive(Clone)] struct WorktreeInfo { name: SharedString, full_path: SharedString, highlight_positions: Vec<usize>, kind: ui::WorktreeKind, } #[derive(Clone)] struct ThreadEntry { metadata: ThreadMetadata, icon: IconName, icon_from_external_svg: Option<SharedString>, status: AgentThreadStatus, workspace: ThreadEntryWorkspace, is_live: bool, is_background: bool, is_title_generating: bool, highlight_positions: Vec<usize>, worktrees: Vec<WorktreeInfo>, diff_stats: DiffStats, } impl ThreadEntry { /// Updates this thread entry with active thread information. /// /// The existing [`ThreadEntry`] was likely deserialized from the database /// but if we have a correspond thread already loaded we want to apply the /// live information. fn apply_active_info(&mut self, info: &ActiveThreadInfo) { self.metadata.title = info.title.clone(); self.status = info.status; self.icon = info.icon; self.icon_from_external_svg = info.icon_from_external_svg.clone(); self.is_live = true; self.is_background = info.is_background; self.is_title_generating = info.is_title_generating; self.diff_stats = info.diff_stats; } } #[derive(Clone)] enum ListEntry { ProjectHeader { key: ProjectGroupKey, label: SharedString, highlight_positions: Vec<usize>, has_running_threads: bool, waiting_thread_count: usize, is_active: bool, has_threads: bool, }, Thread(ThreadEntry), ViewMore { key: ProjectGroupKey, is_fully_expanded: bool, }, /// The user's active draft thread. Shows a prefix of the currently-typed /// prompt, or "Untitled Thread" if the prompt is empty. DraftThread { key: project::ProjectGroupKey, workspace: Option<Entity<Workspace>>, worktrees: Vec<WorktreeInfo>, }, } #[cfg(test)] impl ListEntry { fn session_id(&self) -> Option<&acp::SessionId> { match self { ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id), _ => None, } } fn reachable_workspaces<'a>( &'a self, multi_workspace: &'a workspace::MultiWorkspace, cx: &'a App, ) -> Vec<Entity<Workspace>> { match self { ListEntry::Thread(thread) => match &thread.workspace { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, ListEntry::DraftThread { workspace, .. } => { if let Some(ws) = workspace { vec![ws.clone()] } else { // workspace: None means this is the active draft, // which always lives on the current workspace. vec![multi_workspace.workspace().clone()] } } ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .cloned() .collect(), ListEntry::ViewMore { .. } => Vec::new(), } } } impl From<ThreadEntry> for ListEntry { fn from(thread: ThreadEntry) -> Self { ListEntry::Thread(thread) } } #[derive(Default)] struct SidebarContents { entries: Vec<ListEntry>, notified_threads: HashSet<acp::SessionId>, project_header_indices: Vec<usize>, has_open_projects: bool, } impl SidebarContents { fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool { self.notified_threads.contains(session_id) } } fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> { let mut positions = Vec::new(); let mut query_chars = query.chars().peekable(); for (byte_idx, candidate_char) in candidate.char_indices() { if let Some(&query_char) = query_chars.peek() { if candidate_char.eq_ignore_ascii_case(&query_char) { positions.push(byte_idx); query_chars.next(); } } else { break; } } if query_chars.peek().is_none() { Some(positions) } else { None } } // TODO: The mapping from workspace root paths to git repositories needs a // unified approach across the codebase: this function, `AgentPanel::classify_worktrees`, // thread persistence (which PathList is saved to the database), and thread // querying (which PathList is used to read threads back). All of these need // to agree on how repos are resolved for a given workspace, especially in // multi-root and nested-repo configurations. fn root_repository_snapshots( workspace: &Entity<Workspace>, cx: &App, ) -> impl Iterator<Item = project::git_store::RepositorySnapshot> { let path_list = workspace_path_list(workspace, cx); let project = workspace.read(cx).project().read(cx); project.repositories(cx).values().filter_map(move |repo| { let snapshot = repo.read(cx).snapshot(); let is_root = path_list .paths() .iter() .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); is_root.then_some(snapshot) }) } 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`). /// /// `linked_to_main` maps linked worktree abs paths to their main repo /// abs path, used to pick the correct prefix. Falls back to a heuristic /// when no mapping is available. fn worktree_info_from_thread_paths( folder_paths: &PathList, main_worktree_paths: &PathList, linked_to_main: &HashMap<PathBuf, PathBuf>, ) -> Vec<WorktreeInfo> { let main_paths = main_worktree_paths.paths(); let mut infos: Vec<WorktreeInfo> = Vec::new(); let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new(); for path in folder_paths.paths().iter() { let is_main = main_paths.iter().any(|mp| mp.as_path() == path.as_path()); if is_main { let Some(name) = path.file_name() else { continue; }; infos.push(WorktreeInfo { name: SharedString::from(name.to_string_lossy().to_string()), full_path: SharedString::from(path.display().to_string()), highlight_positions: Vec::new(), kind: ui::WorktreeKind::Main, }); } else { let Some(main_path) = linked_to_main .get(&**path) .and_then(|main| main_paths.iter().find(|mp| mp.as_path() == main.as_path())) .or_else(|| { main_paths .iter() .find(|mp| mp.file_name() == path.file_name()) .or(main_paths.first()) }) else { continue; }; let short_name = linked_worktree_short_name(main_path, 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(path.display().to_string()), highlight_positions: Vec::new(), kind: ui::WorktreeKind::Linked, }); } } // 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 main_paths.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` /// argument. fn connect_remote( modal_workspace: Entity<Workspace>, connection_options: RemoteConnectionOptions, window: &mut Window, cx: &mut Context<MultiWorkspace>, ) -> gpui::Task<anyhow::Result<Option<Entity<remote::RemoteClient>>>> { remote_connection::connect_with_modal(&modal_workspace, connection_options, window, cx) } /// 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 /// be computed from the current world state, compute it in the rebuild. pub struct Sidebar { multi_workspace: WeakEntity<MultiWorkspace>, width: Pixels, focus_handle: FocusHandle, filter_editor: Entity<Editor>, list_state: ListState, contents: SidebarContents, /// The index of the list item that currently has the keyboard focus /// /// Note: This is NOT the same as the active item. selection: Option<usize>, /// Tracks which sidebar entry is currently active (highlighted). active_entry: Option<ActiveEntry>, hovered_thread_index: Option<usize>, collapsed_groups: HashSet<ProjectGroupKey>, expanded_groups: HashMap<ProjectGroupKey, usize>, /// Updated only in response to explicit user actions (clicking a /// thread, confirming in the thread switcher, etc.) — never from /// background data changes. Used to sort the thread switcher popup. thread_last_accessed: HashMap<acp::SessionId, DateTime<Utc>>, /// Updated when the user presses a key to send or queue a message. /// Used for sorting threads in the sidebar and as a secondary sort /// key in the thread switcher. thread_last_message_sent_or_queued: HashMap<acp::SessionId, DateTime<Utc>>, thread_switcher: Option<Entity<ThreadSwitcher>>, _thread_switcher_subscriptions: Vec<gpui::Subscription>, pending_remote_thread_activation: Option<acp::SessionId>, view: SidebarView, recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>, project_header_menu_ix: Option<usize>, _subscriptions: Vec<gpui::Subscription>, _draft_observation: Option<gpui::Subscription>, } impl Sidebar { pub fn new( multi_workspace: Entity<MultiWorkspace>, window: &mut Window, cx: &mut Context<Self>, ) -> Self { let focus_handle = cx.focus_handle();