copilot_button.rs

  1use std::sync::Arc;
  2
  3use context_menu::{ContextMenu, ContextMenuItem};
  4use editor::Editor;
  5use gpui::{
  6    elements::*,
  7    impl_internal_actions,
  8    platform::{CursorStyle, MouseButton},
  9    AppContext, Element, ElementBox, Entity, MouseState, RenderContext, Subscription, View,
 10    ViewContext, ViewHandle,
 11};
 12use settings::{settings_file::SettingsFile, Settings};
 13use workspace::{
 14    item::ItemHandle, notifications::simple_message_notification::OsOpen, DismissToast,
 15    StatusItemView,
 16};
 17
 18use copilot::{Copilot, Reinstall, SignIn, SignOut, Status};
 19
 20const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
 21const COPILOT_STARTING_TOAST_ID: usize = 1337;
 22const COPILOT_ERROR_TOAST_ID: usize = 1338;
 23
 24#[derive(Clone, PartialEq)]
 25pub struct DeployCopilotMenu;
 26
 27#[derive(Clone, PartialEq)]
 28pub struct ToggleCopilotForLanguage {
 29    language: Arc<str>,
 30}
 31
 32#[derive(Clone, PartialEq)]
 33pub struct ToggleCopilotGlobally;
 34
 35// TODO: Make the other code path use `get_or_insert` logic for this modal
 36#[derive(Clone, PartialEq)]
 37pub struct DeployCopilotModal;
 38
 39impl_internal_actions!(
 40    copilot,
 41    [
 42        DeployCopilotMenu,
 43        DeployCopilotModal,
 44        ToggleCopilotForLanguage,
 45        ToggleCopilotGlobally,
 46    ]
 47);
 48
 49pub fn init(cx: &mut AppContext) {
 50    cx.add_action(CopilotButton::deploy_copilot_menu);
 51    cx.add_action(
 52        |_: &mut CopilotButton, action: &ToggleCopilotForLanguage, cx| {
 53            let language = action.language.clone();
 54            let show_copilot_suggestions = cx
 55                .global::<Settings>()
 56                .show_copilot_suggestions(Some(&language));
 57
 58            SettingsFile::update(cx, move |file_contents| {
 59                file_contents.languages.insert(
 60                    language,
 61                    settings::EditorSettings {
 62                        show_copilot_suggestions: Some((!show_copilot_suggestions).into()),
 63                        ..Default::default()
 64                    },
 65                );
 66            })
 67        },
 68    );
 69
 70    cx.add_action(|_: &mut CopilotButton, _: &ToggleCopilotGlobally, cx| {
 71        let show_copilot_suggestions = cx.global::<Settings>().show_copilot_suggestions(None);
 72        SettingsFile::update(cx, move |file_contents| {
 73            file_contents.editor.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
 74        })
 75    });
 76}
 77
 78pub struct CopilotButton {
 79    popup_menu: ViewHandle<ContextMenu>,
 80    editor_subscription: Option<(Subscription, usize)>,
 81    editor_enabled: Option<bool>,
 82    language: Option<Arc<str>>,
 83}
 84
 85impl Entity for CopilotButton {
 86    type Event = ();
 87}
 88
 89impl View for CopilotButton {
 90    fn ui_name() -> &'static str {
 91        "CopilotButton"
 92    }
 93
 94    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
 95        let settings = cx.global::<Settings>();
 96
 97        if !settings.features.copilot {
 98            return Empty::new().boxed();
 99        }
100
101        let theme = settings.theme.clone();
102        let active = self.popup_menu.read(cx).visible();
103        let Some(copilot) = Copilot::global(cx) else {
104            return Empty::new().boxed();
105        };
106        let status = copilot.read(cx).status();
107
108        let enabled = self
109            .editor_enabled
110            .unwrap_or(settings.show_copilot_suggestions(None));
111
112        let view_id = cx.view_id();
113
114        Stack::new()
115            .with_child(
116                MouseEventHandler::<Self>::new(0, cx, {
117                    let theme = theme.clone();
118                    let status = status.clone();
119                    move |state, _cx| {
120                        let style = theme
121                            .workspace
122                            .status_bar
123                            .sidebar_buttons
124                            .item
125                            .style_for(state, active);
126
127                        Flex::row()
128                            .with_child(
129                                Svg::new({
130                                    match status {
131                                        Status::Error(_) => "icons/copilot_error_16.svg",
132                                        Status::Authorized => {
133                                            if enabled {
134                                                "icons/copilot_16.svg"
135                                            } else {
136                                                "icons/copilot_disabled_16.svg"
137                                            }
138                                        }
139                                        _ => "icons/copilot_init_16.svg",
140                                    }
141                                })
142                                .with_color(style.icon_color)
143                                .constrained()
144                                .with_width(style.icon_size)
145                                .aligned()
146                                .named("copilot-icon"),
147                            )
148                            .constrained()
149                            .with_height(style.icon_size)
150                            .contained()
151                            .with_style(style.container)
152                            .boxed()
153                    }
154                })
155                .with_cursor_style(CursorStyle::PointingHand)
156                .on_click(MouseButton::Left, {
157                    let status = status.clone();
158                    move |_, cx| match status {
159                        Status::Authorized => cx.dispatch_action(DeployCopilotMenu),
160                        Status::Starting { ref task } => {
161                            cx.dispatch_action(workspace::Toast::new(
162                                COPILOT_STARTING_TOAST_ID,
163                                "Copilot is starting...",
164                            ));
165                            let window_id = cx.window_id();
166                            let task = task.to_owned();
167                            cx.spawn(|mut cx| async move {
168                                task.await;
169                                cx.update(|cx| {
170                                    if let Some(copilot) = Copilot::global(cx) {
171                                        let status = copilot.read(cx).status();
172                                        match status {
173                                            Status::Authorized => cx.dispatch_action_at(
174                                                window_id,
175                                                view_id,
176                                                workspace::Toast::new(
177                                                    COPILOT_STARTING_TOAST_ID,
178                                                    "Copilot has started!",
179                                                ),
180                                            ),
181                                            _ => {
182                                                cx.dispatch_action_at(
183                                                    window_id,
184                                                    view_id,
185                                                    DismissToast::new(COPILOT_STARTING_TOAST_ID),
186                                                );
187                                                cx.dispatch_global_action(SignIn)
188                                            }
189                                        }
190                                    }
191                                })
192                            })
193                            .detach();
194                        }
195                        Status::Error(ref e) => cx.dispatch_action(workspace::Toast::new_action(
196                            COPILOT_ERROR_TOAST_ID,
197                            format!("Copilot can't be started: {}", e),
198                            "Reinstall Copilot",
199                            Reinstall,
200                        )),
201                        _ => cx.dispatch_action(SignIn),
202                    }
203                })
204                .with_tooltip::<Self, _>(
205                    0,
206                    "GitHub Copilot".into(),
207                    None,
208                    theme.tooltip.clone(),
209                    cx,
210                )
211                .boxed(),
212            )
213            .with_child(
214                ChildView::new(&self.popup_menu, cx)
215                    .aligned()
216                    .top()
217                    .right()
218                    .boxed(),
219            )
220            .boxed()
221    }
222}
223
224impl CopilotButton {
225    pub fn new(cx: &mut ViewContext<Self>) -> Self {
226        let menu = cx.add_view(|cx| {
227            let mut menu = ContextMenu::new(cx);
228            menu.set_position_mode(OverlayPositionMode::Local);
229            menu
230        });
231
232        cx.observe(&menu, |_, _, cx| cx.notify()).detach();
233
234        Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
235
236        cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
237            .detach();
238
239        Self {
240            popup_menu: menu,
241            editor_subscription: None,
242            editor_enabled: None,
243            language: None,
244        }
245    }
246
247    pub fn deploy_copilot_menu(&mut self, _: &DeployCopilotMenu, cx: &mut ViewContext<Self>) {
248        let settings = cx.global::<Settings>();
249
250        let mut menu_options = Vec::with_capacity(6);
251
252        if let Some(language) = &self.language {
253            let language_enabled = settings.show_copilot_suggestions(Some(language.as_ref()));
254
255            menu_options.push(ContextMenuItem::item(
256                format!(
257                    "{} Copilot for {}",
258                    if language_enabled {
259                        "Disable"
260                    } else {
261                        "Enable"
262                    },
263                    language
264                ),
265                ToggleCopilotForLanguage {
266                    language: language.to_owned(),
267                },
268            ));
269        }
270
271        let globally_enabled = cx.global::<Settings>().show_copilot_suggestions(None);
272        menu_options.push(ContextMenuItem::item(
273            if globally_enabled {
274                "Disable Copilot Globally"
275            } else {
276                "Enable Copilot Globally"
277            },
278            ToggleCopilotGlobally,
279        ));
280
281        menu_options.push(ContextMenuItem::Separator);
282
283        let icon_style = settings.theme.copilot.out_link_icon.clone();
284        menu_options.push(ContextMenuItem::element_item(
285            Box::new(
286                move |state: &mut MouseState, style: &theme::ContextMenuItem| {
287                    Flex::row()
288                        .with_children([
289                            Label::new("Copilot Settings", style.label.clone()).boxed(),
290                            theme::ui::icon(icon_style.style_for(state, false)).boxed(),
291                        ])
292                        .align_children_center()
293                        .boxed()
294                },
295            ),
296            OsOpen::new(COPILOT_SETTINGS_URL),
297        ));
298
299        menu_options.push(ContextMenuItem::item("Sign Out", SignOut));
300
301        self.popup_menu.update(cx, |menu, cx| {
302            menu.show(
303                Default::default(),
304                AnchorCorner::BottomRight,
305                menu_options,
306                cx,
307            );
308        });
309    }
310
311    pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
312        let editor = editor.read(cx);
313
314        let snapshot = editor.buffer().read(cx).snapshot(cx);
315        let settings = cx.global::<Settings>();
316        let suggestion_anchor = editor.selections.newest_anchor().start;
317
318        let language_name = snapshot
319            .language_at(suggestion_anchor)
320            .map(|language| language.name());
321
322        self.language = language_name.clone();
323
324        self.editor_enabled = Some(settings.show_copilot_suggestions(language_name.as_deref()));
325
326        cx.notify()
327    }
328}
329
330impl StatusItemView for CopilotButton {
331    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
332        if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
333            self.editor_subscription =
334                Some((cx.observe(&editor, Self::update_enabled), editor.id()));
335            self.update_enabled(editor, cx);
336        } else {
337            self.language = None;
338            self.editor_subscription = None;
339            self.editor_enabled = None;
340        }
341        cx.notify();
342    }
343}