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