thread_history.rs

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