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