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