threads_archive_view.rs

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