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