thread_history.rs

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