context_strip.rs

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