threads_archive_view.rs

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