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