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