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