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::{
  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("", 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}