threads_archive_view.rs

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