copilot_button.rs

  1use anyhow::Result;
  2use context_menu::{ContextMenu, ContextMenuItem};
  3use copilot::{Copilot, SignOut, Status};
  4use editor::{scroll::autoscroll::Autoscroll, Editor};
  5use gpui::{
  6    elements::*,
  7    platform::{CursorStyle, MouseButton},
  8    AnyElement, AppContext, AsyncAppContext, Element, Entity, MouseState, Subscription, View,
  9    ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 10};
 11use settings::{settings_file::SettingsFile, Settings};
 12use std::{path::Path, sync::Arc};
 13use util::{paths, ResultExt};
 14use workspace::{
 15    create_and_open_local_file, item::ItemHandle,
 16    notifications::simple_message_notification::OsOpen, StatusItemView, Toast, Workspace,
 17};
 18
 19const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
 20const COPILOT_STARTING_TOAST_ID: usize = 1337;
 21const COPILOT_ERROR_TOAST_ID: usize = 1338;
 22
 23pub struct CopilotButton {
 24    popup_menu: ViewHandle<ContextMenu>,
 25    editor_subscription: Option<(Subscription, usize)>,
 26    editor_enabled: Option<bool>,
 27    language: Option<Arc<str>>,
 28    path: Option<Arc<Path>>,
 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, 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(
109                                            COPILOT_ERROR_TOAST_ID,
110                                            format!("Copilot can't be started: {}", e),
111                                        )
112                                        .on_click(
113                                            "Reinstall Copilot",
114                                            |cx| {
115                                                if let Some(copilot) = Copilot::global(cx) {
116                                                    copilot
117                                                        .update(cx, |copilot, cx| {
118                                                            copilot.reinstall(cx)
119                                                        })
120                                                        .detach();
121                                                }
122                                            },
123                                        ),
124                                        cx,
125                                    );
126                                });
127                            }
128                        }
129                        _ => this.deploy_copilot_start_menu(cx),
130                    }
131                })
132                .with_tooltip::<Self>(
133                    0,
134                    "GitHub Copilot".into(),
135                    None,
136                    theme.tooltip.clone(),
137                    cx,
138                ),
139            )
140            .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
141            .into_any()
142    }
143}
144
145impl CopilotButton {
146    pub fn new(cx: &mut ViewContext<Self>) -> Self {
147        let menu = cx.add_view(|cx| {
148            let mut menu = ContextMenu::new(cx);
149            menu.set_position_mode(OverlayPositionMode::Local);
150            menu
151        });
152
153        cx.observe(&menu, |_, _, cx| cx.notify()).detach();
154
155        Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
156
157        cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
158            .detach();
159
160        Self {
161            popup_menu: menu,
162            editor_subscription: None,
163            editor_enabled: None,
164            language: None,
165            path: None,
166        }
167    }
168
169    pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
170        let mut menu_options = Vec::with_capacity(2);
171
172        menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
173            initiate_sign_in(cx)
174        }));
175        menu_options.push(ContextMenuItem::handler("Disable Copilot", |cx| {
176            hide_copilot(cx)
177        }));
178
179        self.popup_menu.update(cx, |menu, cx| {
180            menu.show(
181                Default::default(),
182                AnchorCorner::BottomRight,
183                menu_options,
184                cx,
185            );
186        });
187    }
188
189    pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
190        let settings = cx.global::<Settings>();
191
192        let mut menu_options = Vec::with_capacity(8);
193
194        if let Some(language) = self.language.clone() {
195            let language_enabled = settings.copilot_enabled_for_language(Some(language.as_ref()));
196            menu_options.push(ContextMenuItem::handler(
197                format!(
198                    "{} Suggestions for {}",
199                    if language_enabled { "Hide" } else { "Show" },
200                    language
201                ),
202                move |cx| toggle_copilot_for_language(language.clone(), cx),
203            ));
204        }
205
206        if let Some(path) = self.path.as_ref() {
207            let path_enabled = settings.copilot_enabled_for_path(path);
208            let path = path.clone();
209            menu_options.push(ContextMenuItem::handler(
210                format!(
211                    "{} Suggestions for This Path",
212                    if path_enabled { "Hide" } else { "Show" }
213                ),
214                move |cx| {
215                    if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() {
216                        let workspace = workspace.downgrade();
217                        cx.spawn(|_, cx| {
218                            configure_disabled_globs(
219                                workspace,
220                                path_enabled.then_some(path.clone()),
221                                cx,
222                            )
223                        })
224                        .detach_and_log_err(cx);
225                    }
226                },
227            ));
228        }
229
230        let globally_enabled = cx.global::<Settings>().features.copilot;
231        menu_options.push(ContextMenuItem::handler(
232            if globally_enabled {
233                "Hide Suggestions for All Files"
234            } else {
235                "Show Suggestions for All Files"
236            },
237            |cx| toggle_copilot_globally(cx),
238        ));
239
240        menu_options.push(ContextMenuItem::Separator);
241
242        let icon_style = settings.theme.copilot.out_link_icon.clone();
243        menu_options.push(ContextMenuItem::action(
244            move |state: &mut MouseState, style: &theme::ContextMenuItem| {
245                Flex::row()
246                    .with_child(Label::new("Copilot Settings", style.label.clone()))
247                    .with_child(theme::ui::icon(icon_style.style_for(state, false)))
248                    .align_children_center()
249                    .into_any()
250            },
251            OsOpen::new(COPILOT_SETTINGS_URL),
252        ));
253
254        menu_options.push(ContextMenuItem::action("Sign Out", SignOut));
255
256        self.popup_menu.update(cx, |menu, cx| {
257            menu.show(
258                Default::default(),
259                AnchorCorner::BottomRight,
260                menu_options,
261                cx,
262            );
263        });
264    }
265
266    pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
267        let editor = editor.read(cx);
268
269        let snapshot = editor.buffer().read(cx).snapshot(cx);
270        let settings = cx.global::<Settings>();
271        let suggestion_anchor = editor.selections.newest_anchor().start;
272
273        let language_name = snapshot
274            .language_at(suggestion_anchor)
275            .map(|language| language.name());
276        let path = snapshot
277            .file_at(suggestion_anchor)
278            .map(|file| file.path().clone());
279
280        self.editor_enabled =
281            Some(settings.show_copilot_suggestions(language_name.as_deref(), path.as_deref()));
282        self.language = language_name;
283        self.path = path;
284
285        cx.notify()
286    }
287}
288
289impl StatusItemView for CopilotButton {
290    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
291        if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
292            self.editor_subscription =
293                Some((cx.observe(&editor, Self::update_enabled), editor.id()));
294            self.update_enabled(editor, cx);
295        } else {
296            self.language = None;
297            self.editor_subscription = None;
298            self.editor_enabled = None;
299        }
300        cx.notify();
301    }
302}
303
304async fn configure_disabled_globs(
305    workspace: WeakViewHandle<Workspace>,
306    path_to_disable: Option<Arc<Path>>,
307    mut cx: AsyncAppContext,
308) -> Result<()> {
309    let settings_editor = workspace
310        .update(&mut cx, |_, cx| {
311            create_and_open_local_file(&paths::SETTINGS, cx, || {
312                Settings::initial_user_settings_content(&assets::Assets)
313                    .as_ref()
314                    .into()
315            })
316        })?
317        .await?
318        .downcast::<Editor>()
319        .unwrap();
320
321    settings_editor.downgrade().update(&mut cx, |item, cx| {
322        let text = item.buffer().read(cx).snapshot(cx).text();
323
324        let edits = SettingsFile::update_unsaved(&text, cx, |file| {
325            let copilot = file.copilot.get_or_insert_with(Default::default);
326            let globs = copilot.disabled_globs.get_or_insert_with(|| {
327                cx.global::<Settings>()
328                    .copilot
329                    .disabled_globs
330                    .clone()
331                    .iter()
332                    .map(|glob| glob.as_str().to_string())
333                    .collect::<Vec<_>>()
334            });
335
336            if let Some(path_to_disable) = &path_to_disable {
337                globs.push(path_to_disable.to_string_lossy().into_owned());
338            } else {
339                globs.clear();
340            }
341        });
342
343        if !edits.is_empty() {
344            item.change_selections(Some(Autoscroll::newest()), cx, |selections| {
345                selections.select_ranges(edits.iter().map(|e| e.0.clone()));
346            });
347
348            // When *enabling* a path, don't actually perform an edit, just select the range.
349            if path_to_disable.is_some() {
350                item.edit(edits.iter().cloned(), cx);
351            }
352        }
353    })?;
354
355    anyhow::Ok(())
356}
357
358fn toggle_copilot_globally(cx: &mut AppContext) {
359    let show_copilot_suggestions = cx.global::<Settings>().show_copilot_suggestions(None, None);
360    SettingsFile::update(cx, move |file_contents| {
361        file_contents.editor.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
362    });
363}
364
365fn toggle_copilot_for_language(language: Arc<str>, cx: &mut AppContext) {
366    let show_copilot_suggestions = cx
367        .global::<Settings>()
368        .show_copilot_suggestions(Some(&language), None);
369
370    SettingsFile::update(cx, move |file_contents| {
371        file_contents.languages.insert(
372            language,
373            settings::EditorSettings {
374                show_copilot_suggestions: Some((!show_copilot_suggestions).into()),
375                ..Default::default()
376            },
377        );
378    });
379}
380
381fn hide_copilot(cx: &mut AppContext) {
382    SettingsFile::update(cx, move |file_contents| {
383        file_contents.features.copilot = Some(false)
384    });
385}
386
387fn initiate_sign_in(cx: &mut WindowContext) {
388    let Some(copilot) = Copilot::global(cx) else {
389        return;
390    };
391    let status = copilot.read(cx).status();
392
393    match status {
394        Status::Starting { task } => {
395            let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() else {
396                return;
397            };
398
399            workspace.update(cx, |workspace, cx| {
400                workspace.show_toast(
401                    Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
402                    cx,
403                )
404            });
405            let workspace = workspace.downgrade();
406            cx.spawn(|mut cx| async move {
407                task.await;
408                if let Some(copilot) = cx.read(Copilot::global) {
409                    workspace
410                        .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
411                            Status::Authorized => workspace.show_toast(
412                                Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
413                                cx,
414                            ),
415                            _ => {
416                                workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
417                                copilot
418                                    .update(cx, |copilot, cx| copilot.sign_in(cx))
419                                    .detach_and_log_err(cx);
420                            }
421                        })
422                        .log_err();
423                }
424            })
425            .detach();
426        }
427        _ => {
428            copilot
429                .update(cx, |copilot, cx| copilot.sign_in(cx))
430                .detach_and_log_err(cx);
431        }
432    }
433}