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