copilot_button.rs

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