1use crate::RemoveSelectedThread;
2use agent2::{HistoryEntry, HistoryStore};
3use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
4use editor::{Editor, EditorEvent};
5use fuzzy::{StringMatch, StringMatchCandidate};
6use gpui::{
7 App, Empty, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
8 UniformListScrollHandle, Window, uniform_list,
9};
10use std::{fmt::Display, ops::Range, sync::Arc};
11use time::{OffsetDateTime, UtcOffset};
12use ui::{
13 HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
14 Tooltip, prelude::*,
15};
16use util::ResultExt;
17
18pub struct AcpThreadHistory {
19 pub(crate) history_store: Entity<HistoryStore>,
20 scroll_handle: UniformListScrollHandle,
21 selected_index: usize,
22 hovered_index: Option<usize>,
23 search_editor: Entity<Editor>,
24 all_entries: Arc<Vec<HistoryEntry>>,
25 // When the search is empty, we display date separators between history entries
26 // This vector contains an enum of either a separator or an actual entry
27 separated_items: Vec<ListItemType>,
28 // Maps entry indexes to list item indexes
29 separated_item_indexes: Vec<u32>,
30 _separated_items_task: Option<Task<()>>,
31 search_state: SearchState,
32 scrollbar_visibility: bool,
33 scrollbar_state: ScrollbarState,
34 local_timezone: UtcOffset,
35 _subscriptions: Vec<gpui::Subscription>,
36}
37
38enum SearchState {
39 Empty,
40 Searching {
41 query: SharedString,
42 _task: Task<()>,
43 },
44 Searched {
45 query: SharedString,
46 matches: Vec<StringMatch>,
47 },
48}
49
50enum ListItemType {
51 BucketSeparator(TimeBucket),
52 Entry {
53 index: usize,
54 format: EntryTimeFormat,
55 },
56}
57
58pub enum ThreadHistoryEvent {
59 Open(HistoryEntry),
60}
61
62impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
63
64impl AcpThreadHistory {
65 pub(crate) fn new(
66 history_store: Entity<agent2::HistoryStore>,
67 window: &mut Window,
68 cx: &mut Context<Self>,
69 ) -> Self {
70 let search_editor = cx.new(|cx| {
71 let mut editor = Editor::single_line(window, cx);
72 editor.set_placeholder_text("Search threads...", cx);
73 editor
74 });
75
76 let search_editor_subscription =
77 cx.subscribe(&search_editor, |this, search_editor, event, cx| {
78 if let EditorEvent::BufferEdited = event {
79 let query = search_editor.read(cx).text(cx);
80 this.search(query.into(), cx);
81 }
82 });
83
84 let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
85 this.update_all_entries(cx);
86 });
87
88 let scroll_handle = UniformListScrollHandle::default();
89 let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
90
91 let mut this = Self {
92 history_store,
93 scroll_handle,
94 selected_index: 0,
95 hovered_index: None,
96 search_state: SearchState::Empty,
97 all_entries: Default::default(),
98 separated_items: Default::default(),
99 separated_item_indexes: Default::default(),
100 search_editor,
101 scrollbar_visibility: true,
102 scrollbar_state,
103 local_timezone: UtcOffset::from_whole_seconds(
104 chrono::Local::now().offset().local_minus_utc(),
105 )
106 .unwrap(),
107 _subscriptions: vec![search_editor_subscription, history_store_subscription],
108 _separated_items_task: None,
109 };
110 this.update_all_entries(cx);
111 this
112 }
113
114 fn update_all_entries(&mut self, cx: &mut Context<Self>) {
115 let new_entries: Arc<Vec<HistoryEntry>> = self
116 .history_store
117 .update(cx, |store, cx| store.entries(cx))
118 .into();
119
120 self._separated_items_task.take();
121
122 let mut items = Vec::with_capacity(new_entries.len() + 1);
123 let mut indexes = Vec::with_capacity(new_entries.len() + 1);
124
125 let bg_task = cx.background_spawn(async move {
126 let mut bucket = None;
127 let today = Local::now().naive_local().date();
128
129 for (index, entry) in new_entries.iter().enumerate() {
130 let entry_date = entry
131 .updated_at()
132 .with_timezone(&Local)
133 .naive_local()
134 .date();
135 let entry_bucket = TimeBucket::from_dates(today, entry_date);
136
137 if Some(entry_bucket) != bucket {
138 bucket = Some(entry_bucket);
139 items.push(ListItemType::BucketSeparator(entry_bucket));
140 }
141
142 indexes.push(items.len() as u32);
143 items.push(ListItemType::Entry {
144 index,
145 format: entry_bucket.into(),
146 });
147 }
148 (new_entries, items, indexes)
149 });
150
151 let task = cx.spawn(async move |this, cx| {
152 let (new_entries, items, indexes) = bg_task.await;
153 this.update(cx, |this, cx| {
154 let previously_selected_entry =
155 this.all_entries.get(this.selected_index).map(|e| e.id());
156
157 this.all_entries = new_entries;
158 this.separated_items = items;
159 this.separated_item_indexes = indexes;
160
161 match &this.search_state {
162 SearchState::Empty => {
163 if this.selected_index >= this.all_entries.len() {
164 this.set_selected_entry_index(
165 this.all_entries.len().saturating_sub(1),
166 cx,
167 );
168 } else if let Some(prev_id) = previously_selected_entry
169 && let Some(new_ix) = this
170 .all_entries
171 .iter()
172 .position(|probe| probe.id() == prev_id)
173 {
174 this.set_selected_entry_index(new_ix, cx);
175 }
176 }
177 SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => {
178 this.search(query.clone(), cx);
179 }
180 }
181
182 cx.notify();
183 })
184 .log_err();
185 });
186 self._separated_items_task = Some(task);
187 }
188
189 fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
190 if query.is_empty() {
191 self.search_state = SearchState::Empty;
192 cx.notify();
193 return;
194 }
195
196 let all_entries = self.all_entries.clone();
197
198 let fuzzy_search_task = cx.background_spawn({
199 let query = query.clone();
200 let executor = cx.background_executor().clone();
201 async move {
202 let mut candidates = Vec::with_capacity(all_entries.len());
203
204 for (idx, entry) in all_entries.iter().enumerate() {
205 candidates.push(StringMatchCandidate::new(idx, entry.title()));
206 }
207
208 const MAX_MATCHES: usize = 100;
209
210 fuzzy::match_strings(
211 &candidates,
212 &query,
213 false,
214 true,
215 MAX_MATCHES,
216 &Default::default(),
217 executor,
218 )
219 .await
220 }
221 });
222
223 let task = cx.spawn({
224 let query = query.clone();
225 async move |this, cx| {
226 let matches = fuzzy_search_task.await;
227
228 this.update(cx, |this, cx| {
229 let SearchState::Searching {
230 query: current_query,
231 _task,
232 } = &this.search_state
233 else {
234 return;
235 };
236
237 if &query == current_query {
238 this.search_state = SearchState::Searched {
239 query: query.clone(),
240 matches,
241 };
242
243 this.set_selected_entry_index(0, cx);
244 cx.notify();
245 };
246 })
247 .log_err();
248 }
249 });
250
251 self.search_state = SearchState::Searching { query, _task: task };
252 cx.notify();
253 }
254
255 fn matched_count(&self) -> usize {
256 match &self.search_state {
257 SearchState::Empty => self.all_entries.len(),
258 SearchState::Searching { .. } => 0,
259 SearchState::Searched { matches, .. } => matches.len(),
260 }
261 }
262
263 fn list_item_count(&self) -> usize {
264 match &self.search_state {
265 SearchState::Empty => self.separated_items.len(),
266 SearchState::Searching { .. } => 0,
267 SearchState::Searched { matches, .. } => matches.len(),
268 }
269 }
270
271 fn search_produced_no_matches(&self) -> bool {
272 match &self.search_state {
273 SearchState::Empty => false,
274 SearchState::Searching { .. } => false,
275 SearchState::Searched { matches, .. } => matches.is_empty(),
276 }
277 }
278
279 fn get_match(&self, ix: usize) -> Option<&HistoryEntry> {
280 match &self.search_state {
281 SearchState::Empty => self.all_entries.get(ix),
282 SearchState::Searching { .. } => None,
283 SearchState::Searched { matches, .. } => matches
284 .get(ix)
285 .and_then(|m| self.all_entries.get(m.candidate_id)),
286 }
287 }
288
289 pub fn select_previous(
290 &mut self,
291 _: &menu::SelectPrevious,
292 _window: &mut Window,
293 cx: &mut Context<Self>,
294 ) {
295 let count = self.matched_count();
296 if count > 0 {
297 if self.selected_index == 0 {
298 self.set_selected_entry_index(count - 1, cx);
299 } else {
300 self.set_selected_entry_index(self.selected_index - 1, cx);
301 }
302 }
303 }
304
305 pub fn select_next(
306 &mut self,
307 _: &menu::SelectNext,
308 _window: &mut Window,
309 cx: &mut Context<Self>,
310 ) {
311 let count = self.matched_count();
312 if count > 0 {
313 if self.selected_index == count - 1 {
314 self.set_selected_entry_index(0, cx);
315 } else {
316 self.set_selected_entry_index(self.selected_index + 1, cx);
317 }
318 }
319 }
320
321 fn select_first(
322 &mut self,
323 _: &menu::SelectFirst,
324 _window: &mut Window,
325 cx: &mut Context<Self>,
326 ) {
327 let count = self.matched_count();
328 if count > 0 {
329 self.set_selected_entry_index(0, cx);
330 }
331 }
332
333 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
334 let count = self.matched_count();
335 if count > 0 {
336 self.set_selected_entry_index(count - 1, cx);
337 }
338 }
339
340 fn set_selected_entry_index(&mut self, entry_index: usize, cx: &mut Context<Self>) {
341 self.selected_index = entry_index;
342
343 let scroll_ix = match self.search_state {
344 SearchState::Empty | SearchState::Searching { .. } => self
345 .separated_item_indexes
346 .get(entry_index)
347 .map(|ix| *ix as usize)
348 .unwrap_or(entry_index + 1),
349 SearchState::Searched { .. } => entry_index,
350 };
351
352 self.scroll_handle
353 .scroll_to_item(scroll_ix, ScrollStrategy::Top);
354
355 cx.notify();
356 }
357
358 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
359 if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) {
360 return None;
361 }
362
363 Some(
364 div()
365 .occlude()
366 .id("thread-history-scroll")
367 .h_full()
368 .bg(cx.theme().colors().panel_background.opacity(0.8))
369 .border_l_1()
370 .border_color(cx.theme().colors().border_variant)
371 .absolute()
372 .right_1()
373 .top_0()
374 .bottom_0()
375 .w_4()
376 .pl_1()
377 .cursor_default()
378 .on_mouse_move(cx.listener(|_, _, _window, cx| {
379 cx.notify();
380 cx.stop_propagation()
381 }))
382 .on_hover(|_, _window, cx| {
383 cx.stop_propagation();
384 })
385 .on_any_mouse_down(|_, _window, cx| {
386 cx.stop_propagation();
387 })
388 .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
389 cx.notify();
390 }))
391 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
392 )
393 }
394
395 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
396 self.confirm_entry(self.selected_index, cx);
397 }
398
399 fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
400 let Some(entry) = self.get_match(ix) else {
401 return;
402 };
403 cx.emit(ThreadHistoryEvent::Open(entry.clone()));
404 }
405
406 fn remove_selected_thread(
407 &mut self,
408 _: &RemoveSelectedThread,
409 _window: &mut Window,
410 cx: &mut Context<Self>,
411 ) {
412 self.remove_thread(self.selected_index, cx)
413 }
414
415 fn remove_thread(&mut self, ix: usize, cx: &mut Context<Self>) {
416 let Some(entry) = self.get_match(ix) else {
417 return;
418 };
419
420 let task = match entry {
421 HistoryEntry::AcpThread(thread) => self
422 .history_store
423 .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)),
424 HistoryEntry::TextThread(context) => self.history_store.update(cx, |this, cx| {
425 this.delete_text_thread(context.path.clone(), cx)
426 }),
427 };
428 task.detach_and_log_err(cx);
429 }
430
431 fn list_items(
432 &mut self,
433 range: Range<usize>,
434 _window: &mut Window,
435 cx: &mut Context<Self>,
436 ) -> Vec<AnyElement> {
437 match &self.search_state {
438 SearchState::Empty => self
439 .separated_items
440 .get(range)
441 .iter()
442 .flat_map(|items| {
443 items
444 .iter()
445 .map(|item| self.render_list_item(item, vec![], cx))
446 })
447 .collect(),
448 SearchState::Searched { matches, .. } => matches[range]
449 .iter()
450 .filter_map(|m| {
451 let entry = self.all_entries.get(m.candidate_id)?;
452 Some(self.render_history_entry(
453 entry,
454 EntryTimeFormat::DateAndTime,
455 m.candidate_id,
456 m.positions.clone(),
457 cx,
458 ))
459 })
460 .collect(),
461 SearchState::Searching { .. } => {
462 vec![]
463 }
464 }
465 }
466
467 fn render_list_item(
468 &self,
469 item: &ListItemType,
470 highlight_positions: Vec<usize>,
471 cx: &Context<Self>,
472 ) -> AnyElement {
473 match item {
474 ListItemType::Entry { index, format } => match self.all_entries.get(*index) {
475 Some(entry) => self
476 .render_history_entry(entry, *format, *index, highlight_positions, cx)
477 .into_any(),
478 None => Empty.into_any_element(),
479 },
480 ListItemType::BucketSeparator(bucket) => div()
481 .px(DynamicSpacing::Base06.rems(cx))
482 .pt_2()
483 .pb_1()
484 .child(
485 Label::new(bucket.to_string())
486 .size(LabelSize::XSmall)
487 .color(Color::Muted),
488 )
489 .into_any_element(),
490 }
491 }
492
493 fn render_history_entry(
494 &self,
495 entry: &HistoryEntry,
496 format: EntryTimeFormat,
497 list_entry_ix: usize,
498 highlight_positions: Vec<usize>,
499 cx: &Context<Self>,
500 ) -> AnyElement {
501 let selected = list_entry_ix == self.selected_index;
502 let hovered = Some(list_entry_ix) == self.hovered_index;
503 let timestamp = entry.updated_at().timestamp();
504 let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
505
506 h_flex()
507 .w_full()
508 .pb_1()
509 .child(
510 ListItem::new(list_entry_ix)
511 .rounded()
512 .toggle_state(selected)
513 .spacing(ListItemSpacing::Sparse)
514 .start_slot(
515 h_flex()
516 .w_full()
517 .gap_2()
518 .justify_between()
519 .child(
520 HighlightedLabel::new(entry.title(), highlight_positions)
521 .size(LabelSize::Small)
522 .truncate(),
523 )
524 .child(
525 Label::new(thread_timestamp)
526 .color(Color::Muted)
527 .size(LabelSize::XSmall),
528 ),
529 )
530 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
531 if *is_hovered {
532 this.hovered_index = Some(list_entry_ix);
533 } else if this.hovered_index == Some(list_entry_ix) {
534 this.hovered_index = None;
535 }
536
537 cx.notify();
538 }))
539 .end_slot::<IconButton>(if hovered || selected {
540 Some(
541 IconButton::new("delete", IconName::Trash)
542 .shape(IconButtonShape::Square)
543 .icon_size(IconSize::XSmall)
544 .icon_color(Color::Muted)
545 .tooltip(move |window, cx| {
546 Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
547 })
548 .on_click(cx.listener(move |this, _, _, cx| {
549 this.remove_thread(list_entry_ix, cx)
550 })),
551 )
552 } else {
553 None
554 })
555 .on_click(
556 cx.listener(move |this, _, _, cx| this.confirm_entry(list_entry_ix, cx)),
557 ),
558 )
559 .into_any_element()
560 }
561}
562
563impl Focusable for AcpThreadHistory {
564 fn focus_handle(&self, cx: &App) -> FocusHandle {
565 self.search_editor.focus_handle(cx)
566 }
567}
568
569impl Render for AcpThreadHistory {
570 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
571 v_flex()
572 .key_context("ThreadHistory")
573 .size_full()
574 .on_action(cx.listener(Self::select_previous))
575 .on_action(cx.listener(Self::select_next))
576 .on_action(cx.listener(Self::select_first))
577 .on_action(cx.listener(Self::select_last))
578 .on_action(cx.listener(Self::confirm))
579 .on_action(cx.listener(Self::remove_selected_thread))
580 .when(!self.all_entries.is_empty(), |parent| {
581 parent.child(
582 h_flex()
583 .h(px(41.)) // Match the toolbar perfectly
584 .w_full()
585 .py_1()
586 .px_2()
587 .gap_2()
588 .justify_between()
589 .border_b_1()
590 .border_color(cx.theme().colors().border)
591 .child(
592 Icon::new(IconName::MagnifyingGlass)
593 .color(Color::Muted)
594 .size(IconSize::Small),
595 )
596 .child(self.search_editor.clone()),
597 )
598 })
599 .child({
600 let view = v_flex()
601 .id("list-container")
602 .relative()
603 .overflow_hidden()
604 .flex_grow();
605
606 if self.all_entries.is_empty() {
607 view.justify_center()
608 .child(
609 h_flex().w_full().justify_center().child(
610 Label::new("You don't have any past threads yet.")
611 .size(LabelSize::Small),
612 ),
613 )
614 } else if self.search_produced_no_matches() {
615 view.justify_center().child(
616 h_flex().w_full().justify_center().child(
617 Label::new("No threads match your search.").size(LabelSize::Small),
618 ),
619 )
620 } else {
621 view.pr_5()
622 .child(
623 uniform_list(
624 "thread-history",
625 self.list_item_count(),
626 cx.processor(|this, range: Range<usize>, window, cx| {
627 this.list_items(range, window, cx)
628 }),
629 )
630 .p_1()
631 .track_scroll(self.scroll_handle.clone())
632 .flex_grow(),
633 )
634 .when_some(self.render_scrollbar(cx), |div, scrollbar| {
635 div.child(scrollbar)
636 })
637 }
638 })
639 }
640}
641
642#[derive(Clone, Copy)]
643pub enum EntryTimeFormat {
644 DateAndTime,
645 TimeOnly,
646}
647
648impl EntryTimeFormat {
649 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
650 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
651
652 match self {
653 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
654 timestamp,
655 OffsetDateTime::now_utc(),
656 timezone,
657 time_format::TimestampFormat::EnhancedAbsolute,
658 ),
659 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
660 }
661 }
662}
663
664impl From<TimeBucket> for EntryTimeFormat {
665 fn from(bucket: TimeBucket) -> Self {
666 match bucket {
667 TimeBucket::Today => EntryTimeFormat::TimeOnly,
668 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
669 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
670 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
671 TimeBucket::All => EntryTimeFormat::DateAndTime,
672 }
673 }
674}
675
676#[derive(PartialEq, Eq, Clone, Copy, Debug)]
677enum TimeBucket {
678 Today,
679 Yesterday,
680 ThisWeek,
681 PastWeek,
682 All,
683}
684
685impl TimeBucket {
686 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
687 if date == reference {
688 return TimeBucket::Today;
689 }
690
691 if date == reference - TimeDelta::days(1) {
692 return TimeBucket::Yesterday;
693 }
694
695 let week = date.iso_week();
696
697 if reference.iso_week() == week {
698 return TimeBucket::ThisWeek;
699 }
700
701 let last_week = (reference - TimeDelta::days(7)).iso_week();
702
703 if week == last_week {
704 return TimeBucket::PastWeek;
705 }
706
707 TimeBucket::All
708 }
709}
710
711impl Display for TimeBucket {
712 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
713 match self {
714 TimeBucket::Today => write!(f, "Today"),
715 TimeBucket::Yesterday => write!(f, "Yesterday"),
716 TimeBucket::ThisWeek => write!(f, "This Week"),
717 TimeBucket::PastWeek => write!(f, "Past Week"),
718 TimeBucket::All => write!(f, "All"),
719 }
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use chrono::NaiveDate;
727
728 #[test]
729 fn test_time_bucket_from_dates() {
730 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
731
732 let date = today;
733 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
734
735 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
736 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
737
738 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
739 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
740
741 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
742 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
743
744 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
745 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
746
747 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
748 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
749
750 // All: not in this week or last week
751 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
752 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
753
754 // Test year boundary cases
755 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
756
757 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
758 assert_eq!(
759 TimeBucket::from_dates(new_year, date),
760 TimeBucket::Yesterday
761 );
762
763 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
764 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
765 }
766}