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