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