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}