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::{ConfirmBehavior, 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                ConfirmBehavior::KeepOpen,
 56                window,
 57                cx,
 58            )
 59        });
 60
 61        let focus_handle = cx.focus_handle();
 62
 63        let subscriptions = vec![
 64            cx.observe(&context_store, |_, _, cx| cx.notify()),
 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 project_path = active_buffer.project_path(cx)?;
 99
100        if self
101            .context_store
102            .read(cx)
103            .will_include_buffer(active_buffer.remote_id(), &project_path)
104            .is_some()
105        {
106            return None;
107        }
108
109        let file_name = active_buffer.file()?.file_name(cx);
110
111        let icon_path = FileIcons::get_icon(&Path::new(&file_name), cx);
112
113        Some(SuggestedContext::File {
114            name: file_name.to_string_lossy().into_owned().into(),
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() { None } else { Some(pills) }
243    }
244
245    fn last_pill_index(&self) -> Option<usize> {
246        Some(self.pill_bounds()?.len() - 1)
247    }
248
249    fn find_best_horizontal_match<'a>(
250        focused: &'a Bounds<Pixels>,
251        iter: impl Iterator<Item = (usize, &'a Bounds<Pixels>)>,
252    ) -> Option<usize> {
253        let mut best = None;
254
255        let focused_left = focused.left();
256        let focused_right = focused.right();
257
258        for (index, probe) in iter {
259            if probe.origin.y == focused.origin.y {
260                continue;
261            }
262
263            let overlap = probe.right().min(focused_right) - probe.left().max(focused_left);
264
265            best = match best {
266                Some((_, prev_overlap, y)) if probe.origin.y != y || prev_overlap > overlap => {
267                    break;
268                }
269                Some(_) | None => Some((index, overlap, probe.origin.y)),
270            };
271        }
272
273        best.map(|(index, _, _)| index)
274    }
275
276    fn open_context(&mut self, id: ContextId, window: &mut Window, cx: &mut App) {
277        let Some(workspace) = self.workspace.upgrade() else {
278            return;
279        };
280
281        crate::active_thread::open_context(id, self.context_store.clone(), workspace, window, cx);
282    }
283
284    fn remove_focused_context(
285        &mut self,
286        _: &RemoveFocusedContext,
287        _window: &mut Window,
288        cx: &mut Context<Self>,
289    ) {
290        if let Some(index) = self.focused_index {
291            let mut is_empty = false;
292
293            self.context_store.update(cx, |this, cx| {
294                if let Some(item) = this.context().get(index) {
295                    this.remove_context(item.id(), cx);
296                }
297
298                is_empty = this.context().is_empty();
299            });
300
301            if is_empty {
302                cx.emit(ContextStripEvent::BlurredEmpty);
303            } else {
304                self.focused_index = Some(index.saturating_sub(1));
305                cx.notify();
306            }
307        }
308    }
309
310    fn is_suggested_focused<T>(&self, context: &Vec<T>) -> bool {
311        // We only suggest one item after the actual context
312        self.focused_index == Some(context.len())
313    }
314
315    fn accept_suggested_context(
316        &mut self,
317        _: &AcceptSuggestedContext,
318        window: &mut Window,
319        cx: &mut Context<Self>,
320    ) {
321        if let Some(suggested) = self.suggested_context(cx) {
322            let context_store = self.context_store.read(cx);
323
324            if self.is_suggested_focused(context_store.context()) {
325                self.add_suggested_context(&suggested, window, cx);
326            }
327        }
328    }
329
330    fn add_suggested_context(
331        &mut self,
332        suggested: &SuggestedContext,
333        window: &mut Window,
334        cx: &mut Context<Self>,
335    ) {
336        let task = self.context_store.update(cx, |context_store, cx| {
337            context_store.accept_suggested_context(&suggested, cx)
338        });
339
340        cx.spawn_in(window, async move |this, cx| {
341            match task.await.notify_async_err(cx) {
342                None => {}
343                Some(()) => {
344                    if let Some(this) = this.upgrade() {
345                        this.update(cx, |_, cx| cx.notify())?;
346                    }
347                }
348            }
349            anyhow::Ok(())
350        })
351        .detach_and_log_err(cx);
352
353        cx.notify();
354    }
355}
356
357impl Focusable for ContextStrip {
358    fn focus_handle(&self, _cx: &App) -> FocusHandle {
359        self.focus_handle.clone()
360    }
361}
362
363impl Render for ContextStrip {
364    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
365        let context_store = self.context_store.read(cx);
366        let context = context_store.context();
367        let context_picker = self.context_picker.clone();
368        let focus_handle = self.focus_handle.clone();
369
370        let suggested_context = self.suggested_context(cx);
371
372        let added_contexts = context
373            .iter()
374            .map(|c| AddedContext::new(c, cx))
375            .collect::<Vec<_>>();
376        let dupe_names = added_contexts
377            .iter()
378            .map(|c| c.name.clone())
379            .sorted()
380            .tuple_windows()
381            .filter(|(a, b)| a == b)
382            .map(|(a, _)| a)
383            .collect::<HashSet<SharedString>>();
384
385        h_flex()
386            .flex_wrap()
387            .gap_1()
388            .track_focus(&focus_handle)
389            .key_context("ContextStrip")
390            .on_action(cx.listener(Self::focus_up))
391            .on_action(cx.listener(Self::focus_right))
392            .on_action(cx.listener(Self::focus_down))
393            .on_action(cx.listener(Self::focus_left))
394            .on_action(cx.listener(Self::remove_focused_context))
395            .on_action(cx.listener(Self::accept_suggested_context))
396            .on_children_prepainted({
397                let entity = cx.entity().downgrade();
398                move |children_bounds, _window, cx| {
399                    entity
400                        .update(cx, |this, _| {
401                            this.children_bounds = Some(children_bounds);
402                        })
403                        .ok();
404                }
405            })
406            .child(
407                PopoverMenu::new("context-picker")
408                    .menu(move |window, cx| {
409                        context_picker.update(cx, |this, cx| {
410                            this.init(window, cx);
411                        });
412
413                        Some(context_picker.clone())
414                    })
415                    .trigger_with_tooltip(
416                        IconButton::new("add-context", IconName::Plus)
417                            .icon_size(IconSize::Small)
418                            .style(ui::ButtonStyle::Filled),
419                        {
420                            let focus_handle = focus_handle.clone();
421                            move |window, cx| {
422                                Tooltip::for_action_in(
423                                    "Add Context",
424                                    &ToggleContextPicker,
425                                    &focus_handle,
426                                    window,
427                                    cx,
428                                )
429                            }
430                        },
431                    )
432                    .attach(gpui::Corner::TopLeft)
433                    .anchor(gpui::Corner::BottomLeft)
434                    .offset(gpui::Point {
435                        x: px(0.0),
436                        y: px(-2.0),
437                    })
438                    .with_handle(self.context_picker_menu_handle.clone()),
439            )
440            .when(context.is_empty() && suggested_context.is_none(), {
441                |parent| {
442                    parent.child(
443                        h_flex()
444                            .ml_1p5()
445                            .gap_2()
446                            .child(
447                                Label::new("Add Context")
448                                    .size(LabelSize::Small)
449                                    .color(Color::Muted),
450                            )
451                            .opacity(0.5)
452                            .children(
453                                KeyBinding::for_action_in(
454                                    &ToggleContextPicker,
455                                    &focus_handle,
456                                    window,
457                                    cx,
458                                )
459                                .map(|binding| binding.into_any_element()),
460                            ),
461                    )
462                }
463            })
464            .children(
465                added_contexts
466                    .into_iter()
467                    .enumerate()
468                    .map(|(i, added_context)| {
469                        let name = added_context.name.clone();
470                        let id = added_context.id;
471                        ContextPill::added(
472                            added_context,
473                            dupe_names.contains(&name),
474                            self.focused_index == Some(i),
475                            Some({
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, cx);
480                                    });
481                                    cx.notify();
482                                }))
483                            }),
484                        )
485                        .on_click({
486                            Rc::new(cx.listener(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            )
497            .when_some(suggested_context, |el, suggested| {
498                el.child(
499                    ContextPill::suggested(
500                        suggested.name().clone(),
501                        suggested.icon_path(),
502                        suggested.kind(),
503                        self.is_suggested_focused(&context),
504                    )
505                    .on_click(Rc::new(cx.listener(
506                        move |this, _event, window, cx| {
507                            this.add_suggested_context(&suggested, window, cx);
508                        },
509                    ))),
510                )
511            })
512            .when(!context.is_empty(), {
513                move |parent| {
514                    parent.child(
515                        IconButton::new("remove-all-context", IconName::Eraser)
516                            .icon_size(IconSize::Small)
517                            .tooltip({
518                                let focus_handle = focus_handle.clone();
519                                move |window, cx| {
520                                    Tooltip::for_action_in(
521                                        "Remove All Context",
522                                        &RemoveAllContext,
523                                        &focus_handle,
524                                        window,
525                                        cx,
526                                    )
527                                }
528                            })
529                            .on_click(cx.listener({
530                                let focus_handle = focus_handle.clone();
531                                move |_this, _event, window, cx| {
532                                    focus_handle.dispatch_action(&RemoveAllContext, window, cx);
533                                }
534                            })),
535                    )
536                }
537            })
538    }
539}
540
541pub enum ContextStripEvent {
542    PickerDismissed,
543    BlurredEmpty,
544    BlurredDown,
545    BlurredUp,
546}
547
548impl EventEmitter<ContextStripEvent> for ContextStrip {}
549
550pub enum SuggestContextKind {
551    File,
552    Thread,
553}
554
555#[derive(Clone)]
556pub enum SuggestedContext {
557    File {
558        name: SharedString,
559        icon_path: Option<SharedString>,
560        buffer: WeakEntity<Buffer>,
561    },
562    Thread {
563        name: SharedString,
564        thread: WeakEntity<Thread>,
565    },
566}
567
568impl SuggestedContext {
569    pub fn name(&self) -> &SharedString {
570        match self {
571            Self::File { name, .. } => name,
572            Self::Thread { name, .. } => name,
573        }
574    }
575
576    pub fn icon_path(&self) -> Option<SharedString> {
577        match self {
578            Self::File { icon_path, .. } => icon_path.clone(),
579            Self::Thread { .. } => None,
580        }
581    }
582
583    pub fn kind(&self) -> ContextKind {
584        match self {
585            Self::File { .. } => ContextKind::File,
586            Self::Thread { .. } => ContextKind::Thread,
587        }
588    }
589}