1use std::path::Path;
2use std::sync::Arc;
3use std::sync::atomic::AtomicBool;
4
5use chrono::{DateTime, Utc};
6use fuzzy::StringMatchCandidate;
7use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity};
8use picker::{Picker, PickerDelegate};
9use ui::{ListItem, prelude::*};
10
11use crate::context_picker::ContextPicker;
12use agent::{
13 ThreadId,
14 context_store::{self, ContextStore},
15 thread_store::{TextThreadStore, ThreadStore},
16};
17
18pub struct ThreadContextPicker {
19 picker: Entity<Picker<ThreadContextPickerDelegate>>,
20}
21
22impl ThreadContextPicker {
23 pub fn new(
24 thread_store: WeakEntity<ThreadStore>,
25 text_thread_context_store: WeakEntity<TextThreadStore>,
26 context_picker: WeakEntity<ContextPicker>,
27 context_store: WeakEntity<context_store::ContextStore>,
28 window: &mut Window,
29 cx: &mut Context<Self>,
30 ) -> Self {
31 let delegate = ThreadContextPickerDelegate::new(
32 thread_store,
33 text_thread_context_store,
34 context_picker,
35 context_store,
36 );
37 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
38
39 ThreadContextPicker { picker }
40 }
41}
42
43impl Focusable for ThreadContextPicker {
44 fn focus_handle(&self, cx: &App) -> FocusHandle {
45 self.picker.focus_handle(cx)
46 }
47}
48
49impl Render for ThreadContextPicker {
50 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
51 self.picker.clone()
52 }
53}
54
55#[derive(Debug, Clone)]
56pub enum ThreadContextEntry {
57 Thread {
58 id: ThreadId,
59 title: SharedString,
60 },
61 Context {
62 path: Arc<Path>,
63 title: SharedString,
64 },
65}
66
67impl ThreadContextEntry {
68 pub fn title(&self) -> &SharedString {
69 match self {
70 Self::Thread { title, .. } => title,
71 Self::Context { title, .. } => title,
72 }
73 }
74}
75
76pub struct ThreadContextPickerDelegate {
77 thread_store: WeakEntity<ThreadStore>,
78 text_thread_store: WeakEntity<TextThreadStore>,
79 context_picker: WeakEntity<ContextPicker>,
80 context_store: WeakEntity<context_store::ContextStore>,
81 matches: Vec<ThreadContextEntry>,
82 selected_index: usize,
83}
84
85impl ThreadContextPickerDelegate {
86 pub fn new(
87 thread_store: WeakEntity<ThreadStore>,
88 text_thread_store: WeakEntity<TextThreadStore>,
89 context_picker: WeakEntity<ContextPicker>,
90 context_store: WeakEntity<context_store::ContextStore>,
91 ) -> Self {
92 ThreadContextPickerDelegate {
93 thread_store,
94 context_picker,
95 context_store,
96 text_thread_store,
97 matches: Vec::new(),
98 selected_index: 0,
99 }
100 }
101}
102
103impl PickerDelegate for ThreadContextPickerDelegate {
104 type ListItem = ListItem;
105
106 fn match_count(&self) -> usize {
107 self.matches.len()
108 }
109
110 fn selected_index(&self) -> usize {
111 self.selected_index
112 }
113
114 fn set_selected_index(
115 &mut self,
116 ix: usize,
117 _window: &mut Window,
118 _cx: &mut Context<Picker<Self>>,
119 ) {
120 self.selected_index = ix;
121 }
122
123 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
124 "Search threads…".into()
125 }
126
127 fn update_matches(
128 &mut self,
129 query: String,
130 window: &mut Window,
131 cx: &mut Context<Picker<Self>>,
132 ) -> Task<()> {
133 let Some((thread_store, text_thread_context_store)) = self
134 .thread_store
135 .upgrade()
136 .zip(self.text_thread_store.upgrade())
137 else {
138 return Task::ready(());
139 };
140
141 let search_task = search_threads(
142 query,
143 Arc::new(AtomicBool::default()),
144 thread_store,
145 text_thread_context_store,
146 cx,
147 );
148 cx.spawn_in(window, async move |this, cx| {
149 let matches = search_task.await;
150 this.update(cx, |this, cx| {
151 this.delegate.matches = matches.into_iter().map(|mat| mat.thread).collect();
152 this.delegate.selected_index = 0;
153 cx.notify();
154 })
155 .ok();
156 })
157 }
158
159 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
160 let Some(entry) = self.matches.get(self.selected_index) else {
161 return;
162 };
163
164 match entry {
165 ThreadContextEntry::Thread { id, .. } => {
166 let Some(thread_store) = self.thread_store.upgrade() else {
167 return;
168 };
169 let open_thread_task =
170 thread_store.update(cx, |this, cx| this.open_thread(&id, window, cx));
171
172 cx.spawn(async move |this, cx| {
173 let thread = open_thread_task.await?;
174 this.update(cx, |this, cx| {
175 this.delegate
176 .context_store
177 .update(cx, |context_store, cx| {
178 context_store.add_thread(thread, true, cx)
179 })
180 .ok();
181 })
182 })
183 .detach_and_log_err(cx);
184 }
185 ThreadContextEntry::Context { path, .. } => {
186 let Some(text_thread_store) = self.text_thread_store.upgrade() else {
187 return;
188 };
189 let task = text_thread_store
190 .update(cx, |this, cx| this.open_local_context(path.clone(), cx));
191
192 cx.spawn(async move |this, cx| {
193 let thread = task.await?;
194 this.update(cx, |this, cx| {
195 this.delegate
196 .context_store
197 .update(cx, |context_store, cx| {
198 context_store.add_text_thread(thread, true, cx)
199 })
200 .ok();
201 })
202 })
203 .detach_and_log_err(cx);
204 }
205 }
206 }
207
208 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
209 self.context_picker
210 .update(cx, |_, cx| {
211 cx.emit(DismissEvent);
212 })
213 .ok();
214 }
215
216 fn render_match(
217 &self,
218 ix: usize,
219 selected: bool,
220 _window: &mut Window,
221 cx: &mut Context<Picker<Self>>,
222 ) -> Option<Self::ListItem> {
223 let thread = &self.matches[ix];
224
225 Some(ListItem::new(ix).inset(true).toggle_state(selected).child(
226 render_thread_context_entry(thread, self.context_store.clone(), cx),
227 ))
228 }
229}
230
231pub fn render_thread_context_entry(
232 entry: &ThreadContextEntry,
233 context_store: WeakEntity<ContextStore>,
234 cx: &mut App,
235) -> Div {
236 let is_added = match entry {
237 ThreadContextEntry::Thread { id, .. } => context_store
238 .upgrade()
239 .map_or(false, |ctx_store| ctx_store.read(cx).includes_thread(&id)),
240 ThreadContextEntry::Context { path, .. } => {
241 context_store.upgrade().map_or(false, |ctx_store| {
242 ctx_store.read(cx).includes_text_thread(path)
243 })
244 }
245 };
246
247 h_flex()
248 .gap_1p5()
249 .w_full()
250 .justify_between()
251 .child(
252 h_flex()
253 .gap_1p5()
254 .max_w_72()
255 .child(
256 Icon::new(IconName::Thread)
257 .size(IconSize::XSmall)
258 .color(Color::Muted),
259 )
260 .child(Label::new(entry.title().clone()).truncate()),
261 )
262 .when(is_added, |el| {
263 el.child(
264 h_flex()
265 .gap_1()
266 .child(
267 Icon::new(IconName::Check)
268 .size(IconSize::Small)
269 .color(Color::Success),
270 )
271 .child(Label::new("Added").size(LabelSize::Small)),
272 )
273 })
274}
275
276#[derive(Clone)]
277pub struct ThreadMatch {
278 pub thread: ThreadContextEntry,
279 pub is_recent: bool,
280}
281
282pub fn unordered_thread_entries(
283 thread_store: Entity<ThreadStore>,
284 text_thread_store: Entity<TextThreadStore>,
285 cx: &App,
286) -> impl Iterator<Item = (DateTime<Utc>, ThreadContextEntry)> {
287 let threads = thread_store
288 .read(cx)
289 .reverse_chronological_threads()
290 .map(|thread| {
291 (
292 thread.updated_at,
293 ThreadContextEntry::Thread {
294 id: thread.id.clone(),
295 title: thread.summary.clone(),
296 },
297 )
298 });
299
300 let text_threads = text_thread_store
301 .read(cx)
302 .unordered_contexts()
303 .map(|context| {
304 (
305 context.mtime.to_utc(),
306 ThreadContextEntry::Context {
307 path: context.path.clone(),
308 title: context.title.clone(),
309 },
310 )
311 });
312
313 threads.chain(text_threads)
314}
315
316pub(crate) fn search_threads(
317 query: String,
318 cancellation_flag: Arc<AtomicBool>,
319 thread_store: Entity<ThreadStore>,
320 text_thread_store: Entity<TextThreadStore>,
321 cx: &mut App,
322) -> Task<Vec<ThreadMatch>> {
323 let mut threads =
324 unordered_thread_entries(thread_store, text_thread_store, cx).collect::<Vec<_>>();
325 threads.sort_unstable_by_key(|(updated_at, _)| std::cmp::Reverse(*updated_at));
326
327 let executor = cx.background_executor().clone();
328 cx.background_spawn(async move {
329 if query.is_empty() {
330 threads
331 .into_iter()
332 .map(|(_, thread)| ThreadMatch {
333 thread,
334 is_recent: false,
335 })
336 .collect()
337 } else {
338 let candidates = threads
339 .iter()
340 .enumerate()
341 .map(|(id, (_, thread))| StringMatchCandidate::new(id, &thread.title()))
342 .collect::<Vec<_>>();
343 let matches = fuzzy::match_strings(
344 &candidates,
345 &query,
346 false,
347 true,
348 100,
349 &cancellation_flag,
350 executor,
351 )
352 .await;
353
354 matches
355 .into_iter()
356 .map(|mat| ThreadMatch {
357 thread: threads[mat.candidate_id].1.clone(),
358 is_recent: false,
359 })
360 .collect()
361 }
362 })
363}