thread_context_picker.rs

  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}