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