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