From 23edf066895174bf269c0a517878da26aad1b3a6 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 1 Apr 2026 10:40:31 +0200 Subject: [PATCH] sidebar: Support loading threads that have no project association (#52842) Changed the migration codepath, so that threads with no project are also migrated to the archive. Release Notes: - N/A --------- Co-authored-by: Danilo Leal --- crates/agent_ui/src/thread_metadata_store.rs | 33 +- crates/agent_ui/src/threads_archive_view.rs | 616 ++++++++++++++++++- crates/sidebar/src/sidebar.rs | 1 + 3 files changed, 634 insertions(+), 16 deletions(-) diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index d4e8e6d37aabe98dc41bf39575b77fd28a3bed08..a8b531eb59e7aab740678c464e21e4b54daa3f59 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -55,7 +55,7 @@ fn migrate_thread_metadata(cx: &mut App) { .read(cx) .entries() .filter_map(|entry| { - if existing_entries.contains(&entry.id.0) || entry.folder_paths.is_empty() { + if existing_entries.contains(&entry.id.0) { return None; } @@ -81,6 +81,9 @@ fn migrate_thread_metadata(cx: &mut App) { if is_first_migration { let mut per_project: HashMap> = HashMap::default(); for entry in &mut to_migrate { + if entry.folder_paths.is_empty() { + continue; + } per_project .entry(entry.folder_paths.clone()) .or_default() @@ -316,6 +319,25 @@ impl ThreadMetadataStore { .log_err(); } + pub fn update_working_directories( + &mut self, + session_id: &acp::SessionId, + work_dirs: PathList, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + + if let Some(thread) = self.threads.get(session_id) { + self.save_internal(ThreadMetadata { + folder_paths: work_dirs, + ..thread.clone() + }); + cx.notify(); + } + } + pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context) { self.update_archived(session_id, true, cx); } @@ -994,7 +1016,7 @@ mod tests { store.read(cx).entries().cloned().collect::>() }); - assert_eq!(list.len(), 3); + assert_eq!(list.len(), 4); assert!( list.iter() .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref()) @@ -1013,17 +1035,12 @@ mod tests { .collect::>(); assert!(migrated_session_ids.contains(&"a-session-1")); assert!(migrated_session_ids.contains(&"b-session-0")); - assert!(!migrated_session_ids.contains(&"projectless")); + assert!(migrated_session_ids.contains(&"projectless")); let migrated_entries = list .iter() .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0") .collect::>(); - assert!( - migrated_entries - .iter() - .all(|metadata| !metadata.folder_paths.is_empty()) - ); assert!(migrated_entries.iter().all(|metadata| metadata.archived)); } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 74a93129d387e0aaac6e7092d9e086dd64e369f7..9aca31e1edbe729fccecfc0dd8f0530d2aed2564 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1,3 +1,6 @@ +use std::collections::HashSet; +use std::sync::Arc; + use crate::agent_connection_store::AgentConnectionStore; use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; @@ -9,18 +12,31 @@ use agent_settings::AgentSettings; use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::Editor; use fs::Fs; +use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render, - SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px, + AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + ListState, Render, SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px, }; use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use picker::{ + Picker, PickerDelegate, + highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, +}; use project::{AgentId, AgentServerStore}; use settings::Settings as _; use theme::ActiveTheme; use ui::ThreadItem; use ui::{ - Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, + Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, WithScrollbar, + prelude::*, utils::platform_title_bar_height, +}; +use ui_input::ErasedEditor; +use util::ResultExt; +use util::paths::PathExt; +use workspace::{ + ModalView, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId, + resolve_worktree_workspaces, }; use zed_actions::agents_sidebar::FocusSidebarFilter; @@ -110,12 +126,14 @@ pub struct ThreadsArchiveView { filter_editor: Entity, _subscriptions: Vec, _refresh_history_task: Task<()>, + workspace: WeakEntity, agent_connection_store: WeakEntity, agent_server_store: WeakEntity, } impl ThreadsArchiveView { pub fn new( + workspace: WeakEntity, agent_connection_store: WeakEntity, agent_server_store: WeakEntity, window: &mut Window, @@ -176,6 +194,7 @@ impl ThreadsArchiveView { thread_metadata_store_subscription, ], _refresh_history_task: Task::ready(()), + workspace, agent_connection_store, agent_server_store, }; @@ -254,7 +273,14 @@ impl ThreadsArchiveView { self.list_state.reset(items.len()); self.items = items; - self.hovered_index = None; + + if !preserve { + self.hovered_index = None; + } else if let Some(ix) = self.hovered_index { + if ix >= self.items.len() || !self.is_selectable_item(ix) { + self.hovered_index = None; + } + } if let Some(scroll_top) = saved_scroll { self.list_state.scroll_to(scroll_top); @@ -288,11 +314,57 @@ impl ThreadsArchiveView { window: &mut Window, cx: &mut Context, ) { + if thread.folder_paths.is_empty() { + self.show_project_picker_for_thread(thread, window, cx); + return; + } + self.selection = None; self.reset_filter_editor_text(window, cx); cx.emit(ThreadsArchiveViewEvent::Unarchive { thread }); } + fn show_project_picker_for_thread( + &mut self, + thread: ThreadMetadata, + window: &mut Window, + cx: &mut Context, + ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + let archive_view = cx.weak_entity(); + let fs = workspace.read(cx).app_state().fs.clone(); + let current_workspace_id = workspace.read(cx).database_id(); + let sibling_workspace_ids: HashSet = workspace + .read(cx) + .multi_workspace() + .and_then(|mw| mw.upgrade()) + .map(|mw| { + mw.read(cx) + .workspaces() + .iter() + .filter_map(|ws| ws.read(cx).database_id()) + .collect() + }) + .unwrap_or_default(); + + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + ProjectPickerModal::new( + thread, + fs, + archive_view, + current_workspace_id, + sibling_workspace_ids, + window, + cx, + ) + }); + }); + } + fn is_selectable_item(&self, ix: usize) -> bool { matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. })) } @@ -380,10 +452,6 @@ impl ThreadsArchiveView { return; }; - if thread.folder_paths.is_empty() { - return; - } - self.unarchive_thread(thread.clone(), window, cx); } @@ -471,6 +539,7 @@ impl ThreadsArchiveView { let agent = thread.agent_id.clone(); let session_id = thread.session_id.clone(); cx.listener(move |this, _, _, cx| { + this.preserve_selection_on_next_update = true; this.delete_thread(session_id.clone(), agent.clone(), cx); cx.stop_propagation(); }) @@ -683,3 +752,534 @@ impl Render for ThreadsArchiveView { .child(content) } } + +struct ProjectPickerModal { + picker: Entity>, + _subscription: Subscription, +} + +impl ProjectPickerModal { + fn new( + thread: ThreadMetadata, + fs: Arc, + archive_view: WeakEntity, + current_workspace_id: Option, + sibling_workspace_ids: HashSet, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let delegate = ProjectPickerDelegate { + thread, + archive_view, + workspaces: Vec::new(), + filtered_entries: Vec::new(), + selected_index: 0, + current_workspace_id, + sibling_workspace_ids, + focus_handle: cx.focus_handle(), + }; + + let picker = cx.new(|cx| { + Picker::list(delegate, window, cx) + .list_measure_all() + .modal(false) + }); + + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle; + }); + + let _subscription = + cx.subscribe(&picker, |_this: &mut Self, _, _event: &DismissEvent, cx| { + cx.emit(DismissEvent); + }); + + let db = WorkspaceDb::global(cx); + cx.spawn_in(window, async move |this, cx| { + let workspaces = db + .recent_workspaces_on_disk(fs.as_ref()) + .await + .log_err() + .unwrap_or_default(); + let workspaces = resolve_worktree_workspaces(workspaces, fs.as_ref()).await; + this.update_in(cx, move |this, window, cx| { + this.picker.update(cx, move |picker, cx| { + picker.delegate.workspaces = workspaces; + picker.update_matches(picker.query(cx), window, cx) + }) + }) + .ok(); + }) + .detach(); + + picker.focus_handle(cx).focus(window, cx); + + Self { + picker, + _subscription, + } + } +} + +impl EventEmitter for ProjectPickerModal {} + +impl Focusable for ProjectPickerModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl ModalView for ProjectPickerModal {} + +impl Render for ProjectPickerModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("ProjectPickerModal") + .elevation_3(cx) + .w(rems(34.)) + .on_action(cx.listener(|this, _: &workspace::Open, window, cx| { + this.picker.update(cx, |picker, cx| { + picker.delegate.open_local_folder(window, cx) + }) + })) + .child(self.picker.clone()) + } +} + +enum ProjectPickerEntry { + Header(SharedString), + Workspace(StringMatch), +} + +struct ProjectPickerDelegate { + thread: ThreadMetadata, + archive_view: WeakEntity, + current_workspace_id: Option, + sibling_workspace_ids: HashSet, + workspaces: Vec<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, + filtered_entries: Vec, + selected_index: usize, + focus_handle: FocusHandle, +} + +impl ProjectPickerDelegate { + fn update_working_directories_and_unarchive( + &mut self, + paths: PathList, + window: &mut Window, + cx: &mut Context>, + ) { + self.thread.folder_paths = paths.clone(); + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.update_working_directories(&self.thread.session_id, paths, cx); + }); + + self.archive_view + .update(cx, |view, cx| { + view.selection = None; + view.reset_filter_editor_text(window, cx); + cx.emit(ThreadsArchiveViewEvent::Unarchive { + thread: self.thread.clone(), + }); + }) + .log_err(); + } + + fn is_current_workspace(&self, workspace_id: WorkspaceId) -> bool { + self.current_workspace_id == Some(workspace_id) + } + + fn is_sibling_workspace(&self, workspace_id: WorkspaceId) -> bool { + self.sibling_workspace_ids.contains(&workspace_id) + && !self.is_current_workspace(workspace_id) + } + + fn selected_match(&self) -> Option<&StringMatch> { + match self.filtered_entries.get(self.selected_index)? { + ProjectPickerEntry::Workspace(hit) => Some(hit), + ProjectPickerEntry::Header(_) => None, + } + } + + fn open_local_folder(&mut self, window: &mut Window, cx: &mut Context>) { + let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + cx.spawn_in(window, async move |this, cx| { + let Ok(Ok(Some(paths))) = paths_receiver.await else { + return; + }; + if paths.is_empty() { + return; + } + + let work_dirs = PathList::new(&paths); + + this.update_in(cx, |this, window, cx| { + this.delegate + .update_working_directories_and_unarchive(work_dirs, window, cx); + cx.emit(DismissEvent); + }) + .log_err(); + }) + .detach(); + } +} + +impl EventEmitter for ProjectPickerDelegate {} + +impl PickerDelegate for ProjectPickerDelegate { + type ListItem = AnyElement; + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + format!("Associate the \"{}\" thread with...", self.thread.title).into() + } + + fn render_editor( + &self, + editor: &Arc, + window: &mut Window, + cx: &mut Context>, + ) -> Div { + h_flex() + .flex_none() + .h_9() + .px_2p5() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(editor.render(window, cx)) + } + + fn match_count(&self) -> usize { + self.filtered_entries.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + matches!( + self.filtered_entries.get(ix), + Some(ProjectPickerEntry::Workspace(_)) + ) + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let query = query.trim_start(); + let smart_case = query.chars().any(|c| c.is_uppercase()); + let is_empty_query = query.is_empty(); + + let sibling_candidates: Vec<_> = self + .workspaces + .iter() + .enumerate() + .filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id)) + .map(|(id, (_, _, paths, _))| { + let combined_string = paths + .ordered_paths() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""); + StringMatchCandidate::new(id, &combined_string) + }) + .collect(); + + let mut sibling_matches = smol::block_on(fuzzy::match_strings( + &sibling_candidates, + query, + smart_case, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )); + + sibling_matches.sort_unstable_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.candidate_id.cmp(&b.candidate_id)) + }); + + let recent_candidates: Vec<_> = self + .workspaces + .iter() + .enumerate() + .filter(|(_, (id, _, _, _))| { + !self.is_current_workspace(*id) && !self.is_sibling_workspace(*id) + }) + .map(|(id, (_, _, paths, _))| { + let combined_string = paths + .ordered_paths() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""); + StringMatchCandidate::new(id, &combined_string) + }) + .collect(); + + let mut recent_matches = smol::block_on(fuzzy::match_strings( + &recent_candidates, + query, + smart_case, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )); + + recent_matches.sort_unstable_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.candidate_id.cmp(&b.candidate_id)) + }); + + let mut entries = Vec::new(); + + let has_siblings_to_show = if is_empty_query { + !sibling_candidates.is_empty() + } else { + !sibling_matches.is_empty() + }; + + if has_siblings_to_show { + entries.push(ProjectPickerEntry::Header("This Window".into())); + + if is_empty_query { + for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() { + if self.is_sibling_workspace(*workspace_id) { + entries.push(ProjectPickerEntry::Workspace(StringMatch { + candidate_id: id, + score: 0.0, + positions: Vec::new(), + string: String::new(), + })); + } + } + } else { + for m in sibling_matches { + entries.push(ProjectPickerEntry::Workspace(m)); + } + } + } + + let has_recent_to_show = if is_empty_query { + !recent_candidates.is_empty() + } else { + !recent_matches.is_empty() + }; + + if has_recent_to_show { + entries.push(ProjectPickerEntry::Header("Recent Projects".into())); + + if is_empty_query { + for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() { + if !self.is_current_workspace(*workspace_id) + && !self.is_sibling_workspace(*workspace_id) + { + entries.push(ProjectPickerEntry::Workspace(StringMatch { + candidate_id: id, + score: 0.0, + positions: Vec::new(), + string: String::new(), + })); + } + } + } else { + for m in recent_matches { + entries.push(ProjectPickerEntry::Workspace(m)); + } + } + } + + self.filtered_entries = entries; + + self.selected_index = self + .filtered_entries + .iter() + .position(|e| matches!(e, ProjectPickerEntry::Workspace(_))) + .unwrap_or(0); + + Task::ready(()) + } + + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + let candidate_id = match self.filtered_entries.get(self.selected_index) { + Some(ProjectPickerEntry::Workspace(hit)) => hit.candidate_id, + _ => return, + }; + let Some((_workspace_id, _location, paths, _)) = self.workspaces.get(candidate_id) else { + return; + }; + + self.update_working_directories_and_unarchive(paths.clone(), window, cx); + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + let text = if self.workspaces.is_empty() { + "No recent projects found" + } else { + "No matches" + }; + Some(text.into()) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.filtered_entries.get(ix)? { + ProjectPickerEntry::Header(title) => Some( + v_flex() + .w_full() + .gap_1() + .when(ix > 0, |this| this.mt_1().child(Divider::horizontal())) + .child(ListSubHeader::new(title.clone()).inset(true)) + .into_any_element(), + ), + ProjectPickerEntry::Workspace(hit) => { + let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?; + + let ordered_paths: Vec<_> = paths + .ordered_paths() + .map(|p| p.compact().to_string_lossy().to_string()) + .collect(); + + let tooltip_path: SharedString = ordered_paths.join("\n").into(); + + let mut path_start_offset = 0; + let match_labels: Vec<_> = paths + .ordered_paths() + .map(|p| p.compact()) + .map(|path| { + let path_string = path.to_string_lossy(); + let path_text = path_string.to_string(); + let path_byte_len = path_text.len(); + + let path_positions: Vec = hit + .positions + .iter() + .copied() + .skip_while(|pos| *pos < path_start_offset) + .take_while(|pos| *pos < path_start_offset + path_byte_len) + .map(|pos| pos - path_start_offset) + .collect(); + + let file_name_match = path.file_name().map(|file_name| { + let file_name_text = file_name.to_string_lossy().into_owned(); + let file_name_start = path_byte_len - file_name_text.len(); + let highlight_positions: Vec = path_positions + .iter() + .copied() + .skip_while(|pos| *pos < file_name_start) + .take_while(|pos| *pos < file_name_start + file_name_text.len()) + .map(|pos| pos - file_name_start) + .collect(); + HighlightedMatch { + text: file_name_text, + highlight_positions, + color: Color::Default, + } + }); + + path_start_offset += path_byte_len; + file_name_match + }) + .collect(); + + let highlighted_match = HighlightedMatchWithPaths { + prefix: match location { + SerializedWorkspaceLocation::Remote(options) => { + Some(SharedString::from(options.display_name())) + } + _ => None, + }, + match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "), + paths: Vec::new(), + }; + + Some( + ListItem::new(ix) + .toggle_state(selected) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .gap_3() + .flex_grow() + .child(highlighted_match.render(window, cx)), + ) + .tooltip(Tooltip::text(tooltip_path)) + .into_any_element(), + ) + } + } + } + + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let has_selection = self.selected_match().is_some(); + let focus_handle = self.focus_handle.clone(); + + Some( + h_flex() + .flex_1() + .p_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("open_local_folder", "Choose from Local Folders") + .key_binding(KeyBinding::for_action_in( + &workspace::Open::default(), + &focus_handle, + cx, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.open_local_folder(window, cx); + })), + ) + .child( + Button::new("select_project", "Select") + .disabled(!has_selection) + .key_binding(KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)) + .on_click(cx.listener(move |picker, _, window, cx| { + picker.delegate.confirm(false, window, cx); + })), + ) + .into_any(), + ) + } +} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index b1257b4c79c2ef193ec4594139cd1f57b93a5666..e09ee3e8809417924b1b1b43f25cee75834568a1 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -3488,6 +3488,7 @@ impl Sidebar { let archive_view = cx.new(|cx| { ThreadsArchiveView::new( + active_workspace.downgrade(), agent_connection_store.clone(), agent_server_store.clone(), window,