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}