threads_archive_view.rs

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