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