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