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