1use std::collections::HashSet;
2use std::sync::Arc;
3
4use crate::agent_connection_store::AgentConnectionStore;
5
6use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
7use crate::{Agent, RemoveSelectedThread};
8
9use agent::ThreadStore;
10use agent_client_protocol::schema as acp;
11use agent_settings::AgentSettings;
12use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
13use editor::Editor;
14use fs::Fs;
15use fuzzy::{StringMatch, StringMatchCandidate};
16use gpui::{
17 AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
18 ListState, Render, SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px,
19};
20use itertools::Itertools as _;
21use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
22use picker::{
23 Picker, PickerDelegate,
24 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
25};
26use project::{AgentId, AgentServerStore};
27use settings::Settings as _;
28use theme::ActiveTheme;
29use ui::ThreadItem;
30use ui::{
31 Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, WithScrollbar,
32 prelude::*, utils::platform_title_bar_height,
33};
34use ui_input::ErasedEditor;
35use util::ResultExt;
36use util::paths::PathExt;
37use workspace::{
38 ModalView, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
39 resolve_worktree_workspaces,
40};
41
42use zed_actions::agents_sidebar::FocusSidebarFilter;
43use zed_actions::editor::{MoveDown, MoveUp};
44
45#[derive(Clone)]
46enum ArchiveListItem {
47 BucketSeparator(TimeBucket),
48 Entry {
49 thread: ThreadMetadata,
50 highlight_positions: Vec<usize>,
51 },
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55enum TimeBucket {
56 Today,
57 Yesterday,
58 ThisWeek,
59 PastWeek,
60 Older,
61}
62
63impl TimeBucket {
64 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
65 if date == reference {
66 return TimeBucket::Today;
67 }
68 if date == reference - TimeDelta::days(1) {
69 return TimeBucket::Yesterday;
70 }
71 let week = date.iso_week();
72 if reference.iso_week() == week {
73 return TimeBucket::ThisWeek;
74 }
75 let last_week = (reference - TimeDelta::days(7)).iso_week();
76 if week == last_week {
77 return TimeBucket::PastWeek;
78 }
79 TimeBucket::Older
80 }
81
82 fn label(&self) -> &'static str {
83 match self {
84 TimeBucket::Today => "Today",
85 TimeBucket::Yesterday => "Yesterday",
86 TimeBucket::ThisWeek => "This Week",
87 TimeBucket::PastWeek => "Past Week",
88 TimeBucket::Older => "Older",
89 }
90 }
91}
92
93fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
94 let mut positions = Vec::new();
95 let mut query_chars = query.chars().peekable();
96 for (byte_idx, candidate_char) in text.char_indices() {
97 if let Some(&query_char) = query_chars.peek() {
98 if candidate_char.eq_ignore_ascii_case(&query_char) {
99 positions.push(byte_idx);
100 query_chars.next();
101 }
102 } else {
103 break;
104 }
105 }
106 if query_chars.peek().is_none() {
107 Some(positions)
108 } else {
109 None
110 }
111}
112
113pub enum ThreadsArchiveViewEvent {
114 Close,
115 Unarchive { thread: ThreadMetadata },
116}
117
118impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
119
120pub struct ThreadsArchiveView {
121 _history_subscription: Subscription,
122 focus_handle: FocusHandle,
123 list_state: ListState,
124 items: Vec<ArchiveListItem>,
125 selection: Option<usize>,
126 hovered_index: Option<usize>,
127 preserve_selection_on_next_update: bool,
128 filter_editor: Entity<Editor>,
129 _subscriptions: Vec<gpui::Subscription>,
130 _refresh_history_task: Task<()>,
131 workspace: WeakEntity<Workspace>,
132 agent_connection_store: WeakEntity<AgentConnectionStore>,
133 agent_server_store: WeakEntity<AgentServerStore>,
134}
135
136impl ThreadsArchiveView {
137 pub fn new(
138 workspace: WeakEntity<Workspace>,
139 agent_connection_store: WeakEntity<AgentConnectionStore>,
140 agent_server_store: WeakEntity<AgentServerStore>,
141 window: &mut Window,
142 cx: &mut Context<Self>,
143 ) -> Self {
144 let focus_handle = cx.focus_handle();
145
146 let filter_editor = cx.new(|cx| {
147 let mut editor = Editor::single_line(window, cx);
148 editor.set_placeholder_text("Search archive…", window, cx);
149 editor
150 });
151
152 let filter_editor_subscription =
153 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
154 if let editor::EditorEvent::BufferEdited = event {
155 this.update_items(cx);
156 }
157 });
158
159 let filter_focus_handle = filter_editor.read(cx).focus_handle(cx);
160 cx.on_focus_in(
161 &filter_focus_handle,
162 window,
163 |this: &mut Self, _window, cx| {
164 if this.selection.is_some() {
165 this.selection = None;
166 cx.notify();
167 }
168 },
169 )
170 .detach();
171
172 let thread_metadata_store_subscription = cx.observe(
173 &ThreadMetadataStore::global(cx),
174 |this: &mut Self, _, cx| {
175 this.update_items(cx);
176 },
177 );
178
179 cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| {
180 this.selection = None;
181 cx.notify();
182 })
183 .detach();
184
185 let mut this = Self {
186 _history_subscription: Subscription::new(|| {}),
187 focus_handle,
188 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
189 items: Vec::new(),
190 selection: None,
191 hovered_index: None,
192 preserve_selection_on_next_update: false,
193 filter_editor,
194 _subscriptions: vec![
195 filter_editor_subscription,
196 thread_metadata_store_subscription,
197 ],
198 _refresh_history_task: Task::ready(()),
199 workspace,
200 agent_connection_store,
201 agent_server_store,
202 };
203
204 this.update_items(cx);
205 this
206 }
207
208 pub fn has_selection(&self) -> bool {
209 self.selection.is_some()
210 }
211
212 pub fn clear_selection(&mut self) {
213 self.selection = None;
214 }
215
216 pub fn focus_filter_editor(&self, window: &mut Window, cx: &mut App) {
217 let handle = self.filter_editor.read(cx).focus_handle(cx);
218 handle.focus(window, cx);
219 }
220
221 fn update_items(&mut self, cx: &mut Context<Self>) {
222 let sessions = ThreadMetadataStore::global(cx)
223 .read(cx)
224 .archived_entries()
225 .sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at))
226 .rev()
227 .cloned()
228 .collect::<Vec<_>>();
229
230 let query = self.filter_editor.read(cx).text(cx).to_lowercase();
231 let today = Local::now().naive_local().date();
232
233 let mut items = Vec::with_capacity(sessions.len() + 5);
234 let mut current_bucket: Option<TimeBucket> = None;
235
236 for session in sessions {
237 let highlight_positions = if !query.is_empty() {
238 match fuzzy_match_positions(&query, &session.title) {
239 Some(positions) => positions,
240 None => continue,
241 }
242 } else {
243 Vec::new()
244 };
245
246 let entry_bucket = {
247 let entry_date = session
248 .created_at
249 .unwrap_or(session.updated_at)
250 .with_timezone(&Local)
251 .naive_local()
252 .date();
253 TimeBucket::from_dates(today, entry_date)
254 };
255
256 if Some(entry_bucket) != current_bucket {
257 current_bucket = Some(entry_bucket);
258 items.push(ArchiveListItem::BucketSeparator(entry_bucket));
259 }
260
261 items.push(ArchiveListItem::Entry {
262 thread: session,
263 highlight_positions,
264 });
265 }
266
267 let preserve = self.preserve_selection_on_next_update;
268 self.preserve_selection_on_next_update = false;
269
270 let saved_scroll = if preserve {
271 Some(self.list_state.logical_scroll_top())
272 } else {
273 None
274 };
275
276 self.list_state.reset(items.len());
277 self.items = items;
278
279 if !preserve {
280 self.hovered_index = None;
281 } else if let Some(ix) = self.hovered_index {
282 if ix >= self.items.len() || !self.is_selectable_item(ix) {
283 self.hovered_index = None;
284 }
285 }
286
287 if let Some(scroll_top) = saved_scroll {
288 self.list_state.scroll_to(scroll_top);
289
290 if let Some(ix) = self.selection {
291 let next = self.find_next_selectable(ix).or_else(|| {
292 ix.checked_sub(1)
293 .and_then(|i| self.find_previous_selectable(i))
294 });
295 self.selection = next;
296 if let Some(next) = next {
297 self.list_state.scroll_to_reveal_item(next);
298 }
299 }
300 } else {
301 self.selection = None;
302 }
303
304 cx.notify();
305 }
306
307 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
308 self.filter_editor.update(cx, |editor, cx| {
309 editor.set_text("", window, cx);
310 });
311 }
312
313 fn unarchive_thread(
314 &mut self,
315 thread: ThreadMetadata,
316 window: &mut Window,
317 cx: &mut Context<Self>,
318 ) {
319 if thread.folder_paths.is_empty() {
320 self.show_project_picker_for_thread(thread, window, cx);
321 return;
322 }
323
324 self.selection = None;
325 self.reset_filter_editor_text(window, cx);
326 cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
327 }
328
329 fn show_project_picker_for_thread(
330 &mut self,
331 thread: ThreadMetadata,
332 window: &mut Window,
333 cx: &mut Context<Self>,
334 ) {
335 let Some(workspace) = self.workspace.upgrade() else {
336 return;
337 };
338
339 let archive_view = cx.weak_entity();
340 let fs = workspace.read(cx).app_state().fs.clone();
341 let current_workspace_id = workspace.read(cx).database_id();
342 let sibling_workspace_ids: HashSet<WorkspaceId> = workspace
343 .read(cx)
344 .multi_workspace()
345 .and_then(|mw| mw.upgrade())
346 .map(|mw| {
347 mw.read(cx)
348 .workspaces()
349 .iter()
350 .filter_map(|ws| ws.read(cx).database_id())
351 .collect()
352 })
353 .unwrap_or_default();
354
355 workspace.update(cx, |workspace, cx| {
356 workspace.toggle_modal(window, cx, |window, cx| {
357 ProjectPickerModal::new(
358 thread,
359 fs,
360 archive_view,
361 current_workspace_id,
362 sibling_workspace_ids,
363 window,
364 cx,
365 )
366 });
367 });
368 }
369
370 fn is_selectable_item(&self, ix: usize) -> bool {
371 matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
372 }
373
374 fn find_next_selectable(&self, start: usize) -> Option<usize> {
375 (start..self.items.len()).find(|&i| self.is_selectable_item(i))
376 }
377
378 fn find_previous_selectable(&self, start: usize) -> Option<usize> {
379 (0..=start).rev().find(|&i| self.is_selectable_item(i))
380 }
381
382 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
383 self.select_next(&SelectNext, window, cx);
384 if self.selection.is_some() {
385 self.focus_handle.focus(window, cx);
386 }
387 }
388
389 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
390 self.select_previous(&SelectPrevious, window, cx);
391 if self.selection.is_some() {
392 self.focus_handle.focus(window, cx);
393 }
394 }
395
396 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
397 let next = match self.selection {
398 Some(ix) => self.find_next_selectable(ix + 1),
399 None => self.find_next_selectable(0),
400 };
401 if let Some(next) = next {
402 self.selection = Some(next);
403 self.list_state.scroll_to_reveal_item(next);
404 cx.notify();
405 }
406 }
407
408 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
409 match self.selection {
410 Some(ix) => {
411 if let Some(prev) = (ix > 0)
412 .then(|| self.find_previous_selectable(ix - 1))
413 .flatten()
414 {
415 self.selection = Some(prev);
416 self.list_state.scroll_to_reveal_item(prev);
417 } else {
418 self.selection = None;
419 self.focus_filter_editor(window, cx);
420 }
421 cx.notify();
422 }
423 None => {
424 let last = self.items.len().saturating_sub(1);
425 if let Some(prev) = self.find_previous_selectable(last) {
426 self.selection = Some(prev);
427 self.list_state.scroll_to_reveal_item(prev);
428 cx.notify();
429 }
430 }
431 }
432 }
433
434 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
435 if let Some(first) = self.find_next_selectable(0) {
436 self.selection = Some(first);
437 self.list_state.scroll_to_reveal_item(first);
438 cx.notify();
439 }
440 }
441
442 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
443 let last = self.items.len().saturating_sub(1);
444 if let Some(last) = self.find_previous_selectable(last) {
445 self.selection = Some(last);
446 self.list_state.scroll_to_reveal_item(last);
447 cx.notify();
448 }
449 }
450
451 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
452 let Some(ix) = self.selection else { return };
453 let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
454 return;
455 };
456
457 self.unarchive_thread(thread.clone(), window, cx);
458 }
459
460 fn render_list_entry(
461 &mut self,
462 ix: usize,
463 _window: &mut Window,
464 cx: &mut Context<Self>,
465 ) -> AnyElement {
466 let Some(item) = self.items.get(ix) else {
467 return div().into_any_element();
468 };
469
470 match item {
471 ArchiveListItem::BucketSeparator(bucket) => div()
472 .w_full()
473 .px_2p5()
474 .pt_3()
475 .pb_1()
476 .child(
477 Label::new(bucket.label())
478 .size(LabelSize::Small)
479 .color(Color::Muted),
480 )
481 .into_any_element(),
482 ArchiveListItem::Entry {
483 thread,
484 highlight_positions,
485 } => {
486 let id = SharedString::from(format!("archive-entry-{}", ix));
487
488 let is_focused = self.selection == Some(ix);
489 let is_hovered = self.hovered_index == Some(ix);
490
491 let focus_handle = self.focus_handle.clone();
492
493 let timestamp =
494 format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at));
495
496 let icon_from_external_svg = self
497 .agent_server_store
498 .upgrade()
499 .and_then(|store| store.read(cx).agent_icon(&thread.agent_id));
500
501 let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
502 IconName::ZedAgent
503 } else {
504 IconName::Sparkle
505 };
506
507 ThreadItem::new(id, thread.title.clone())
508 .icon(icon)
509 .when_some(icon_from_external_svg, |this, svg| {
510 this.custom_icon_from_external_svg(svg)
511 })
512 .timestamp(timestamp)
513 .highlight_positions(highlight_positions.clone())
514 .project_paths(thread.folder_paths.paths_owned())
515 .focused(is_focused)
516 .hovered(is_hovered)
517 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
518 if *is_hovered {
519 this.hovered_index = Some(ix);
520 } else if this.hovered_index == Some(ix) {
521 this.hovered_index = None;
522 }
523 cx.notify();
524 }))
525 .action_slot(
526 IconButton::new("delete-thread", IconName::Trash)
527 .style(ButtonStyle::Filled)
528 .icon_size(IconSize::Small)
529 .icon_color(Color::Muted)
530 .tooltip({
531 move |_window, cx| {
532 Tooltip::for_action_in(
533 "Delete Thread",
534 &RemoveSelectedThread,
535 &focus_handle,
536 cx,
537 )
538 }
539 })
540 .on_click({
541 let agent = thread.agent_id.clone();
542 let session_id = thread.session_id.clone();
543 cx.listener(move |this, _, _, cx| {
544 this.preserve_selection_on_next_update = true;
545 this.delete_thread(session_id.clone(), agent.clone(), cx);
546 cx.stop_propagation();
547 })
548 }),
549 )
550 .tooltip(move |_, cx| Tooltip::for_action("Restore Thread", &menu::Confirm, cx))
551 .on_click({
552 let thread = thread.clone();
553 cx.listener(move |this, _, window, cx| {
554 this.unarchive_thread(thread.clone(), window, cx);
555 })
556 })
557 .into_any_element()
558 }
559 }
560 }
561
562 fn remove_selected_thread(
563 &mut self,
564 _: &RemoveSelectedThread,
565 _window: &mut Window,
566 cx: &mut Context<Self>,
567 ) {
568 let Some(ix) = self.selection else { return };
569 let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
570 return;
571 };
572
573 self.preserve_selection_on_next_update = true;
574 self.delete_thread(thread.session_id.clone(), thread.agent_id.clone(), cx);
575 }
576
577 fn delete_thread(
578 &mut self,
579 session_id: acp::SessionId,
580 agent: AgentId,
581 cx: &mut Context<Self>,
582 ) {
583 ThreadMetadataStore::global(cx)
584 .update(cx, |store, cx| store.delete(session_id.clone(), cx));
585
586 let agent = Agent::from(agent);
587
588 let Some(agent_connection_store) = self.agent_connection_store.upgrade() else {
589 return;
590 };
591 let fs = <dyn Fs>::global(cx);
592
593 let task = agent_connection_store.update(cx, |store, cx| {
594 store
595 .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx)
596 .read(cx)
597 .wait_for_connection()
598 });
599 cx.spawn(async move |_this, cx| {
600 let state = task.await?;
601 let task = cx.update(|cx| {
602 if let Some(list) = state.connection.session_list(cx) {
603 list.delete_session(&session_id, cx)
604 } else {
605 Task::ready(Ok(()))
606 }
607 });
608 task.await
609 })
610 .detach_and_log_err(cx);
611 }
612
613 fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
614 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
615 let sidebar_on_left = matches!(
616 AgentSettings::get_global(cx).sidebar_side(),
617 settings::SidebarSide::Left
618 );
619 let traffic_lights =
620 cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
621 let header_height = platform_title_bar_height(window);
622 let show_focus_keybinding =
623 self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
624
625 h_flex()
626 .h(header_height)
627 .mt_px()
628 .pb_px()
629 .map(|this| {
630 if traffic_lights {
631 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
632 } else {
633 this.pl_1p5()
634 }
635 })
636 .pr_1p5()
637 .gap_1()
638 .justify_between()
639 .border_b_1()
640 .border_color(cx.theme().colors().border)
641 .when(traffic_lights, |this| {
642 this.child(Divider::vertical().color(ui::DividerColor::Border))
643 })
644 .child(
645 h_flex()
646 .ml_1()
647 .min_w_0()
648 .w_full()
649 .gap_1()
650 .child(
651 Icon::new(IconName::MagnifyingGlass)
652 .size(IconSize::Small)
653 .color(Color::Muted),
654 )
655 .child(self.filter_editor.clone()),
656 )
657 .when(show_focus_keybinding, |this| {
658 this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
659 })
660 .when(has_query, |this| {
661 this.child(
662 IconButton::new("clear-filter", IconName::Close)
663 .icon_size(IconSize::Small)
664 .tooltip(Tooltip::text("Clear Search"))
665 .on_click(cx.listener(|this, _, window, cx| {
666 this.reset_filter_editor_text(window, cx);
667 this.update_items(cx);
668 })),
669 )
670 })
671 }
672}
673
674pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
675 let now = Utc::now();
676 let duration = now.signed_duration_since(entry_time);
677
678 let minutes = duration.num_minutes();
679 let hours = duration.num_hours();
680 let days = duration.num_days();
681 let weeks = days / 7;
682 let months = days / 30;
683
684 if minutes < 60 {
685 format!("{}m", minutes.max(1))
686 } else if hours < 24 {
687 format!("{}h", hours.max(1))
688 } else if days < 7 {
689 format!("{}d", days.max(1))
690 } else if weeks < 4 {
691 format!("{}w", weeks.max(1))
692 } else {
693 format!("{}mo", months.max(1))
694 }
695}
696
697impl Focusable for ThreadsArchiveView {
698 fn focus_handle(&self, _cx: &App) -> FocusHandle {
699 self.focus_handle.clone()
700 }
701}
702
703impl Render for ThreadsArchiveView {
704 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
705 let is_empty = self.items.is_empty();
706 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
707
708 let content = if is_empty {
709 let message = if has_query {
710 "No threads match your search."
711 } else {
712 "No archived or hidden threads yet."
713 };
714
715 v_flex()
716 .flex_1()
717 .justify_center()
718 .items_center()
719 .child(
720 Label::new(message)
721 .size(LabelSize::Small)
722 .color(Color::Muted),
723 )
724 .into_any_element()
725 } else {
726 v_flex()
727 .flex_1()
728 .overflow_hidden()
729 .child(
730 list(
731 self.list_state.clone(),
732 cx.processor(Self::render_list_entry),
733 )
734 .flex_1()
735 .size_full(),
736 )
737 .vertical_scrollbar_for(&self.list_state, window, cx)
738 .into_any_element()
739 };
740
741 v_flex()
742 .key_context("ThreadsArchiveView")
743 .track_focus(&self.focus_handle)
744 .on_action(cx.listener(Self::select_next))
745 .on_action(cx.listener(Self::select_previous))
746 .on_action(cx.listener(Self::editor_move_down))
747 .on_action(cx.listener(Self::editor_move_up))
748 .on_action(cx.listener(Self::select_first))
749 .on_action(cx.listener(Self::select_last))
750 .on_action(cx.listener(Self::confirm))
751 .on_action(cx.listener(Self::remove_selected_thread))
752 .size_full()
753 .child(self.render_header(window, cx))
754 .child(content)
755 }
756}
757
758struct ProjectPickerModal {
759 picker: Entity<Picker<ProjectPickerDelegate>>,
760 _subscription: Subscription,
761}
762
763impl ProjectPickerModal {
764 fn new(
765 thread: ThreadMetadata,
766 fs: Arc<dyn Fs>,
767 archive_view: WeakEntity<ThreadsArchiveView>,
768 current_workspace_id: Option<WorkspaceId>,
769 sibling_workspace_ids: HashSet<WorkspaceId>,
770 window: &mut Window,
771 cx: &mut Context<Self>,
772 ) -> Self {
773 let delegate = ProjectPickerDelegate {
774 thread,
775 archive_view,
776 workspaces: Vec::new(),
777 filtered_entries: Vec::new(),
778 selected_index: 0,
779 current_workspace_id,
780 sibling_workspace_ids,
781 focus_handle: cx.focus_handle(),
782 };
783
784 let picker = cx.new(|cx| {
785 Picker::list(delegate, window, cx)
786 .list_measure_all()
787 .modal(false)
788 });
789
790 let picker_focus_handle = picker.focus_handle(cx);
791 picker.update(cx, |picker, _| {
792 picker.delegate.focus_handle = picker_focus_handle;
793 });
794
795 let _subscription =
796 cx.subscribe(&picker, |_this: &mut Self, _, _event: &DismissEvent, cx| {
797 cx.emit(DismissEvent);
798 });
799
800 let db = WorkspaceDb::global(cx);
801 cx.spawn_in(window, async move |this, cx| {
802 let workspaces = db
803 .recent_workspaces_on_disk(fs.as_ref())
804 .await
805 .log_err()
806 .unwrap_or_default();
807 let workspaces = resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
808 this.update_in(cx, move |this, window, cx| {
809 this.picker.update(cx, move |picker, cx| {
810 picker.delegate.workspaces = workspaces;
811 picker.update_matches(picker.query(cx), window, cx)
812 })
813 })
814 .ok();
815 })
816 .detach();
817
818 picker.focus_handle(cx).focus(window, cx);
819
820 Self {
821 picker,
822 _subscription,
823 }
824 }
825}
826
827impl EventEmitter<DismissEvent> for ProjectPickerModal {}
828
829impl Focusable for ProjectPickerModal {
830 fn focus_handle(&self, cx: &App) -> FocusHandle {
831 self.picker.focus_handle(cx)
832 }
833}
834
835impl ModalView for ProjectPickerModal {}
836
837impl Render for ProjectPickerModal {
838 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
839 v_flex()
840 .key_context("ProjectPickerModal")
841 .elevation_3(cx)
842 .w(rems(34.))
843 .on_action(cx.listener(|this, _: &workspace::Open, window, cx| {
844 this.picker.update(cx, |picker, cx| {
845 picker.delegate.open_local_folder(window, cx)
846 })
847 }))
848 .child(self.picker.clone())
849 }
850}
851
852enum ProjectPickerEntry {
853 Header(SharedString),
854 Workspace(StringMatch),
855}
856
857struct ProjectPickerDelegate {
858 thread: ThreadMetadata,
859 archive_view: WeakEntity<ThreadsArchiveView>,
860 current_workspace_id: Option<WorkspaceId>,
861 sibling_workspace_ids: HashSet<WorkspaceId>,
862 workspaces: Vec<(
863 WorkspaceId,
864 SerializedWorkspaceLocation,
865 PathList,
866 DateTime<Utc>,
867 )>,
868 filtered_entries: Vec<ProjectPickerEntry>,
869 selected_index: usize,
870 focus_handle: FocusHandle,
871}
872
873impl ProjectPickerDelegate {
874 fn update_working_directories_and_unarchive(
875 &mut self,
876 paths: PathList,
877 window: &mut Window,
878 cx: &mut Context<Picker<Self>>,
879 ) {
880 self.thread.folder_paths = paths.clone();
881 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
882 store.update_working_directories(&self.thread.session_id, paths, cx);
883 });
884
885 self.archive_view
886 .update(cx, |view, cx| {
887 view.selection = None;
888 view.reset_filter_editor_text(window, cx);
889 cx.emit(ThreadsArchiveViewEvent::Unarchive {
890 thread: self.thread.clone(),
891 });
892 })
893 .log_err();
894 }
895
896 fn is_current_workspace(&self, workspace_id: WorkspaceId) -> bool {
897 self.current_workspace_id == Some(workspace_id)
898 }
899
900 fn is_sibling_workspace(&self, workspace_id: WorkspaceId) -> bool {
901 self.sibling_workspace_ids.contains(&workspace_id)
902 && !self.is_current_workspace(workspace_id)
903 }
904
905 fn selected_match(&self) -> Option<&StringMatch> {
906 match self.filtered_entries.get(self.selected_index)? {
907 ProjectPickerEntry::Workspace(hit) => Some(hit),
908 ProjectPickerEntry::Header(_) => None,
909 }
910 }
911
912 fn open_local_folder(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
913 let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
914 files: false,
915 directories: true,
916 multiple: false,
917 prompt: None,
918 });
919 cx.spawn_in(window, async move |this, cx| {
920 let Ok(Ok(Some(paths))) = paths_receiver.await else {
921 return;
922 };
923 if paths.is_empty() {
924 return;
925 }
926
927 let work_dirs = PathList::new(&paths);
928
929 this.update_in(cx, |this, window, cx| {
930 this.delegate
931 .update_working_directories_and_unarchive(work_dirs, window, cx);
932 cx.emit(DismissEvent);
933 })
934 .log_err();
935 })
936 .detach();
937 }
938}
939
940impl EventEmitter<DismissEvent> for ProjectPickerDelegate {}
941
942impl PickerDelegate for ProjectPickerDelegate {
943 type ListItem = AnyElement;
944
945 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
946 format!("Associate the \"{}\" thread with...", self.thread.title).into()
947 }
948
949 fn render_editor(
950 &self,
951 editor: &Arc<dyn ErasedEditor>,
952 window: &mut Window,
953 cx: &mut Context<Picker<Self>>,
954 ) -> Div {
955 h_flex()
956 .flex_none()
957 .h_9()
958 .px_2p5()
959 .justify_between()
960 .border_b_1()
961 .border_color(cx.theme().colors().border_variant)
962 .child(editor.render(window, cx))
963 }
964
965 fn match_count(&self) -> usize {
966 self.filtered_entries.len()
967 }
968
969 fn selected_index(&self) -> usize {
970 self.selected_index
971 }
972
973 fn set_selected_index(
974 &mut self,
975 ix: usize,
976 _window: &mut Window,
977 _cx: &mut Context<Picker<Self>>,
978 ) {
979 self.selected_index = ix;
980 }
981
982 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
983 matches!(
984 self.filtered_entries.get(ix),
985 Some(ProjectPickerEntry::Workspace(_))
986 )
987 }
988
989 fn update_matches(
990 &mut self,
991 query: String,
992 _window: &mut Window,
993 cx: &mut Context<Picker<Self>>,
994 ) -> Task<()> {
995 let query = query.trim_start();
996 let smart_case = query.chars().any(|c| c.is_uppercase());
997 let is_empty_query = query.is_empty();
998
999 let sibling_candidates: Vec<_> = self
1000 .workspaces
1001 .iter()
1002 .enumerate()
1003 .filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id))
1004 .map(|(id, (_, _, paths, _))| {
1005 let combined_string = paths
1006 .ordered_paths()
1007 .map(|path| path.compact().to_string_lossy().into_owned())
1008 .collect::<Vec<_>>()
1009 .join("");
1010 StringMatchCandidate::new(id, &combined_string)
1011 })
1012 .collect();
1013
1014 let mut sibling_matches = smol::block_on(fuzzy::match_strings(
1015 &sibling_candidates,
1016 query,
1017 smart_case,
1018 true,
1019 100,
1020 &Default::default(),
1021 cx.background_executor().clone(),
1022 ));
1023
1024 sibling_matches.sort_unstable_by(|a, b| {
1025 b.score
1026 .partial_cmp(&a.score)
1027 .unwrap_or(std::cmp::Ordering::Equal)
1028 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1029 });
1030
1031 let recent_candidates: Vec<_> = self
1032 .workspaces
1033 .iter()
1034 .enumerate()
1035 .filter(|(_, (id, _, _, _))| {
1036 !self.is_current_workspace(*id) && !self.is_sibling_workspace(*id)
1037 })
1038 .map(|(id, (_, _, paths, _))| {
1039 let combined_string = paths
1040 .ordered_paths()
1041 .map(|path| path.compact().to_string_lossy().into_owned())
1042 .collect::<Vec<_>>()
1043 .join("");
1044 StringMatchCandidate::new(id, &combined_string)
1045 })
1046 .collect();
1047
1048 let mut recent_matches = smol::block_on(fuzzy::match_strings(
1049 &recent_candidates,
1050 query,
1051 smart_case,
1052 true,
1053 100,
1054 &Default::default(),
1055 cx.background_executor().clone(),
1056 ));
1057
1058 recent_matches.sort_unstable_by(|a, b| {
1059 b.score
1060 .partial_cmp(&a.score)
1061 .unwrap_or(std::cmp::Ordering::Equal)
1062 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1063 });
1064
1065 let mut entries = Vec::new();
1066
1067 let has_siblings_to_show = if is_empty_query {
1068 !sibling_candidates.is_empty()
1069 } else {
1070 !sibling_matches.is_empty()
1071 };
1072
1073 if has_siblings_to_show {
1074 entries.push(ProjectPickerEntry::Header("This Window".into()));
1075
1076 if is_empty_query {
1077 for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
1078 if self.is_sibling_workspace(*workspace_id) {
1079 entries.push(ProjectPickerEntry::Workspace(StringMatch {
1080 candidate_id: id,
1081 score: 0.0,
1082 positions: Vec::new(),
1083 string: String::new(),
1084 }));
1085 }
1086 }
1087 } else {
1088 for m in sibling_matches {
1089 entries.push(ProjectPickerEntry::Workspace(m));
1090 }
1091 }
1092 }
1093
1094 let has_recent_to_show = if is_empty_query {
1095 !recent_candidates.is_empty()
1096 } else {
1097 !recent_matches.is_empty()
1098 };
1099
1100 if has_recent_to_show {
1101 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1102
1103 if is_empty_query {
1104 for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
1105 if !self.is_current_workspace(*workspace_id)
1106 && !self.is_sibling_workspace(*workspace_id)
1107 {
1108 entries.push(ProjectPickerEntry::Workspace(StringMatch {
1109 candidate_id: id,
1110 score: 0.0,
1111 positions: Vec::new(),
1112 string: String::new(),
1113 }));
1114 }
1115 }
1116 } else {
1117 for m in recent_matches {
1118 entries.push(ProjectPickerEntry::Workspace(m));
1119 }
1120 }
1121 }
1122
1123 self.filtered_entries = entries;
1124
1125 self.selected_index = self
1126 .filtered_entries
1127 .iter()
1128 .position(|e| matches!(e, ProjectPickerEntry::Workspace(_)))
1129 .unwrap_or(0);
1130
1131 Task::ready(())
1132 }
1133
1134 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1135 let candidate_id = match self.filtered_entries.get(self.selected_index) {
1136 Some(ProjectPickerEntry::Workspace(hit)) => hit.candidate_id,
1137 _ => return,
1138 };
1139 let Some((_workspace_id, _location, paths, _)) = self.workspaces.get(candidate_id) else {
1140 return;
1141 };
1142
1143 self.update_working_directories_and_unarchive(paths.clone(), window, cx);
1144 cx.emit(DismissEvent);
1145 }
1146
1147 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
1148
1149 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1150 let text = if self.workspaces.is_empty() {
1151 "No recent projects found"
1152 } else {
1153 "No matches"
1154 };
1155 Some(text.into())
1156 }
1157
1158 fn render_match(
1159 &self,
1160 ix: usize,
1161 selected: bool,
1162 window: &mut Window,
1163 cx: &mut Context<Picker<Self>>,
1164 ) -> Option<Self::ListItem> {
1165 match self.filtered_entries.get(ix)? {
1166 ProjectPickerEntry::Header(title) => Some(
1167 v_flex()
1168 .w_full()
1169 .gap_1()
1170 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1171 .child(ListSubHeader::new(title.clone()).inset(true))
1172 .into_any_element(),
1173 ),
1174 ProjectPickerEntry::Workspace(hit) => {
1175 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1176
1177 let ordered_paths: Vec<_> = paths
1178 .ordered_paths()
1179 .map(|p| p.compact().to_string_lossy().to_string())
1180 .collect();
1181
1182 let tooltip_path: SharedString = ordered_paths.join("\n").into();
1183
1184 let mut path_start_offset = 0;
1185 let match_labels: Vec<_> = paths
1186 .ordered_paths()
1187 .map(|p| p.compact())
1188 .map(|path| {
1189 let path_string = path.to_string_lossy();
1190 let path_text = path_string.to_string();
1191 let path_byte_len = path_text.len();
1192
1193 let path_positions: Vec<usize> = hit
1194 .positions
1195 .iter()
1196 .copied()
1197 .skip_while(|pos| *pos < path_start_offset)
1198 .take_while(|pos| *pos < path_start_offset + path_byte_len)
1199 .map(|pos| pos - path_start_offset)
1200 .collect();
1201
1202 let file_name_match = path.file_name().map(|file_name| {
1203 let file_name_text = file_name.to_string_lossy().into_owned();
1204 let file_name_start = path_byte_len - file_name_text.len();
1205 let highlight_positions: Vec<usize> = path_positions
1206 .iter()
1207 .copied()
1208 .skip_while(|pos| *pos < file_name_start)
1209 .take_while(|pos| *pos < file_name_start + file_name_text.len())
1210 .map(|pos| pos - file_name_start)
1211 .collect();
1212 HighlightedMatch {
1213 text: file_name_text,
1214 highlight_positions,
1215 color: Color::Default,
1216 }
1217 });
1218
1219 path_start_offset += path_byte_len;
1220 file_name_match
1221 })
1222 .collect();
1223
1224 let highlighted_match = HighlightedMatchWithPaths {
1225 prefix: match location {
1226 SerializedWorkspaceLocation::Remote(options) => {
1227 Some(SharedString::from(options.display_name()))
1228 }
1229 _ => None,
1230 },
1231 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1232 paths: Vec::new(),
1233 };
1234
1235 Some(
1236 ListItem::new(ix)
1237 .toggle_state(selected)
1238 .inset(true)
1239 .spacing(ListItemSpacing::Sparse)
1240 .child(
1241 h_flex()
1242 .gap_3()
1243 .flex_grow()
1244 .child(highlighted_match.render(window, cx)),
1245 )
1246 .tooltip(Tooltip::text(tooltip_path))
1247 .into_any_element(),
1248 )
1249 }
1250 }
1251 }
1252
1253 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1254 let has_selection = self.selected_match().is_some();
1255 let focus_handle = self.focus_handle.clone();
1256
1257 Some(
1258 h_flex()
1259 .flex_1()
1260 .p_1p5()
1261 .gap_1()
1262 .justify_end()
1263 .border_t_1()
1264 .border_color(cx.theme().colors().border_variant)
1265 .child(
1266 Button::new("open_local_folder", "Choose from Local Folders")
1267 .key_binding(KeyBinding::for_action_in(
1268 &workspace::Open::default(),
1269 &focus_handle,
1270 cx,
1271 ))
1272 .on_click(cx.listener(|this, _, window, cx| {
1273 this.delegate.open_local_folder(window, cx);
1274 })),
1275 )
1276 .child(
1277 Button::new("select_project", "Select")
1278 .disabled(!has_selection)
1279 .key_binding(KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx))
1280 .on_click(cx.listener(move |picker, _, window, cx| {
1281 picker.delegate.confirm(false, window, cx);
1282 })),
1283 )
1284 .into_any(),
1285 )
1286 }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292
1293 #[test]
1294 fn test_fuzzy_match_positions_returns_byte_indices() {
1295 // "🔥abc" — the fire emoji is 4 bytes, so 'a' starts at byte 4, 'b' at 5, 'c' at 6.
1296 let text = "🔥abc";
1297 let positions = fuzzy_match_positions("ab", text).expect("should match");
1298 assert_eq!(positions, vec![4, 5]);
1299
1300 // Verify positions are valid char boundaries (this is the assertion that
1301 // panicked before the fix).
1302 for &pos in &positions {
1303 assert!(
1304 text.is_char_boundary(pos),
1305 "position {pos} is not a valid UTF-8 boundary in {text:?}"
1306 );
1307 }
1308 }
1309
1310 #[test]
1311 fn test_fuzzy_match_positions_ascii_still_works() {
1312 let positions = fuzzy_match_positions("he", "hello").expect("should match");
1313 assert_eq!(positions, vec![0, 1]);
1314 }
1315
1316 #[test]
1317 fn test_fuzzy_match_positions_case_insensitive() {
1318 let positions = fuzzy_match_positions("HE", "hello").expect("should match");
1319 assert_eq!(positions, vec![0, 1]);
1320 }
1321
1322 #[test]
1323 fn test_fuzzy_match_positions_no_match() {
1324 assert!(fuzzy_match_positions("xyz", "hello").is_none());
1325 }
1326
1327 #[test]
1328 fn test_fuzzy_match_positions_multi_byte_interior() {
1329 // "café" — 'é' is 2 bytes (0xC3 0xA9), so 'f' starts at byte 4, 'é' at byte 5.
1330 let text = "café";
1331 let positions = fuzzy_match_positions("fé", text).expect("should match");
1332 // 'c'=0, 'a'=1, 'f'=2, 'é'=3..4 — wait, let's verify:
1333 // Actually: c=1 byte, a=1 byte, f=1 byte, é=2 bytes
1334 // So byte positions: c=0, a=1, f=2, é=3
1335 assert_eq!(positions, vec![2, 3]);
1336 for &pos in &positions {
1337 assert!(
1338 text.is_char_boundary(pos),
1339 "position {pos} is not a valid UTF-8 boundary in {text:?}"
1340 );
1341 }
1342 }
1343}