text_thread_history.rs

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