thread_history_view.rs

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