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