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, Subscription, View, ViewContext,
 10    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.to_owned();
 54
 55            let current_langauge = cx.global::<Settings>().copilot_on(Some(&language));
 56
 57            SettingsFile::update(cx, move |file_contents| {
 58                file_contents.languages.insert(
 59                    language.to_owned(),
 60                    settings::EditorSettings {
 61                        copilot: Some((!current_langauge).into()),
 62                        ..Default::default()
 63                    },
 64                );
 65            })
 66        },
 67    );
 68
 69    cx.add_action(|_: &mut CopilotButton, _: &ToggleCopilotGlobally, cx| {
 70        let copilot_on = cx.global::<Settings>().copilot_on(None);
 71
 72        SettingsFile::update(cx, move |file_contents| {
 73            file_contents.editor.copilot = Some((!copilot_on).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 ViewContext<'_, Self>) -> ElementBox {
 95        let settings = cx.global::<Settings>();
 96
 97        if !settings.enable_copilot_integration {
 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.editor_enabled.unwrap_or(settings.copilot_on(None));
109
110        let view_id = cx.view_id();
111
112        Stack::new()
113            .with_child(
114                MouseEventHandler::<Self>::new(0, cx, {
115                    let theme = theme.clone();
116                    let status = status.clone();
117                    move |state, _cx| {
118                        let style = theme
119                            .workspace
120                            .status_bar
121                            .sidebar_buttons
122                            .item
123                            .style_for(state, active);
124
125                        Flex::row()
126                            .with_child(
127                                Svg::new({
128                                    match status {
129                                        Status::Error(_) => "icons/copilot_error_16.svg",
130                                        Status::Authorized => {
131                                            if enabled {
132                                                "icons/copilot_16.svg"
133                                            } else {
134                                                "icons/copilot_disabled_16.svg"
135                                            }
136                                        }
137                                        _ => "icons/copilot_init_16.svg",
138                                    }
139                                })
140                                .with_color(style.icon_color)
141                                .constrained()
142                                .with_width(style.icon_size)
143                                .aligned()
144                                .named("copilot-icon"),
145                            )
146                            .constrained()
147                            .with_height(style.icon_size)
148                            .contained()
149                            .with_style(style.container)
150                            .boxed()
151                    }
152                })
153                .with_cursor_style(CursorStyle::PointingHand)
154                .on_click(MouseButton::Left, {
155                    let status = status.clone();
156                    move |_, cx| match status {
157                        Status::Authorized => cx.dispatch_action(DeployCopilotMenu),
158                        Status::Starting { ref task } => {
159                            cx.dispatch_action(workspace::Toast::new(
160                                COPILOT_STARTING_TOAST_ID,
161                                "Copilot is starting...",
162                            ));
163                            let window_id = cx.window_id();
164                            let task = task.to_owned();
165                            cx.spawn(|mut cx| async move {
166                                task.await;
167                                cx.update(|cx| {
168                                    if let Some(copilot) = Copilot::global(cx) {
169                                        let status = copilot.read(cx).status();
170                                        match status {
171                                            Status::Authorized => cx.dispatch_action_at(
172                                                window_id,
173                                                view_id,
174                                                workspace::Toast::new(
175                                                    COPILOT_STARTING_TOAST_ID,
176                                                    "Copilot has started!",
177                                                ),
178                                            ),
179                                            _ => {
180                                                cx.dispatch_action_at(
181                                                    window_id,
182                                                    view_id,
183                                                    DismissToast::new(COPILOT_STARTING_TOAST_ID),
184                                                );
185                                                cx.dispatch_global_action(SignIn)
186                                            }
187                                        }
188                                    }
189                                })
190                            })
191                            .detach();
192                        }
193                        Status::Error(ref e) => cx.dispatch_action(workspace::Toast::new_action(
194                            COPILOT_ERROR_TOAST_ID,
195                            format!("Copilot can't be started: {}", e),
196                            "Reinstall Copilot",
197                            Reinstall,
198                        )),
199                        _ => cx.dispatch_action(SignIn),
200                    }
201                })
202                .with_tooltip::<Self, _>(
203                    0,
204                    "GitHub Copilot".into(),
205                    None,
206                    theme.tooltip.clone(),
207                    cx,
208                )
209                .boxed(),
210            )
211            .with_child(
212                ChildView::new(&self.popup_menu, cx)
213                    .aligned()
214                    .top()
215                    .right()
216                    .boxed(),
217            )
218            .boxed()
219    }
220}
221
222impl CopilotButton {
223    pub fn new(cx: &mut ViewContext<Self>) -> Self {
224        let menu = cx.add_view(|cx| {
225            let mut menu = ContextMenu::new(cx);
226            menu.set_position_mode(OverlayPositionMode::Local);
227            menu
228        });
229
230        cx.observe(&menu, |_, _, cx| cx.notify()).detach();
231
232        Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
233
234        cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
235            .detach();
236
237        Self {
238            popup_menu: menu,
239            editor_subscription: None,
240            editor_enabled: None,
241            language: None,
242        }
243    }
244
245    pub fn deploy_copilot_menu(&mut self, _: &DeployCopilotMenu, cx: &mut ViewContext<Self>) {
246        let settings = cx.global::<Settings>();
247
248        let mut menu_options = Vec::with_capacity(6);
249
250        if let Some(language) = &self.language {
251            let language_enabled = settings.copilot_on(Some(language.as_ref()));
252
253            menu_options.push(ContextMenuItem::item(
254                format!(
255                    "{} Copilot for {}",
256                    if language_enabled {
257                        "Disable"
258                    } else {
259                        "Enable"
260                    },
261                    language
262                ),
263                ToggleCopilotForLanguage {
264                    language: language.to_owned(),
265                },
266            ));
267        }
268
269        let globally_enabled = cx.global::<Settings>().copilot_on(None);
270        menu_options.push(ContextMenuItem::item(
271            if globally_enabled {
272                "Disable Copilot Globally"
273            } else {
274                "Enable Copilot Globally"
275            },
276            ToggleCopilotGlobally,
277        ));
278
279        menu_options.push(ContextMenuItem::Separator);
280
281        let icon_style = settings.theme.copilot.out_link_icon.clone();
282        menu_options.push(ContextMenuItem::element_item(
283            Box::new(
284                move |state: &mut MouseState, style: &theme::ContextMenuItem| {
285                    Flex::row()
286                        .with_children([
287                            Label::new("Copilot Settings", style.label.clone()).boxed(),
288                            theme::ui::icon(icon_style.style_for(state, false)).boxed(),
289                        ])
290                        .align_children_center()
291                        .boxed()
292                },
293            ),
294            OsOpen::new(COPILOT_SETTINGS_URL),
295        ));
296
297        menu_options.push(ContextMenuItem::item("Sign Out", SignOut));
298
299        self.popup_menu.update(cx, |menu, cx| {
300            menu.show(
301                Default::default(),
302                AnchorCorner::BottomRight,
303                menu_options,
304                cx,
305            );
306        });
307    }
308
309    pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
310        let editor = editor.read(cx);
311
312        let snapshot = editor.buffer().read(cx).snapshot(cx);
313        let settings = cx.global::<Settings>();
314        let suggestion_anchor = editor.selections.newest_anchor().start;
315
316        let language_name = snapshot
317            .language_at(suggestion_anchor)
318            .map(|language| language.name());
319
320        self.language = language_name.clone();
321
322        self.editor_enabled = Some(settings.copilot_on(language_name.as_deref()));
323
324        cx.notify()
325    }
326}
327
328impl StatusItemView for CopilotButton {
329    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
330        if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
331            self.editor_subscription =
332                Some((cx.observe(&editor, Self::update_enabled), editor.id()));
333            self.update_enabled(editor, cx);
334        } else {
335            self.language = None;
336            self.editor_subscription = None;
337            self.editor_enabled = None;
338        }
339        cx.notify();
340    }
341}