context_strip.rs

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