context_strip.rs

  1use std::rc::Rc;
  2
  3use collections::HashSet;
  4use editor::Editor;
  5use file_icons::FileIcons;
  6use gpui::{
  7    DismissEvent, EventEmitter, FocusHandle, Model, Subscription, View, WeakModel, WeakView,
  8};
  9use itertools::Itertools;
 10use language::Buffer;
 11use ui::{prelude::*, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip};
 12use workspace::Workspace;
 13
 14use crate::context::ContextKind;
 15use crate::context_picker::{ConfirmBehavior, ContextPicker};
 16use crate::context_store::ContextStore;
 17use crate::thread::Thread;
 18use crate::thread_store::ThreadStore;
 19use crate::ui::ContextPill;
 20use crate::{AssistantPanel, RemoveAllContext, ToggleContextPicker};
 21
 22pub struct ContextStrip {
 23    context_store: Model<ContextStore>,
 24    pub context_picker: View<ContextPicker>,
 25    context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 26    focus_handle: FocusHandle,
 27    suggest_context_kind: SuggestContextKind,
 28    workspace: WeakView<Workspace>,
 29    _context_picker_subscription: Subscription,
 30}
 31
 32impl ContextStrip {
 33    pub fn new(
 34        context_store: Model<ContextStore>,
 35        workspace: WeakView<Workspace>,
 36        thread_store: Option<WeakModel<ThreadStore>>,
 37        focus_handle: FocusHandle,
 38        context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 39        suggest_context_kind: SuggestContextKind,
 40        cx: &mut ViewContext<Self>,
 41    ) -> Self {
 42        let context_picker = cx.new_view(|cx| {
 43            ContextPicker::new(
 44                workspace.clone(),
 45                thread_store.clone(),
 46                context_store.downgrade(),
 47                ConfirmBehavior::KeepOpen,
 48                cx,
 49            )
 50        });
 51
 52        let context_picker_subscription =
 53            cx.subscribe(&context_picker, Self::handle_context_picker_event);
 54
 55        Self {
 56            context_store: context_store.clone(),
 57            context_picker,
 58            context_picker_menu_handle,
 59            focus_handle,
 60            suggest_context_kind,
 61            workspace,
 62            _context_picker_subscription: context_picker_subscription,
 63        }
 64    }
 65
 66    fn suggested_context(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
 67        match self.suggest_context_kind {
 68            SuggestContextKind::File => self.suggested_file(cx),
 69            SuggestContextKind::Thread => self.suggested_thread(cx),
 70        }
 71    }
 72
 73    fn suggested_file(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
 74        let workspace = self.workspace.upgrade()?;
 75        let active_item = workspace.read(cx).active_item(cx)?;
 76
 77        let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
 78        let active_buffer_model = editor.buffer().read(cx).as_singleton()?;
 79        let active_buffer = active_buffer_model.read(cx);
 80
 81        let path = active_buffer.file()?.path();
 82
 83        if self
 84            .context_store
 85            .read(cx)
 86            .will_include_buffer(active_buffer.remote_id(), path)
 87            .is_some()
 88        {
 89            return None;
 90        }
 91
 92        let name = match path.file_name() {
 93            Some(name) => name.to_string_lossy().into_owned().into(),
 94            None => path.to_string_lossy().into_owned().into(),
 95        };
 96
 97        let icon_path = FileIcons::get_icon(path, cx);
 98
 99        Some(SuggestedContext::File {
100            name,
101            buffer: active_buffer_model.downgrade(),
102            icon_path,
103        })
104    }
105
106    fn suggested_thread(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
107        let workspace = self.workspace.upgrade()?;
108        let active_thread = workspace
109            .read(cx)
110            .panel::<AssistantPanel>(cx)?
111            .read(cx)
112            .active_thread(cx);
113        let weak_active_thread = active_thread.downgrade();
114
115        let active_thread = active_thread.read(cx);
116
117        if self
118            .context_store
119            .read(cx)
120            .includes_thread(active_thread.id())
121            .is_some()
122        {
123            return None;
124        }
125
126        Some(SuggestedContext::Thread {
127            name: active_thread.summary_or_default(),
128            thread: weak_active_thread,
129        })
130    }
131
132    fn handle_context_picker_event(
133        &mut self,
134        _picker: View<ContextPicker>,
135        _event: &DismissEvent,
136        cx: &mut ViewContext<Self>,
137    ) {
138        cx.emit(ContextStripEvent::PickerDismissed);
139    }
140}
141
142impl Render for ContextStrip {
143    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
144        let context_store = self.context_store.read(cx);
145        let context = context_store
146            .context()
147            .iter()
148            .flat_map(|context| context.snapshot(cx))
149            .collect::<Vec<_>>();
150        let context_picker = self.context_picker.clone();
151        let focus_handle = self.focus_handle.clone();
152
153        let suggested_context = self.suggested_context(cx);
154
155        let dupe_names = context
156            .iter()
157            .map(|context| context.name.clone())
158            .sorted()
159            .tuple_windows()
160            .filter(|(a, b)| a == b)
161            .map(|(a, _)| a)
162            .collect::<HashSet<SharedString>>();
163
164        h_flex()
165            .flex_wrap()
166            .gap_1()
167            .child(
168                PopoverMenu::new("context-picker")
169                    .menu(move |cx| {
170                        context_picker.update(cx, |this, cx| {
171                            this.reset_mode(cx);
172                        });
173
174                        Some(context_picker.clone())
175                    })
176                    .trigger(
177                        IconButton::new("add-context", IconName::Plus)
178                            .icon_size(IconSize::Small)
179                            .style(ui::ButtonStyle::Filled)
180                            .tooltip({
181                                let focus_handle = focus_handle.clone();
182
183                                move |cx| {
184                                    Tooltip::for_action_in(
185                                        "Add Context",
186                                        &ToggleContextPicker,
187                                        &focus_handle,
188                                        cx,
189                                    )
190                                }
191                            }),
192                    )
193                    .attach(gpui::Corner::TopLeft)
194                    .anchor(gpui::Corner::BottomLeft)
195                    .offset(gpui::Point {
196                        x: px(0.0),
197                        y: px(-2.0),
198                    })
199                    .with_handle(self.context_picker_menu_handle.clone()),
200            )
201            .when(context.is_empty() && suggested_context.is_none(), {
202                |parent| {
203                    parent.child(
204                        h_flex()
205                            .ml_1p5()
206                            .gap_2()
207                            .child(
208                                Label::new("Add Context")
209                                    .size(LabelSize::Small)
210                                    .color(Color::Muted),
211                            )
212                            .opacity(0.5)
213                            .children(
214                                KeyBinding::for_action_in(&ToggleContextPicker, &focus_handle, cx)
215                                    .map(|binding| binding.into_any_element()),
216                            ),
217                    )
218                }
219            })
220            .children(context.iter().map(|context| {
221                ContextPill::new_added(
222                    context.clone(),
223                    dupe_names.contains(&context.name),
224                    Some({
225                        let id = context.id;
226                        let context_store = self.context_store.clone();
227                        Rc::new(cx.listener(move |_this, _event, cx| {
228                            context_store.update(cx, |this, _cx| {
229                                this.remove_context(id);
230                            });
231                            cx.notify();
232                        }))
233                    }),
234                )
235            }))
236            .when_some(suggested_context, |el, suggested| {
237                el.child(ContextPill::new_suggested(
238                    suggested.name().clone(),
239                    suggested.icon_path(),
240                    suggested.kind(),
241                    {
242                        let context_store = self.context_store.clone();
243                        Rc::new(cx.listener(move |this, _event, cx| {
244                            let task = context_store.update(cx, |context_store, cx| {
245                                context_store.accept_suggested_context(&suggested, cx)
246                            });
247
248                            let workspace = this.workspace.clone();
249                            cx.spawn(|this, mut cx| async move {
250                                match task.await {
251                                    Ok(()) => {
252                                        if let Some(this) = this.upgrade() {
253                                            this.update(&mut cx, |_, cx| cx.notify())?;
254                                        }
255                                    }
256                                    Err(err) => {
257                                        let Some(workspace) = workspace.upgrade() else {
258                                            return anyhow::Ok(());
259                                        };
260
261                                        workspace.update(&mut cx, |workspace, cx| {
262                                            workspace.show_error(&err, cx);
263                                        })?;
264                                    }
265                                }
266                                anyhow::Ok(())
267                            })
268                            .detach_and_log_err(cx);
269                        }))
270                    },
271                ))
272            })
273            .when(!context.is_empty(), {
274                move |parent| {
275                    parent.child(
276                        IconButton::new("remove-all-context", IconName::Eraser)
277                            .icon_size(IconSize::Small)
278                            .tooltip({
279                                let focus_handle = focus_handle.clone();
280                                move |cx| {
281                                    Tooltip::for_action_in(
282                                        "Remove All Context",
283                                        &RemoveAllContext,
284                                        &focus_handle,
285                                        cx,
286                                    )
287                                }
288                            })
289                            .on_click(cx.listener({
290                                let focus_handle = focus_handle.clone();
291                                move |_this, _event, cx| {
292                                    focus_handle.dispatch_action(&RemoveAllContext, cx);
293                                }
294                            })),
295                    )
296                }
297            })
298    }
299}
300
301pub enum ContextStripEvent {
302    PickerDismissed,
303}
304
305impl EventEmitter<ContextStripEvent> for ContextStrip {}
306
307pub enum SuggestContextKind {
308    File,
309    Thread,
310}
311
312#[derive(Clone)]
313pub enum SuggestedContext {
314    File {
315        name: SharedString,
316        icon_path: Option<SharedString>,
317        buffer: WeakModel<Buffer>,
318    },
319    Thread {
320        name: SharedString,
321        thread: WeakModel<Thread>,
322    },
323}
324
325impl SuggestedContext {
326    pub fn name(&self) -> &SharedString {
327        match self {
328            Self::File { name, .. } => name,
329            Self::Thread { name, .. } => name,
330        }
331    }
332
333    pub fn icon_path(&self) -> Option<SharedString> {
334        match self {
335            Self::File { icon_path, .. } => icon_path.clone(),
336            Self::Thread { .. } => None,
337        }
338    }
339
340    pub fn kind(&self) -> ContextKind {
341        match self {
342            Self::File { .. } => ContextKind::File,
343            Self::Thread { .. } => ContextKind::Thread,
344        }
345    }
346}