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