copilot_button.rs

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