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