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