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[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}