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