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