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