1use std::sync::Arc;
2
3use assistant_context_editor::SavedContextMetadata;
4use editor::{Editor, EditorEvent};
5use fuzzy::{StringMatch, StringMatchCandidate};
6use gpui::{
7 App, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, UniformListScrollHandle,
8 WeakEntity, Window, uniform_list,
9};
10use time::{OffsetDateTime, UtcOffset};
11use ui::{
12 HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
13 Tooltip, prelude::*,
14};
15use util::ResultExt;
16
17use crate::history_store::{HistoryEntry, HistoryStore};
18use crate::thread_store::SerializedThreadMetadata;
19use crate::{AssistantPanel, RemoveSelectedThread};
20
21pub struct ThreadHistory {
22 assistant_panel: WeakEntity<AssistantPanel>,
23 history_store: Entity<HistoryStore>,
24 scroll_handle: UniformListScrollHandle,
25 selected_index: usize,
26 search_query: SharedString,
27 search_editor: Entity<Editor>,
28 all_entries: Arc<Vec<HistoryEntry>>,
29 matches: Vec<StringMatch>,
30 _subscriptions: Vec<gpui::Subscription>,
31 _search_task: Option<Task<()>>,
32 scrollbar_visibility: bool,
33 scrollbar_state: ScrollbarState,
34}
35
36impl ThreadHistory {
37 pub(crate) fn new(
38 assistant_panel: WeakEntity<AssistantPanel>,
39 history_store: Entity<HistoryStore>,
40 window: &mut Window,
41 cx: &mut Context<Self>,
42 ) -> Self {
43 let search_editor = cx.new(|cx| {
44 let mut editor = Editor::single_line(window, cx);
45 editor.set_placeholder_text("Search threads...", cx);
46 editor
47 });
48
49 let search_editor_subscription =
50 cx.subscribe(&search_editor, |this, search_editor, event, cx| {
51 if let EditorEvent::BufferEdited = event {
52 let query = search_editor.read(cx).text(cx);
53 this.search_query = query.into();
54 this.update_search(cx);
55 }
56 });
57
58 let entries: Arc<Vec<_>> = history_store
59 .update(cx, |store, cx| store.entries(cx))
60 .into();
61
62 let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
63 this.update_all_entries(cx);
64 });
65
66 let scroll_handle = UniformListScrollHandle::default();
67 let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
68
69 Self {
70 assistant_panel,
71 history_store,
72 scroll_handle,
73 selected_index: 0,
74 search_query: SharedString::new_static(""),
75 all_entries: entries,
76 matches: Vec::new(),
77 search_editor,
78 _subscriptions: vec![search_editor_subscription, history_store_subscription],
79 _search_task: None,
80 scrollbar_visibility: true,
81 scrollbar_state,
82 }
83 }
84
85 fn update_all_entries(&mut self, cx: &mut Context<Self>) {
86 self.all_entries = self
87 .history_store
88 .update(cx, |store, cx| store.entries(cx))
89 .into();
90 self.matches.clear();
91 self.update_search(cx);
92 }
93
94 fn update_search(&mut self, cx: &mut Context<Self>) {
95 self._search_task.take();
96
97 if self.has_search_query() {
98 self.perform_search(cx);
99 } else {
100 self.matches.clear();
101 self.set_selected_index(0, cx);
102 cx.notify();
103 }
104 }
105
106 fn perform_search(&mut self, cx: &mut Context<Self>) {
107 let query = self.search_query.clone();
108 let all_entries = self.all_entries.clone();
109
110 let task = cx.spawn(async move |this, cx| {
111 let executor = cx.background_executor().clone();
112
113 let matches = cx
114 .background_spawn(async move {
115 let mut candidates = Vec::with_capacity(all_entries.len());
116
117 for (idx, entry) in all_entries.iter().enumerate() {
118 match entry {
119 HistoryEntry::Thread(thread) => {
120 candidates.push(StringMatchCandidate::new(idx, &thread.summary));
121 }
122 HistoryEntry::Context(context) => {
123 candidates.push(StringMatchCandidate::new(idx, &context.title));
124 }
125 }
126 }
127
128 const MAX_MATCHES: usize = 100;
129
130 fuzzy::match_strings(
131 &candidates,
132 &query,
133 false,
134 MAX_MATCHES,
135 &Default::default(),
136 executor,
137 )
138 .await
139 })
140 .await;
141
142 this.update(cx, |this, cx| {
143 this.matches = matches;
144 this.set_selected_index(0, cx);
145 cx.notify();
146 })
147 .log_err();
148 });
149
150 self._search_task = Some(task);
151 }
152
153 fn has_search_query(&self) -> bool {
154 !self.search_query.is_empty()
155 }
156
157 fn matched_count(&self) -> usize {
158 if self.has_search_query() {
159 self.matches.len()
160 } else {
161 self.all_entries.len()
162 }
163 }
164
165 fn get_match(&self, ix: usize) -> Option<&HistoryEntry> {
166 if self.has_search_query() {
167 self.matches
168 .get(ix)
169 .and_then(|m| self.all_entries.get(m.candidate_id))
170 } else {
171 self.all_entries.get(ix)
172 }
173 }
174
175 pub fn select_previous(
176 &mut self,
177 _: &menu::SelectPrevious,
178 _window: &mut Window,
179 cx: &mut Context<Self>,
180 ) {
181 let count = self.matched_count();
182 if count > 0 {
183 if self.selected_index == 0 {
184 self.set_selected_index(count - 1, cx);
185 } else {
186 self.set_selected_index(self.selected_index - 1, cx);
187 }
188 }
189 }
190
191 pub fn select_next(
192 &mut self,
193 _: &menu::SelectNext,
194 _window: &mut Window,
195 cx: &mut Context<Self>,
196 ) {
197 let count = self.matched_count();
198 if count > 0 {
199 if self.selected_index == count - 1 {
200 self.set_selected_index(0, cx);
201 } else {
202 self.set_selected_index(self.selected_index + 1, cx);
203 }
204 }
205 }
206
207 fn select_first(
208 &mut self,
209 _: &menu::SelectFirst,
210 _window: &mut Window,
211 cx: &mut Context<Self>,
212 ) {
213 let count = self.matched_count();
214 if count > 0 {
215 self.set_selected_index(0, cx);
216 }
217 }
218
219 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
220 let count = self.matched_count();
221 if count > 0 {
222 self.set_selected_index(count - 1, cx);
223 }
224 }
225
226 fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
227 self.selected_index = index;
228 self.scroll_handle
229 .scroll_to_item(index, ScrollStrategy::Top);
230 cx.notify();
231 }
232
233 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
234 if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) {
235 return None;
236 }
237
238 Some(
239 div()
240 .occlude()
241 .id("thread-history-scroll")
242 .h_full()
243 .bg(cx.theme().colors().panel_background.opacity(0.8))
244 .border_l_1()
245 .border_color(cx.theme().colors().border_variant)
246 .absolute()
247 .right_1()
248 .top_0()
249 .bottom_0()
250 .w_4()
251 .pl_1()
252 .cursor_default()
253 .on_mouse_move(cx.listener(|_, _, _window, cx| {
254 cx.notify();
255 cx.stop_propagation()
256 }))
257 .on_hover(|_, _window, cx| {
258 cx.stop_propagation();
259 })
260 .on_any_mouse_down(|_, _window, cx| {
261 cx.stop_propagation();
262 })
263 .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
264 cx.notify();
265 }))
266 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
267 )
268 }
269
270 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
271 if let Some(entry) = self.get_match(self.selected_index) {
272 let task_result = match entry {
273 HistoryEntry::Thread(thread) => self.assistant_panel.update(cx, move |this, cx| {
274 this.open_thread_by_id(&thread.id, window, cx)
275 }),
276 HistoryEntry::Context(context) => {
277 self.assistant_panel.update(cx, move |this, cx| {
278 this.open_saved_prompt_editor(context.path.clone(), window, cx)
279 })
280 }
281 };
282
283 if let Some(task) = task_result.log_err() {
284 task.detach_and_log_err(cx);
285 };
286
287 cx.notify();
288 }
289 }
290
291 fn remove_selected_thread(
292 &mut self,
293 _: &RemoveSelectedThread,
294 _window: &mut Window,
295 cx: &mut Context<Self>,
296 ) {
297 if let Some(entry) = self.get_match(self.selected_index) {
298 let task_result = match entry {
299 HistoryEntry::Thread(thread) => self
300 .assistant_panel
301 .update(cx, |this, cx| this.delete_thread(&thread.id, cx)),
302 HistoryEntry::Context(context) => self
303 .assistant_panel
304 .update(cx, |this, cx| this.delete_context(context.path.clone(), cx)),
305 };
306
307 if let Some(task) = task_result.log_err() {
308 task.detach_and_log_err(cx);
309 };
310
311 cx.notify();
312 }
313 }
314}
315
316impl Focusable for ThreadHistory {
317 fn focus_handle(&self, cx: &App) -> FocusHandle {
318 self.search_editor.focus_handle(cx)
319 }
320}
321
322impl Render for ThreadHistory {
323 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
324 let selected_index = self.selected_index;
325
326 v_flex()
327 .key_context("ThreadHistory")
328 .size_full()
329 .on_action(cx.listener(Self::select_previous))
330 .on_action(cx.listener(Self::select_next))
331 .on_action(cx.listener(Self::select_first))
332 .on_action(cx.listener(Self::select_last))
333 .on_action(cx.listener(Self::confirm))
334 .on_action(cx.listener(Self::remove_selected_thread))
335 .when(!self.all_entries.is_empty(), |parent| {
336 parent.child(
337 h_flex()
338 .h(px(41.)) // Match the toolbar perfectly
339 .w_full()
340 .py_1()
341 .px_2()
342 .gap_2()
343 .justify_between()
344 .border_b_1()
345 .border_color(cx.theme().colors().border)
346 .child(
347 Icon::new(IconName::MagnifyingGlass)
348 .color(Color::Muted)
349 .size(IconSize::Small),
350 )
351 .child(self.search_editor.clone()),
352 )
353 })
354 .child({
355 let view = v_flex()
356 .id("list-container")
357 .relative()
358 .overflow_hidden()
359 .flex_grow();
360
361 if self.all_entries.is_empty() {
362 view.justify_center()
363 .child(
364 h_flex().w_full().justify_center().child(
365 Label::new("You don't have any past threads yet.")
366 .size(LabelSize::Small),
367 ),
368 )
369 } else if self.has_search_query() && self.matches.is_empty() {
370 view.justify_center().child(
371 h_flex().w_full().justify_center().child(
372 Label::new("No threads match your search.").size(LabelSize::Small),
373 ),
374 )
375 } else {
376 view.pr_5()
377 .child(
378 uniform_list(
379 cx.entity().clone(),
380 "thread-history",
381 self.matched_count(),
382 move |history, range, _window, _cx| {
383 let range_start = range.start;
384 let assistant_panel = history.assistant_panel.clone();
385
386 let render_item = |index: usize,
387 entry: &HistoryEntry,
388 highlight_positions: Vec<usize>|
389 -> Div {
390 h_flex().w_full().pb_1().child(match entry {
391 HistoryEntry::Thread(thread) => PastThread::new(
392 thread.clone(),
393 assistant_panel.clone(),
394 selected_index == index + range_start,
395 highlight_positions,
396 )
397 .into_any_element(),
398 HistoryEntry::Context(context) => PastContext::new(
399 context.clone(),
400 assistant_panel.clone(),
401 selected_index == index + range_start,
402 highlight_positions,
403 )
404 .into_any_element(),
405 })
406 };
407
408 if history.has_search_query() {
409 history.matches[range]
410 .iter()
411 .enumerate()
412 .filter_map(|(index, m)| {
413 history.all_entries.get(m.candidate_id).map(
414 |entry| {
415 render_item(
416 index,
417 entry,
418 m.positions.clone(),
419 )
420 },
421 )
422 })
423 .collect()
424 } else {
425 history.all_entries[range]
426 .iter()
427 .enumerate()
428 .map(|(index, entry)| render_item(index, entry, vec![]))
429 .collect()
430 }
431 },
432 )
433 .p_1()
434 .track_scroll(self.scroll_handle.clone())
435 .flex_grow(),
436 )
437 .when_some(self.render_scrollbar(cx), |div, scrollbar| {
438 div.child(scrollbar)
439 })
440 }
441 })
442 }
443}
444
445#[derive(IntoElement)]
446pub struct PastThread {
447 thread: SerializedThreadMetadata,
448 assistant_panel: WeakEntity<AssistantPanel>,
449 selected: bool,
450 highlight_positions: Vec<usize>,
451}
452
453impl PastThread {
454 pub fn new(
455 thread: SerializedThreadMetadata,
456 assistant_panel: WeakEntity<AssistantPanel>,
457 selected: bool,
458 highlight_positions: Vec<usize>,
459 ) -> Self {
460 Self {
461 thread,
462 assistant_panel,
463 selected,
464 highlight_positions,
465 }
466 }
467}
468
469impl RenderOnce for PastThread {
470 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
471 let summary = self.thread.summary;
472
473 let thread_timestamp = time_format::format_localized_timestamp(
474 OffsetDateTime::from_unix_timestamp(self.thread.updated_at.timestamp()).unwrap(),
475 OffsetDateTime::now_utc(),
476 self.assistant_panel
477 .update(cx, |this, _cx| this.local_timezone())
478 .unwrap_or(UtcOffset::UTC),
479 time_format::TimestampFormat::EnhancedAbsolute,
480 );
481
482 ListItem::new(SharedString::from(self.thread.id.to_string()))
483 .rounded()
484 .toggle_state(self.selected)
485 .spacing(ListItemSpacing::Sparse)
486 .start_slot(
487 div().max_w_4_5().child(
488 HighlightedLabel::new(summary, self.highlight_positions)
489 .size(LabelSize::Small)
490 .truncate(),
491 ),
492 )
493 .end_slot(
494 h_flex()
495 .gap_1p5()
496 .child(
497 Label::new(thread_timestamp)
498 .color(Color::Muted)
499 .size(LabelSize::XSmall),
500 )
501 .child(
502 IconButton::new("delete", IconName::TrashAlt)
503 .shape(IconButtonShape::Square)
504 .icon_size(IconSize::XSmall)
505 .icon_color(Color::Muted)
506 .tooltip(move |window, cx| {
507 Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
508 })
509 .on_click({
510 let assistant_panel = self.assistant_panel.clone();
511 let id = self.thread.id.clone();
512 move |_event, _window, cx| {
513 assistant_panel
514 .update(cx, |this, cx| {
515 this.delete_thread(&id, cx).detach_and_log_err(cx);
516 })
517 .ok();
518 }
519 }),
520 ),
521 )
522 .on_click({
523 let assistant_panel = self.assistant_panel.clone();
524 let id = self.thread.id.clone();
525 move |_event, window, cx| {
526 assistant_panel
527 .update(cx, |this, cx| {
528 this.open_thread_by_id(&id, window, cx)
529 .detach_and_log_err(cx);
530 })
531 .ok();
532 }
533 })
534 }
535}
536
537#[derive(IntoElement)]
538pub struct PastContext {
539 context: SavedContextMetadata,
540 assistant_panel: WeakEntity<AssistantPanel>,
541 selected: bool,
542 highlight_positions: Vec<usize>,
543}
544
545impl PastContext {
546 pub fn new(
547 context: SavedContextMetadata,
548 assistant_panel: WeakEntity<AssistantPanel>,
549 selected: bool,
550 highlight_positions: Vec<usize>,
551 ) -> Self {
552 Self {
553 context,
554 assistant_panel,
555 selected,
556 highlight_positions,
557 }
558 }
559}
560
561impl RenderOnce for PastContext {
562 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
563 let summary = self.context.title;
564 let context_timestamp = time_format::format_localized_timestamp(
565 OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(),
566 OffsetDateTime::now_utc(),
567 self.assistant_panel
568 .update(cx, |this, _cx| this.local_timezone())
569 .unwrap_or(UtcOffset::UTC),
570 time_format::TimestampFormat::EnhancedAbsolute,
571 );
572
573 ListItem::new(SharedString::from(
574 self.context.path.to_string_lossy().to_string(),
575 ))
576 .rounded()
577 .toggle_state(self.selected)
578 .spacing(ListItemSpacing::Sparse)
579 .start_slot(
580 div().max_w_4_5().child(
581 HighlightedLabel::new(summary, self.highlight_positions)
582 .size(LabelSize::Small)
583 .truncate(),
584 ),
585 )
586 .end_slot(
587 h_flex()
588 .gap_1p5()
589 .child(
590 Label::new(context_timestamp)
591 .color(Color::Muted)
592 .size(LabelSize::XSmall),
593 )
594 .child(
595 IconButton::new("delete", IconName::TrashAlt)
596 .shape(IconButtonShape::Square)
597 .icon_size(IconSize::XSmall)
598 .icon_color(Color::Muted)
599 .tooltip(move |window, cx| {
600 Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
601 })
602 .on_click({
603 let assistant_panel = self.assistant_panel.clone();
604 let path = self.context.path.clone();
605 move |_event, _window, cx| {
606 assistant_panel
607 .update(cx, |this, cx| {
608 this.delete_context(path.clone(), cx)
609 .detach_and_log_err(cx);
610 })
611 .ok();
612 }
613 }),
614 ),
615 )
616 .on_click({
617 let assistant_panel = self.assistant_panel.clone();
618 let path = self.context.path.clone();
619 move |_event, window, cx| {
620 assistant_panel
621 .update(cx, |this, cx| {
622 this.open_saved_prompt_editor(path.clone(), window, cx)
623 .detach_and_log_err(cx);
624 })
625 .ok();
626 }
627 })
628 }
629}