thread_history.rs

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