context_strip.rs

  1use std::path::Path;
  2use std::rc::Rc;
  3
  4use collections::HashSet;
  5use editor::Editor;
  6use file_icons::FileIcons;
  7use gpui::{
  8    App, Bounds, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  9    Subscription, WeakEntity,
 10};
 11use itertools::Itertools;
 12use language::Buffer;
 13use project::ProjectItem;
 14use ui::{KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
 15use workspace::{Workspace, notifications::NotifyResultExt};
 16
 17use crate::context::{ContextId, ContextKind};
 18use crate::context_picker::ContextPicker;
 19use crate::context_store::ContextStore;
 20use crate::thread::Thread;
 21use crate::thread_store::ThreadStore;
 22use crate::ui::{AddedContext, ContextPill};
 23use crate::{
 24    AcceptSuggestedContext, AssistantPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
 25    RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
 26};
 27
 28pub struct ContextStrip {
 29    context_store: Entity<ContextStore>,
 30    context_picker: Entity<ContextPicker>,
 31    context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 32    focus_handle: FocusHandle,
 33    suggest_context_kind: SuggestContextKind,
 34    workspace: WeakEntity<Workspace>,
 35    _subscriptions: Vec<Subscription>,
 36    focused_index: Option<usize>,
 37    children_bounds: Option<Vec<Bounds<Pixels>>>,
 38}
 39
 40impl ContextStrip {
 41    pub fn new(
 42        context_store: Entity<ContextStore>,
 43        workspace: WeakEntity<Workspace>,
 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                window,
 56                cx,
 57            )
 58        });
 59
 60        let focus_handle = cx.focus_handle();
 61
 62        let subscriptions = vec![
 63            cx.observe(&context_store, |_, _, cx| cx.notify()),
 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 project_path = active_buffer.project_path(cx)?;
 98
 99        if self
100            .context_store
101            .read(cx)
102            .will_include_buffer(active_buffer.remote_id(), &project_path)
103            .is_some()
104        {
105            return None;
106        }
107
108        let file_name = active_buffer.file()?.file_name(cx);
109
110        let icon_path = FileIcons::get_icon(&Path::new(&file_name), cx);
111
112        Some(SuggestedContext::File {
113            name: file_name.to_string_lossy().into_owned().into(),
114            buffer: active_buffer_entity.downgrade(),
115            icon_path,
116        })
117    }
118
119    fn suggested_thread(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
120        if !self.context_picker.read(cx).allow_threads() {
121            return None;
122        }
123
124        let workspace = self.workspace.upgrade()?;
125        let active_thread = workspace
126            .read(cx)
127            .panel::<AssistantPanel>(cx)?
128            .read(cx)
129            .active_thread(cx);
130        let weak_active_thread = active_thread.downgrade();
131
132        let active_thread = active_thread.read(cx);
133
134        if self
135            .context_store
136            .read(cx)
137            .includes_thread(active_thread.id())
138            .is_some()
139        {
140            return None;
141        }
142
143        Some(SuggestedContext::Thread {
144            name: active_thread.summary_or_default(),
145            thread: weak_active_thread,
146        })
147    }
148
149    fn handle_context_picker_event(
150        &mut self,
151        _picker: &Entity<ContextPicker>,
152        _event: &DismissEvent,
153        _window: &mut Window,
154        cx: &mut Context<Self>,
155    ) {
156        cx.emit(ContextStripEvent::PickerDismissed);
157    }
158
159    fn handle_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
160        self.focused_index = self.last_pill_index();
161        cx.notify();
162    }
163
164    fn handle_blur(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
165        self.focused_index = None;
166        cx.notify();
167    }
168
169    fn focus_left(&mut self, _: &FocusLeft, _window: &mut Window, cx: &mut Context<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, _window: &mut Window, cx: &mut Context<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, _window: &mut Window, cx: &mut Context<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, _window: &mut Window, cx: &mut Context<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() { None } else { Some(pills) }
242    }
243
244    fn last_pill_index(&self) -> Option<usize> {
245        Some(self.pill_bounds()?.len() - 1)
246    }
247
248    fn find_best_horizontal_match<'a>(
249        focused: &'a Bounds<Pixels>,
250        iter: impl Iterator<Item = (usize, &'a Bounds<Pixels>)>,
251    ) -> Option<usize> {
252        let mut best = None;
253
254        let focused_left = focused.left();
255        let focused_right = focused.right();
256
257        for (index, probe) in iter {
258            if probe.origin.y == focused.origin.y {
259                continue;
260            }
261
262            let overlap = probe.right().min(focused_right) - probe.left().max(focused_left);
263
264            best = match best {
265                Some((_, prev_overlap, y)) if probe.origin.y != y || prev_overlap > overlap => {
266                    break;
267                }
268                Some(_) | None => Some((index, overlap, probe.origin.y)),
269            };
270        }
271
272        best.map(|(index, _, _)| index)
273    }
274
275    fn open_context(&mut self, id: ContextId, window: &mut Window, cx: &mut App) {
276        let Some(workspace) = self.workspace.upgrade() else {
277            return;
278        };
279
280        crate::active_thread::open_context(id, self.context_store.clone(), workspace, window, cx);
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(), cx);
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, async move |this, cx| {
340            match task.await.notify_async_err(cx) {
341                None => {}
342                Some(()) => {
343                    if let Some(this) = this.upgrade() {
344                        this.update(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.context();
366        let context_picker = self.context_picker.clone();
367        let focus_handle = self.focus_handle.clone();
368
369        let suggested_context = self.suggested_context(cx);
370
371        let added_contexts = context
372            .iter()
373            .map(|c| AddedContext::new(c, cx))
374            .collect::<Vec<_>>();
375        let dupe_names = added_contexts
376            .iter()
377            .map(|c| c.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(
464                added_contexts
465                    .into_iter()
466                    .enumerate()
467                    .map(|(i, added_context)| {
468                        let name = added_context.name.clone();
469                        let id = added_context.id;
470                        ContextPill::added(
471                            added_context,
472                            dupe_names.contains(&name),
473                            self.focused_index == Some(i),
474                            Some({
475                                let context_store = self.context_store.clone();
476                                Rc::new(cx.listener(move |_this, _event, _window, cx| {
477                                    context_store.update(cx, |this, cx| {
478                                        this.remove_context(id, cx);
479                                    });
480                                    cx.notify();
481                                }))
482                            }),
483                        )
484                        .on_click({
485                            Rc::new(cx.listener(move |this, event: &ClickEvent, window, cx| {
486                                if event.down.click_count > 1 {
487                                    this.open_context(id, window, cx);
488                                } else {
489                                    this.focused_index = Some(i);
490                                }
491                                cx.notify();
492                            }))
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}