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