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