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