thread_history.rs

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