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