thread_history.rs

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