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