thread_history.rs

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