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