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