context_strip.rs

  1use std::rc::Rc;
  2
  3use collections::HashSet;
  4use editor::Editor;
  5use file_icons::FileIcons;
  6use gpui::{
  7    AppContext, Bounds, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
  8    Subscription, View, WeakModel, WeakView,
  9};
 10use itertools::Itertools;
 11use language::Buffer;
 12use ui::{prelude::*, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip};
 13use workspace::Workspace;
 14
 15use crate::context::ContextKind;
 16use crate::context_picker::{ConfirmBehavior, ContextPicker};
 17use crate::context_store::ContextStore;
 18use crate::thread::Thread;
 19use crate::thread_store::ThreadStore;
 20use crate::ui::ContextPill;
 21use crate::{
 22    AcceptSuggestedContext, AssistantPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
 23    RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
 24};
 25
 26pub struct ContextStrip {
 27    context_store: Model<ContextStore>,
 28    pub context_picker: View<ContextPicker>,
 29    context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 30    focus_handle: FocusHandle,
 31    suggest_context_kind: SuggestContextKind,
 32    workspace: WeakView<Workspace>,
 33    _subscriptions: Vec<Subscription>,
 34    focused_index: Option<usize>,
 35    children_bounds: Option<Vec<Bounds<Pixels>>>,
 36}
 37
 38impl ContextStrip {
 39    pub fn new(
 40        context_store: Model<ContextStore>,
 41        workspace: WeakView<Workspace>,
 42        thread_store: Option<WeakModel<ThreadStore>>,
 43        context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 44        suggest_context_kind: SuggestContextKind,
 45        cx: &mut ViewContext<Self>,
 46    ) -> Self {
 47        let context_picker = cx.new_view(|cx| {
 48            ContextPicker::new(
 49                workspace.clone(),
 50                thread_store.clone(),
 51                context_store.downgrade(),
 52                ConfirmBehavior::KeepOpen,
 53                cx,
 54            )
 55        });
 56
 57        let focus_handle = cx.focus_handle();
 58
 59        let subscriptions = vec![
 60            cx.subscribe(&context_picker, Self::handle_context_picker_event),
 61            cx.on_focus(&focus_handle, Self::handle_focus),
 62            cx.on_blur(&focus_handle, Self::handle_blur),
 63        ];
 64
 65        Self {
 66            context_store: context_store.clone(),
 67            context_picker,
 68            context_picker_menu_handle,
 69            focus_handle,
 70            suggest_context_kind,
 71            workspace,
 72            _subscriptions: subscriptions,
 73            focused_index: None,
 74            children_bounds: None,
 75        }
 76    }
 77
 78    fn suggested_context(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
 79        match self.suggest_context_kind {
 80            SuggestContextKind::File => self.suggested_file(cx),
 81            SuggestContextKind::Thread => self.suggested_thread(cx),
 82        }
 83    }
 84
 85    fn suggested_file(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
 86        let workspace = self.workspace.upgrade()?;
 87        let active_item = workspace.read(cx).active_item(cx)?;
 88
 89        let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
 90        let active_buffer_model = editor.buffer().read(cx).as_singleton()?;
 91        let active_buffer = active_buffer_model.read(cx);
 92
 93        let path = active_buffer.file()?.path();
 94
 95        if self
 96            .context_store
 97            .read(cx)
 98            .will_include_buffer(active_buffer.remote_id(), path)
 99            .is_some()
100        {
101            return None;
102        }
103
104        let name = match path.file_name() {
105            Some(name) => name.to_string_lossy().into_owned().into(),
106            None => path.to_string_lossy().into_owned().into(),
107        };
108
109        let icon_path = FileIcons::get_icon(path, cx);
110
111        Some(SuggestedContext::File {
112            name,
113            buffer: active_buffer_model.downgrade(),
114            icon_path,
115        })
116    }
117
118    fn suggested_thread(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
119        let workspace = self.workspace.upgrade()?;
120        let active_thread = workspace
121            .read(cx)
122            .panel::<AssistantPanel>(cx)?
123            .read(cx)
124            .active_thread(cx);
125        let weak_active_thread = active_thread.downgrade();
126
127        let active_thread = active_thread.read(cx);
128
129        if self
130            .context_store
131            .read(cx)
132            .includes_thread(active_thread.id())
133            .is_some()
134        {
135            return None;
136        }
137
138        Some(SuggestedContext::Thread {
139            name: active_thread.summary_or_default(),
140            thread: weak_active_thread,
141        })
142    }
143
144    fn handle_context_picker_event(
145        &mut self,
146        _picker: View<ContextPicker>,
147        _event: &DismissEvent,
148        cx: &mut ViewContext<Self>,
149    ) {
150        cx.emit(ContextStripEvent::PickerDismissed);
151    }
152
153    fn handle_focus(&mut self, cx: &mut ViewContext<Self>) {
154        self.focused_index = self.last_pill_index();
155        cx.notify();
156    }
157
158    fn handle_blur(&mut self, cx: &mut ViewContext<Self>) {
159        self.focused_index = None;
160        cx.notify();
161    }
162
163    fn focus_left(&mut self, _: &FocusLeft, cx: &mut ViewContext<Self>) {
164        self.focused_index = match self.focused_index {
165            Some(index) if index > 0 => Some(index - 1),
166            _ => self.last_pill_index(),
167        };
168
169        cx.notify();
170    }
171
172    fn focus_right(&mut self, _: &FocusRight, cx: &mut ViewContext<Self>) {
173        let Some(last_index) = self.last_pill_index() else {
174            return;
175        };
176
177        self.focused_index = match self.focused_index {
178            Some(index) if index < last_index => Some(index + 1),
179            _ => Some(0),
180        };
181
182        cx.notify();
183    }
184
185    fn focus_up(&mut self, _: &FocusUp, cx: &mut ViewContext<Self>) {
186        let Some(focused_index) = self.focused_index else {
187            return;
188        };
189
190        if focused_index == 0 {
191            return cx.emit(ContextStripEvent::BlurredUp);
192        }
193
194        let Some((focused, pills)) = self.focused_bounds(focused_index) else {
195            return;
196        };
197
198        let iter = pills[..focused_index].iter().enumerate().rev();
199        self.focused_index = Self::find_best_horizontal_match(focused, iter).or(Some(0));
200        cx.notify();
201    }
202
203    fn focus_down(&mut self, _: &FocusDown, cx: &mut ViewContext<Self>) {
204        let Some(focused_index) = self.focused_index else {
205            return;
206        };
207
208        let last_index = self.last_pill_index();
209
210        if self.focused_index == last_index {
211            return cx.emit(ContextStripEvent::BlurredDown);
212        }
213
214        let Some((focused, pills)) = self.focused_bounds(focused_index) else {
215            return;
216        };
217
218        let iter = pills.iter().enumerate().skip(focused_index + 1);
219        self.focused_index = Self::find_best_horizontal_match(focused, iter).or(last_index);
220        cx.notify();
221    }
222
223    fn focused_bounds(&self, focused: usize) -> Option<(&Bounds<Pixels>, &[Bounds<Pixels>])> {
224        let pill_bounds = self.pill_bounds()?;
225        let focused = pill_bounds.get(focused)?;
226
227        Some((focused, pill_bounds))
228    }
229
230    fn pill_bounds(&self) -> Option<&[Bounds<Pixels>]> {
231        let bounds = self.children_bounds.as_ref()?;
232        let eraser = if bounds.len() < 3 { 0 } else { 1 };
233        let pills = &bounds[1..bounds.len() - eraser];
234
235        if pills.is_empty() {
236            None
237        } else {
238            Some(pills)
239        }
240    }
241
242    fn last_pill_index(&self) -> Option<usize> {
243        Some(self.pill_bounds()?.len() - 1)
244    }
245
246    fn find_best_horizontal_match<'a>(
247        focused: &'a Bounds<Pixels>,
248        iter: impl Iterator<Item = (usize, &'a Bounds<Pixels>)>,
249    ) -> Option<usize> {
250        let mut best = None;
251
252        let focused_left = focused.left();
253        let focused_right = focused.right();
254
255        for (index, probe) in iter {
256            if probe.origin.y == focused.origin.y {
257                continue;
258            }
259
260            let overlap = probe.right().min(focused_right) - probe.left().max(focused_left);
261
262            best = match best {
263                Some((_, prev_overlap, y)) if probe.origin.y != y || prev_overlap > overlap => {
264                    break;
265                }
266                Some(_) | None => Some((index, overlap, probe.origin.y)),
267            };
268        }
269
270        best.map(|(index, _, _)| index)
271    }
272
273    fn remove_focused_context(&mut self, _: &RemoveFocusedContext, cx: &mut ViewContext<Self>) {
274        if let Some(index) = self.focused_index {
275            let mut is_empty = false;
276
277            self.context_store.update(cx, |this, _cx| {
278                if let Some(item) = this.context().get(index) {
279                    this.remove_context(item.id());
280                }
281
282                is_empty = this.context().is_empty();
283            });
284
285            if is_empty {
286                cx.emit(ContextStripEvent::BlurredEmpty);
287            } else {
288                self.focused_index = Some(index.saturating_sub(1));
289                cx.notify();
290            }
291        }
292    }
293
294    fn is_suggested_focused<T>(&self, context: &Vec<T>) -> bool {
295        // We only suggest one item after the actual context
296        self.focused_index == Some(context.len())
297    }
298
299    fn accept_suggested_context(&mut self, _: &AcceptSuggestedContext, cx: &mut ViewContext<Self>) {
300        if let Some(suggested) = self.suggested_context(cx) {
301            let context_store = self.context_store.read(cx);
302
303            if self.is_suggested_focused(context_store.context()) {
304                self.add_suggested_context(&suggested, cx);
305            }
306        }
307    }
308
309    fn add_suggested_context(&mut self, suggested: &SuggestedContext, cx: &mut ViewContext<Self>) {
310        let task = self.context_store.update(cx, |context_store, cx| {
311            context_store.accept_suggested_context(&suggested, cx)
312        });
313
314        let workspace = self.workspace.clone();
315
316        cx.spawn(|this, mut cx| async move {
317            match task.await {
318                Ok(()) => {
319                    if let Some(this) = this.upgrade() {
320                        this.update(&mut cx, |_, cx| cx.notify())?;
321                    }
322                }
323                Err(err) => {
324                    let Some(workspace) = workspace.upgrade() else {
325                        return anyhow::Ok(());
326                    };
327
328                    workspace.update(&mut cx, |workspace, cx| {
329                        workspace.show_error(&err, cx);
330                    })?;
331                }
332            }
333            anyhow::Ok(())
334        })
335        .detach_and_log_err(cx);
336
337        cx.notify();
338    }
339}
340
341impl FocusableView for ContextStrip {
342    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
343        self.focus_handle.clone()
344    }
345}
346
347impl Render for ContextStrip {
348    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
349        let context_store = self.context_store.read(cx);
350        let context = context_store
351            .context()
352            .iter()
353            .flat_map(|context| context.snapshot(cx))
354            .collect::<Vec<_>>();
355        let context_picker = self.context_picker.clone();
356        let focus_handle = self.focus_handle.clone();
357
358        let suggested_context = self.suggested_context(cx);
359
360        let dupe_names = context
361            .iter()
362            .map(|context| context.name.clone())
363            .sorted()
364            .tuple_windows()
365            .filter(|(a, b)| a == b)
366            .map(|(a, _)| a)
367            .collect::<HashSet<SharedString>>();
368
369        h_flex()
370            .flex_wrap()
371            .gap_1()
372            .track_focus(&focus_handle)
373            .key_context("ContextStrip")
374            .on_action(cx.listener(Self::focus_up))
375            .on_action(cx.listener(Self::focus_right))
376            .on_action(cx.listener(Self::focus_down))
377            .on_action(cx.listener(Self::focus_left))
378            .on_action(cx.listener(Self::remove_focused_context))
379            .on_action(cx.listener(Self::accept_suggested_context))
380            .on_children_prepainted({
381                let view = cx.view().downgrade();
382                move |children_bounds, cx| {
383                    view.update(cx, |this, _| {
384                        this.children_bounds = Some(children_bounds);
385                    })
386                    .ok();
387                }
388            })
389            .child(
390                PopoverMenu::new("context-picker")
391                    .menu(move |cx| {
392                        context_picker.update(cx, |this, cx| {
393                            this.init(cx);
394                        });
395
396                        Some(context_picker.clone())
397                    })
398                    .trigger(
399                        IconButton::new("add-context", IconName::Plus)
400                            .icon_size(IconSize::Small)
401                            .style(ui::ButtonStyle::Filled)
402                            .tooltip({
403                                let focus_handle = focus_handle.clone();
404
405                                move |cx| {
406                                    Tooltip::for_action_in(
407                                        "Add Context",
408                                        &ToggleContextPicker,
409                                        &focus_handle,
410                                        cx,
411                                    )
412                                }
413                            }),
414                    )
415                    .attach(gpui::Corner::TopLeft)
416                    .anchor(gpui::Corner::BottomLeft)
417                    .offset(gpui::Point {
418                        x: px(0.0),
419                        y: px(-2.0),
420                    })
421                    .with_handle(self.context_picker_menu_handle.clone()),
422            )
423            .when(context.is_empty() && suggested_context.is_none(), {
424                |parent| {
425                    parent.child(
426                        h_flex()
427                            .ml_1p5()
428                            .gap_2()
429                            .child(
430                                Label::new("Add Context")
431                                    .size(LabelSize::Small)
432                                    .color(Color::Muted),
433                            )
434                            .opacity(0.5)
435                            .children(
436                                KeyBinding::for_action_in(&ToggleContextPicker, &focus_handle, cx)
437                                    .map(|binding| binding.into_any_element()),
438                            ),
439                    )
440                }
441            })
442            .children(context.iter().enumerate().map(|(i, context)| {
443                ContextPill::new_added(
444                    context.clone(),
445                    dupe_names.contains(&context.name),
446                    self.focused_index == Some(i),
447                    Some({
448                        let id = context.id;
449                        let context_store = self.context_store.clone();
450                        Rc::new(cx.listener(move |_this, _event, cx| {
451                            context_store.update(cx, |this, _cx| {
452                                this.remove_context(id);
453                            });
454                            cx.notify();
455                        }))
456                    }),
457                )
458                .on_click(Rc::new(cx.listener(move |this, _, cx| {
459                    this.focused_index = Some(i);
460                    cx.notify();
461                })))
462            }))
463            .when_some(suggested_context, |el, suggested| {
464                el.child(
465                    ContextPill::new_suggested(
466                        suggested.name().clone(),
467                        suggested.icon_path(),
468                        suggested.kind(),
469                        self.is_suggested_focused(&context),
470                    )
471                    .on_click(Rc::new(cx.listener(move |this, _event, cx| {
472                        this.add_suggested_context(&suggested, cx);
473                    }))),
474                )
475            })
476            .when(!context.is_empty(), {
477                move |parent| {
478                    parent.child(
479                        IconButton::new("remove-all-context", IconName::Eraser)
480                            .icon_size(IconSize::Small)
481                            .tooltip({
482                                let focus_handle = focus_handle.clone();
483                                move |cx| {
484                                    Tooltip::for_action_in(
485                                        "Remove All Context",
486                                        &RemoveAllContext,
487                                        &focus_handle,
488                                        cx,
489                                    )
490                                }
491                            })
492                            .on_click(cx.listener({
493                                let focus_handle = focus_handle.clone();
494                                move |_this, _event, cx| {
495                                    focus_handle.dispatch_action(&RemoveAllContext, cx);
496                                }
497                            })),
498                    )
499                }
500            })
501    }
502}
503
504pub enum ContextStripEvent {
505    PickerDismissed,
506    BlurredEmpty,
507    BlurredDown,
508    BlurredUp,
509}
510
511impl EventEmitter<ContextStripEvent> for ContextStrip {}
512
513pub enum SuggestContextKind {
514    File,
515    Thread,
516}
517
518#[derive(Clone)]
519pub enum SuggestedContext {
520    File {
521        name: SharedString,
522        icon_path: Option<SharedString>,
523        buffer: WeakModel<Buffer>,
524    },
525    Thread {
526        name: SharedString,
527        thread: WeakModel<Thread>,
528    },
529}
530
531impl SuggestedContext {
532    pub fn name(&self) -> &SharedString {
533        match self {
534            Self::File { name, .. } => name,
535            Self::Thread { name, .. } => name,
536        }
537    }
538
539    pub fn icon_path(&self) -> Option<SharedString> {
540        match self {
541            Self::File { icon_path, .. } => icon_path.clone(),
542            Self::Thread { .. } => None,
543        }
544    }
545
546    pub fn kind(&self) -> ContextKind {
547        match self {
548            Self::File { .. } => ContextKind::File,
549            Self::Thread { .. } => ContextKind::Thread,
550        }
551    }
552}