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.get(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 .is_some_and(|ctx_store| ctx_store.read(cx).includes_thread(id)),
240 ThreadContextEntry::Context { path, .. } => context_store
241 .upgrade()
242 .is_some_and(|ctx_store| ctx_store.read(cx).includes_text_thread(path)),
243 };
244
245 h_flex()
246 .gap_1p5()
247 .w_full()
248 .justify_between()
249 .child(
250 h_flex()
251 .gap_1p5()
252 .max_w_72()
253 .child(
254 Icon::new(IconName::Thread)
255 .size(IconSize::XSmall)
256 .color(Color::Muted),
257 )
258 .child(Label::new(entry.title().clone()).truncate()),
259 )
260 .when(is_added, |el| {
261 el.child(
262 h_flex()
263 .gap_1()
264 .child(
265 Icon::new(IconName::Check)
266 .size(IconSize::Small)
267 .color(Color::Success),
268 )
269 .child(Label::new("Added").size(LabelSize::Small)),
270 )
271 })
272}
273
274#[derive(Clone)]
275pub struct ThreadMatch {
276 pub thread: ThreadContextEntry,
277 pub is_recent: bool,
278}
279
280pub fn unordered_thread_entries(
281 thread_store: Entity<ThreadStore>,
282 text_thread_store: Entity<TextThreadStore>,
283 cx: &App,
284) -> impl Iterator<Item = (DateTime<Utc>, ThreadContextEntry)> {
285 let threads = thread_store
286 .read(cx)
287 .reverse_chronological_threads()
288 .map(|thread| {
289 (
290 thread.updated_at,
291 ThreadContextEntry::Thread {
292 id: thread.id.clone(),
293 title: thread.summary.clone(),
294 },
295 )
296 });
297
298 let text_threads = text_thread_store
299 .read(cx)
300 .unordered_contexts()
301 .map(|context| {
302 (
303 context.mtime.to_utc(),
304 ThreadContextEntry::Context {
305 path: context.path.clone(),
306 title: context.title.clone(),
307 },
308 )
309 });
310
311 threads.chain(text_threads)
312}
313
314pub(crate) fn search_threads(
315 query: String,
316 cancellation_flag: Arc<AtomicBool>,
317 thread_store: Entity<ThreadStore>,
318 text_thread_store: Entity<TextThreadStore>,
319 cx: &mut App,
320) -> Task<Vec<ThreadMatch>> {
321 let mut threads =
322 unordered_thread_entries(thread_store, text_thread_store, cx).collect::<Vec<_>>();
323 threads.sort_unstable_by_key(|(updated_at, _)| std::cmp::Reverse(*updated_at));
324
325 let executor = cx.background_executor().clone();
326 cx.background_spawn(async move {
327 if query.is_empty() {
328 threads
329 .into_iter()
330 .map(|(_, thread)| ThreadMatch {
331 thread,
332 is_recent: false,
333 })
334 .collect()
335 } else {
336 let candidates = threads
337 .iter()
338 .enumerate()
339 .map(|(id, (_, thread))| StringMatchCandidate::new(id, thread.title()))
340 .collect::<Vec<_>>();
341 let matches = fuzzy::match_strings(
342 &candidates,
343 &query,
344 false,
345 true,
346 100,
347 &cancellation_flag,
348 executor,
349 )
350 .await;
351
352 matches
353 .into_iter()
354 .map(|mat| ThreadMatch {
355 thread: threads[mat.candidate_id].1.clone(),
356 is_recent: false,
357 })
358 .collect()
359 }
360 })
361}