thread_history_view.rs

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