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