use acp_thread::ThreadStatus;use agent_ui::{AgentPanel, AgentPanelEvent};use chrono::{Datelike, Local, NaiveDate, TimeDelta};use db::kvp::KEY_VALUE_STORE; use fs::Fs;use fuzzy::StringMatchCandidate;use gpui::{ App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, Subscription, Task, Window, px,};use picker::{Picker, PickerDelegate};use project::Event as ProjectEvent;use recent_projects::{RecentProjectEntry, get_recent_projects};use std::fmt::Display; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf};use std::sync::Arc;use theme::ActiveTheme;use ui::utils::TRAFFIC_LIGHT_PADDING;use ui::{ AgentThreadStatus, Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip, prelude::*,};use ui_input::ErasedEditor;use util::ResultExt as _;use workspace::{ FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar, SidebarEvent, ToggleWorkspaceSidebar, Workspace,}; #[derive(Clone, Debug)]struct AgentThreadInfo { title: SharedString, status: AgentThreadStatus, icon: IconName,} const LAST_THREAD_TITLES_KEY: &str = "sidebar-last-thread-titles"; const DEFAULT_WIDTH: Pixels = px(320.0);const MIN_WIDTH: Pixels = px(200.0);const MAX_WIDTH: Pixels = px(800.0);const MAX_MATCHES: usize = 100; #[derive(Clone)]struct WorkspaceThreadEntry { index: usize, worktree_label: SharedString, full_path: SharedString, thread_info: Option<AgentThreadInfo>,} impl WorkspaceThreadEntry { fn new( index: usize, workspace: &Entity<Workspace>, persisted_titles: &HashMap<String, String>, cx: &App, ) -> Self { let workspace_ref = workspace.read(cx); let worktrees: Vec<_> = workspace_ref .worktrees(cx) .filter(|worktree| worktree.read(cx).is_visible()) .map(|worktree| worktree.read(cx).abs_path()) .collect(); let worktree_names: Vec<String> = worktrees .iter() .filter_map(|path| { path.file_name() .map(|name| name.to_string_lossy().to_string()) }) .collect(); let worktree_label: SharedString = if worktree_names.is_empty() { format!("Workspace {}", index + 1).into() } else { worktree_names.join(", ").into() }; let full_path: SharedString = worktrees .iter() .map(|path| path.to_string_lossy().to_string()) .collect::<Vec<_>>() .join("\n") .into(); let thread_info = Self::thread_info(workspace, cx).or_else(|| { if worktrees.is_empty() { return None; } let path_key = sorted_paths_key(&worktrees); let title = persisted_titles.get(&path_key)?; Some(AgentThreadInfo { title: SharedString::from(title.clone()), status: AgentThreadStatus::Completed, icon: IconName::ZedAgent, }) }); Self { index, worktree_label, full_path, thread_info, } } fn thread_info(workspace: &Entity<Workspace>, cx: &App) -> Option<AgentThreadInfo> { let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?; let agent_panel_ref = agent_panel.read(cx); let thread_view = agent_panel_ref.as_active_thread_view(cx)?.read(cx); let thread = thread_view.thread.read(cx); let icon = thread_view.agent_icon; let title = thread.title(); let status = if thread.is_waiting_for_confirmation() { AgentThreadStatus::WaitingForConfirmation } else if thread.had_error() { AgentThreadStatus::Error } else { match thread.status() { ThreadStatus::Generating => AgentThreadStatus::Running, ThreadStatus::Idle => AgentThreadStatus::Completed, } }; Some(AgentThreadInfo { title, status, icon, }) }} #[derive(Clone)]enum SidebarEntry { Separator(SharedString), WorkspaceThread(WorkspaceThreadEntry), RecentProject(RecentProjectEntry),} impl SidebarEntry { fn searchable_text(&self) -> &str { match self { SidebarEntry::Separator(_) => "", SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(), SidebarEntry::RecentProject(entry) => entry.name.as_ref(), } }} #[derive(Clone)]struct SidebarMatch { entry: SidebarEntry, positions: Vec<usize>,} struct WorkspacePickerDelegate { multi_workspace: Entity<MultiWorkspace>, entries: Vec<SidebarEntry>, active_workspace_index: usize, workspace_thread_count: usize, /// All recent projects including what's filtered out of entries /// used to add unopened projects to entries on rebuild recent_projects: Vec<RecentProjectEntry>, recent_project_thread_titles: HashMap<SharedString, SharedString>, matches: Vec<SidebarMatch>, selected_index: usize, query: String, hovered_thread_item: Option<usize>, notified_workspaces: HashSet<usize>,} impl WorkspacePickerDelegate { fn new(multi_workspace: Entity<MultiWorkspace>) -> Self { Self { multi_workspace, entries: Vec::new(), active_workspace_index: 0, workspace_thread_count: 0, recent_projects: Vec::new(), recent_project_thread_titles: HashMap::new(), matches: Vec::new(), selected_index: 0, query: String::new(), hovered_thread_item: None, notified_workspaces: HashSet::new(), } } fn set_entries( &mut self, workspace_threads: Vec<WorkspaceThreadEntry>, active_workspace_index: usize, cx: &App, ) { if let Some(hovered_index) = self.hovered_thread_item { let still_exists = workspace_threads .iter() .any(|thread| thread.index == hovered_index); if !still_exists { self.hovered_thread_item = None; } } let old_statuses: HashMap<usize, AgentThreadStatus> = self .entries .iter() .filter_map(|entry| match entry { SidebarEntry::WorkspaceThread(thread) => thread .thread_info .as_ref() .map(|info| (thread.index, info.status)), _ => None, }) .collect(); for thread in &workspace_threads { if let Some(info) = &thread.thread_info { if info.status == AgentThreadStatus::Completed && thread.index != active_workspace_index { if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) { self.notified_workspaces.insert(thread.index); } } } } if self.active_workspace_index != active_workspace_index { self.notified_workspaces.remove(&active_workspace_index); } self.active_workspace_index = active_workspace_index; self.workspace_thread_count = workspace_threads.len(); self.rebuild_entries(workspace_threads, cx); } fn set_recent_projects(&mut self, recent_projects: Vec<RecentProjectEntry>, cx: &App) { self.recent_project_thread_titles.clear(); if let Some(map) = read_thread_title_map() { for entry in &recent_projects { let path_key = sorted_paths_key(&entry.paths); if let Some(title) = map.get(&path_key) { self.recent_project_thread_titles .insert(entry.full_path.clone(), title.clone().into()); } } } self.recent_projects = recent_projects; let workspace_threads: Vec<WorkspaceThreadEntry> = self .entries .iter() .filter_map(|entry| match entry { SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()), _ => None, }) .collect(); self.rebuild_entries(workspace_threads, cx); } fn open_workspace_path_sets(&self, cx: &App) -> Vec<Vec<Arc<Path>>> { self.multi_workspace .read(cx) .workspaces() .iter() .map(|workspace| { let mut paths = workspace.read(cx).root_paths(cx); paths.sort(); paths }) .collect() } fn rebuild_entries(&mut self, workspace_threads: Vec<WorkspaceThreadEntry>, cx: &App) { let open_path_sets = self.open_workspace_path_sets(cx); self.entries.clear(); if !workspace_threads.is_empty() { self.entries .push(SidebarEntry::Separator("Active Workspaces".into())); for thread in workspace_threads { self.entries.push(SidebarEntry::WorkspaceThread(thread)); } } let recent: Vec<_> = self .recent_projects .iter() .filter(|project| { let mut project_paths: Vec<&Path> = project.paths.iter().map(|p| p.as_path()).collect(); project_paths.sort(); !open_path_sets.iter().any(|open_paths| { open_paths.len() == project_paths.len() && open_paths .iter() .zip(&project_paths) .all(|(a, b)| a.as_ref() == *b) }) }) .cloned() .collect(); if !recent.is_empty() { let today = Local::now().naive_local().date(); let mut current_bucket: Option<TimeBucket> = None; for project in recent { let entry_date = project.timestamp.with_timezone(&Local).naive_local().date(); let bucket = TimeBucket::from_dates(today, entry_date); if current_bucket != Some(bucket) { current_bucket = Some(bucket); self.entries .push(SidebarEntry::Separator(bucket.to_string().into())); } self.entries.push(SidebarEntry::RecentProject(project)); } } }} #[derive(Clone, Copy, Debug, PartialEq, Eq)]enum TimeBucket { Today, Yesterday, ThisWeek, PastWeek, All,} impl TimeBucket { fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { if date == reference { return TimeBucket::Today; } if date == reference - TimeDelta::days(1) { return TimeBucket::Yesterday; } let week = date.iso_week(); if reference.iso_week() == week { return TimeBucket::ThisWeek; } let last_week = (reference - TimeDelta::days(7)).iso_week(); if week == last_week { return TimeBucket::PastWeek; } TimeBucket::All }} impl Display for TimeBucket { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimeBucket::Today => write!(f, "Today"), TimeBucket::Yesterday => write!(f, "Yesterday"), TimeBucket::ThisWeek => write!(f, "This Week"), TimeBucket::PastWeek => write!(f, "Past Week"), TimeBucket::All => write!(f, "All"), } }} fn open_recent_project(paths: Vec<PathBuf>, window: &mut Window, cx: &mut App) { let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else { return; }; cx.defer(move |cx| { if let Some(task) = handle .update(cx, |multi_workspace, window, cx| { multi_workspace.open_project(paths, window, cx) }) .log_err() { task.detach_and_log_err(cx); } });} impl PickerDelegate for WorkspacePickerDelegate { type ListItem = AnyElement; fn match_count(&self) -> usize { self.matches.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) { self.selected_index = ix; } fn can_select( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) -> bool { match self.matches.get(ix) { Some(SidebarMatch { entry: SidebarEntry::Separator(_), .. }) => false, _ => true, } } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> { "Search…".into() } fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> { if self.query.is_empty() { None } else { Some("No threads match your search.".into()) } } fn update_matches( &mut self, query: String, window: &mut Window, cx: &mut Context<Picker<Self>>, ) -> Task<()> { let query_changed = self.query != query; self.query = query.clone(); if query_changed { self.hovered_thread_item = None; } let entries = self.entries.clone(); if query.is_empty() { self.matches = entries .into_iter() .map(|entry| SidebarMatch { entry, positions: Vec::new(), }) .collect(); let separator_offset = if self.workspace_thread_count > 0 { 1 } else { 0 }; self.selected_index = (self.active_workspace_index + separator_offset) .min(self.matches.len().saturating_sub(1)); return Task::ready(()); } let executor = cx.background_executor().clone(); cx.spawn_in(window, async move |picker, cx| { let matches = cx .background_spawn(async move { let data_entries: Vec<(usize, &SidebarEntry)> = entries .iter() .enumerate() .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_))) .collect(); let candidates: Vec<StringMatchCandidate> = data_entries .iter() .enumerate() .map(|(candidate_index, (_, entry))| { StringMatchCandidate::new(candidate_index, entry.searchable_text()) }) .collect(); let search_matches = fuzzy::match_strings( &candidates, &query, false, true, MAX_MATCHES, &Default::default(),Lines 1–500 of 1368. Load more
use acp_thread::ThreadStatus; use agent_ui::{AgentPanel, AgentPanelEvent}; use chrono::{Datelike, Local, NaiveDate, TimeDelta}; use db::kvp::KEY_VALUE_STORE; use fs::Fs; use fuzzy::StringMatchCandidate; use gpui::{ App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, Subscription, Task, Window, px, }; use picker::{Picker, PickerDelegate}; use project::Event as ProjectEvent; use recent_projects::{RecentProjectEntry, get_recent_projects}; use std::fmt::Display; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use theme::ActiveTheme; use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ AgentThreadStatus, Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip, prelude::*, }; use ui_input::ErasedEditor; use util::ResultExt as _; use workspace::{ FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar, SidebarEvent, ToggleWorkspaceSidebar, Workspace, }; #[derive(Clone, Debug)] struct AgentThreadInfo { title: SharedString, status: AgentThreadStatus, icon: IconName, } const LAST_THREAD_TITLES_KEY: &str = "sidebar-last-thread-titles"; const DEFAULT_WIDTH: Pixels = px(320.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); const MAX_MATCHES: usize = 100; #[derive(Clone)] struct WorkspaceThreadEntry { index: usize, worktree_label: SharedString, full_path: SharedString, thread_info: Option<AgentThreadInfo>, } impl WorkspaceThreadEntry { fn new( index: usize, workspace: &Entity<Workspace>, persisted_titles: &HashMap<String, String>, cx: &App, ) -> Self { let workspace_ref = workspace.read(cx); let worktrees: Vec<_> = workspace_ref .worktrees(cx) .filter(|worktree| worktree.read(cx).is_visible()) .map(|worktree| worktree.read(cx).abs_path()) .collect(); let worktree_names: Vec<String> = worktrees .iter() .filter_map(|path| { path.file_name() .map(|name| name.to_string_lossy().to_string()) }) .collect(); let worktree_label: SharedString = if worktree_names.is_empty() { format!("Workspace {}", index + 1).into() } else { worktree_names.join(", ").into() }; let full_path: SharedString = worktrees .iter() .map(|path| path.to_string_lossy().to_string()) .collect::<Vec<_>>() .join("\n") .into(); let thread_info = Self::thread_info(workspace, cx).or_else(|| { if worktrees.is_empty() { return None; } let path_key = sorted_paths_key(&worktrees); let title = persisted_titles.get(&path_key)?; Some(AgentThreadInfo { title: SharedString::from(title.clone()), status: AgentThreadStatus::Completed, icon: IconName::ZedAgent, }) }); Self { index, worktree_label, full_path, thread_info, } } fn thread_info(workspace: &Entity<Workspace>, cx: &App) -> Option<AgentThreadInfo> { let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?; let agent_panel_ref = agent_panel.read(cx); let thread_view = agent_panel_ref.as_active_thread_view(cx)?.read(cx); let thread = thread_view.thread.read(cx); let icon = thread_view.agent_icon; let title = thread.title(); let status = if thread.is_waiting_for_confirmation() { AgentThreadStatus::WaitingForConfirmation } else if thread.had_error() { AgentThreadStatus::Error } else { match thread.status() { ThreadStatus::Generating => AgentThreadStatus::Running, ThreadStatus::Idle => AgentThreadStatus::Completed, } }; Some(AgentThreadInfo { title, status, icon, }) } } #[derive(Clone)] enum SidebarEntry { Separator(SharedString), WorkspaceThread(WorkspaceThreadEntry), RecentProject(RecentProjectEntry), } impl SidebarEntry { fn searchable_text(&self) -> &str { match self { SidebarEntry::Separator(_) => "", SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(), SidebarEntry::RecentProject(entry) => entry.name.as_ref(), } } } #[derive(Clone)] struct SidebarMatch { entry: SidebarEntry, positions: Vec<usize>, } struct WorkspacePickerDelegate { multi_workspace: Entity<MultiWorkspace>, entries: Vec<SidebarEntry>, active_workspace_index: usize, workspace_thread_count: usize, /// All recent projects including what's filtered out of entries /// used to add unopened projects to entries on rebuild recent_projects: Vec<RecentProjectEntry>, recent_project_thread_titles: HashMap<SharedString, SharedString>, matches: Vec<SidebarMatch>, selected_index: usize, query: String, hovered_thread_item: Option<usize>, notified_workspaces: HashSet<usize>, } impl WorkspacePickerDelegate { fn new(multi_workspace: Entity<MultiWorkspace>) -> Self { Self { multi_workspace, entries: Vec::new(), active_workspace_index: 0, workspace_thread_count: 0, recent_projects: Vec::new(), recent_project_thread_titles: HashMap::new(), matches: Vec::new(), selected_index: 0, query: String::new(), hovered_thread_item: None, notified_workspaces: HashSet::new(), } } fn set_entries( &mut self, workspace_threads: Vec<WorkspaceThreadEntry>, active_workspace_index: usize, cx: &App, ) { if let Some(hovered_index) = self.hovered_thread_item { let still_exists = workspace_threads .iter() .any(|thread| thread.index == hovered_index); if !still_exists { self.hovered_thread_item = None; } } let old_statuses: HashMap<usize, AgentThreadStatus> = self .entries .iter() .filter_map(|entry| match entry { SidebarEntry::WorkspaceThread(thread) => thread .thread_info .as_ref() .map(|info| (thread.index, info.status)), _ => None, }) .collect(); for thread in &workspace_threads { if let Some(info) = &thread.thread_info { if info.status == AgentThreadStatus::Completed && thread.index != active_workspace_index { if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) { self.notified_workspaces.insert(thread.index); } } } } if self.active_workspace_index != active_workspace_index { self.notified_workspaces.remove(&active_workspace_index); } self.active_workspace_index = active_workspace_index; self.workspace_thread_count = workspace_threads.len(); self.rebuild_entries(workspace_threads, cx); } fn set_recent_projects(&mut self, recent_projects: Vec<RecentProjectEntry>, cx: &App) { self.recent_project_thread_titles.clear(); if let Some(map) = read_thread_title_map() { for entry in &recent_projects { let path_key = sorted_paths_key(&entry.paths); if let Some(title) = map.get(&path_key) { self.recent_project_thread_titles .insert(entry.full_path.clone(), title.clone().into()); } } } self.recent_projects = recent_projects; let workspace_threads: Vec<WorkspaceThreadEntry> = self .entries .iter() .filter_map(|entry| match entry { SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()), _ => None, }) .collect(); self.rebuild_entries(workspace_threads, cx); } fn open_workspace_path_sets(&self, cx: &App) -> Vec<Vec<Arc<Path>>> { self.multi_workspace .read(cx) .workspaces() .iter() .map(|workspace| { let mut paths = workspace.read(cx).root_paths(cx); paths.sort(); paths }) .collect() } fn rebuild_entries(&mut self, workspace_threads: Vec<WorkspaceThreadEntry>, cx: &App) { let open_path_sets = self.open_workspace_path_sets(cx); self.entries.clear(); if !workspace_threads.is_empty() { self.entries .push(SidebarEntry::Separator("Active Workspaces".into())); for thread in workspace_threads { self.entries.push(SidebarEntry::WorkspaceThread(thread)); } } let recent: Vec<_> = self .recent_projects .iter() .filter(|project| { let mut project_paths: Vec<&Path> = project.paths.iter().map(|p| p.as_path()).collect(); project_paths.sort(); !open_path_sets.iter().any(|open_paths| { open_paths.len() == project_paths.len() && open_paths .iter() .zip(&project_paths) .all(|(a, b)| a.as_ref() == *b) }) }) .cloned() .collect(); if !recent.is_empty() { let today = Local::now().naive_local().date(); let mut current_bucket: Option<TimeBucket> = None; for project in recent { let entry_date = project.timestamp.with_timezone(&Local).naive_local().date(); let bucket = TimeBucket::from_dates(today, entry_date); if current_bucket != Some(bucket) { current_bucket = Some(bucket); self.entries .push(SidebarEntry::Separator(bucket.to_string().into())); } self.entries.push(SidebarEntry::RecentProject(project)); } } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TimeBucket { Today, Yesterday, ThisWeek, PastWeek, All, } impl TimeBucket { fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { if date == reference { return TimeBucket::Today; } if date == reference - TimeDelta::days(1) { return TimeBucket::Yesterday; } let week = date.iso_week(); if reference.iso_week() == week { return TimeBucket::ThisWeek; } let last_week = (reference - TimeDelta::days(7)).iso_week(); if week == last_week { return TimeBucket::PastWeek; } TimeBucket::All } } impl Display for TimeBucket { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimeBucket::Today => write!(f, "Today"), TimeBucket::Yesterday => write!(f, "Yesterday"), TimeBucket::ThisWeek => write!(f, "This Week"), TimeBucket::PastWeek => write!(f, "Past Week"), TimeBucket::All => write!(f, "All"), } } } fn open_recent_project(paths: Vec<PathBuf>, window: &mut Window, cx: &mut App) { let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else { return; }; cx.defer(move |cx| { if let Some(task) = handle .update(cx, |multi_workspace, window, cx| { multi_workspace.open_project(paths, window, cx) }) .log_err() { task.detach_and_log_err(cx); } }); } impl PickerDelegate for WorkspacePickerDelegate { type ListItem = AnyElement; fn match_count(&self) -> usize { self.matches.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) { self.selected_index = ix; } fn can_select( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) -> bool { match self.matches.get(ix) { Some(SidebarMatch { entry: SidebarEntry::Separator(_), .. }) => false, _ => true, } } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> { "Search…".into() } fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> { if self.query.is_empty() { None } else { Some("No threads match your search.".into()) } } fn update_matches( &mut self, query: String, window: &mut Window, cx: &mut Context<Picker<Self>>, ) -> Task<()> { let query_changed = self.query != query; self.query = query.clone(); if query_changed { self.hovered_thread_item = None; } let entries = self.entries.clone(); if query.is_empty() { self.matches = entries .into_iter() .map(|entry| SidebarMatch { entry, positions: Vec::new(), }) .collect(); let separator_offset = if self.workspace_thread_count > 0 { 1 } else { 0 }; self.selected_index = (self.active_workspace_index + separator_offset) .min(self.matches.len().saturating_sub(1)); return Task::ready(()); } let executor = cx.background_executor().clone(); cx.spawn_in(window, async move |picker, cx| { let matches = cx .background_spawn(async move { let data_entries: Vec<(usize, &SidebarEntry)> = entries .iter() .enumerate() .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_))) .collect(); let candidates: Vec<StringMatchCandidate> = data_entries .iter() .enumerate() .map(|(candidate_index, (_, entry))| { StringMatchCandidate::new(candidate_index, entry.searchable_text()) }) .collect(); let search_matches = fuzzy::match_strings( &candidates, &query, false, true, MAX_MATCHES, &Default::default(),