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::<Self, _>::new(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                            .style_for(state, active);
 75
 76                        Flex::row()
 77                            .with_child(
 78                                Svg::new({
 79                                    match status {
 80                                        Status::Error(_) => "icons/copilot_error_16.svg",
 81                                        Status::Authorized => {
 82                                            if enabled {
 83                                                "icons/copilot_16.svg"
 84                                            } else {
 85                                                "icons/copilot_disabled_16.svg"
 86                                            }
 87                                        }
 88                                        _ => "icons/copilot_init_16.svg",
 89                                    }
 90                                })
 91                                .with_color(style.icon_color)
 92                                .constrained()
 93                                .with_width(style.icon_size)
 94                                .aligned()
 95                                .into_any_named("copilot-icon"),
 96                            )
 97                            .constrained()
 98                            .with_height(style.icon_size)
 99                            .contained()
100                            .with_style(style.container)
101                    }
102                })
103                .with_cursor_style(CursorStyle::PointingHand)
104                .on_click(MouseButton::Left, {
105                    let status = status.clone();
106                    move |_, this, cx| match status {
107                        Status::Authorized => this.deploy_copilot_menu(cx),
108                        Status::Error(ref e) => {
109                            if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>()
110                            {
111                                workspace.update(cx, |workspace, cx| {
112                                    workspace.show_toast(
113                                        Toast::new(
114                                            COPILOT_ERROR_TOAST_ID,
115                                            format!("Copilot can't be started: {}", e),
116                                        )
117                                        .on_click(
118                                            "Reinstall Copilot",
119                                            |cx| {
120                                                if let Some(copilot) = Copilot::global(cx) {
121                                                    copilot
122                                                        .update(cx, |copilot, cx| {
123                                                            copilot.reinstall(cx)
124                                                        })
125                                                        .detach();
126                                                }
127                                            },
128                                        ),
129                                        cx,
130                                    );
131                                });
132                            }
133                        }
134                        _ => this.deploy_copilot_start_menu(cx),
135                    }
136                })
137                .with_tooltip::<Self>(
138                    0,
139                    "GitHub Copilot".into(),
140                    None,
141                    theme.tooltip.clone(),
142                    cx,
143                ),
144            )
145            .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
146            .into_any()
147    }
148}
149
150impl CopilotButton {
151    pub fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<Self>) -> Self {
152        let button_view_id = cx.view_id();
153        let menu = cx.add_view(|cx| {
154            let mut menu = ContextMenu::new(button_view_id, cx);
155            menu.set_position_mode(OverlayPositionMode::Local);
156            menu
157        });
158
159        cx.observe(&menu, |_, _, cx| cx.notify()).detach();
160
161        Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
162
163        cx.observe_global::<SettingsStore, _>(move |_, cx| cx.notify())
164            .detach();
165
166        Self {
167            popup_menu: menu,
168            editor_subscription: None,
169            editor_enabled: None,
170            language: None,
171            file: None,
172            fs,
173        }
174    }
175
176    pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
177        let mut menu_options = Vec::with_capacity(2);
178        let fs = self.fs.clone();
179
180        menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
181            initiate_sign_in(cx)
182        }));
183        menu_options.push(ContextMenuItem::handler("Disable Copilot", move |cx| {
184            hide_copilot(fs.clone(), cx)
185        }));
186
187        self.popup_menu.update(cx, |menu, cx| {
188            menu.show(
189                Default::default(),
190                AnchorCorner::BottomRight,
191                menu_options,
192                cx,
193            );
194        });
195    }
196
197    pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
198        let fs = self.fs.clone();
199        let mut menu_options = Vec::with_capacity(8);
200
201        if let Some(language) = self.language.clone() {
202            let fs = fs.clone();
203            let language_enabled = language_settings::language_settings(Some(&language), None, cx)
204                .show_copilot_suggestions;
205            menu_options.push(ContextMenuItem::handler(
206                format!(
207                    "{} Suggestions for {}",
208                    if language_enabled { "Hide" } else { "Show" },
209                    language.name()
210                ),
211                move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
212            ));
213        }
214
215        let settings = settings::get::<AllLanguageSettings>(cx);
216
217        if let Some(file) = &self.file {
218            let path = file.path().clone();
219            let path_enabled = settings.copilot_enabled_for_path(&path);
220            menu_options.push(ContextMenuItem::handler(
221                format!(
222                    "{} Suggestions for This Path",
223                    if path_enabled { "Hide" } else { "Show" }
224                ),
225                move |cx| {
226                    if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() {
227                        let workspace = workspace.downgrade();
228                        cx.spawn(|_, cx| {
229                            configure_disabled_globs(
230                                workspace,
231                                path_enabled.then_some(path.clone()),
232                                cx,
233                            )
234                        })
235                        .detach_and_log_err(cx);
236                    }
237                },
238            ));
239        }
240
241        let globally_enabled = settings.copilot_enabled(None, None);
242        menu_options.push(ContextMenuItem::handler(
243            if globally_enabled {
244                "Hide Suggestions for All Files"
245            } else {
246                "Show Suggestions for All Files"
247            },
248            move |cx| toggle_copilot_globally(fs.clone(), cx),
249        ));
250
251        menu_options.push(ContextMenuItem::Separator);
252
253        let icon_style = theme::current(cx).copilot.out_link_icon.clone();
254        menu_options.push(ContextMenuItem::action(
255            move |state: &mut MouseState, style: &theme::ContextMenuItem| {
256                Flex::row()
257                    .with_child(Label::new("Copilot Settings", style.label.clone()))
258                    .with_child(theme::ui::icon(icon_style.style_for(state, false)))
259                    .align_children_center()
260                    .into_any()
261            },
262            OsOpen::new(COPILOT_SETTINGS_URL),
263        ));
264
265        menu_options.push(ContextMenuItem::action("Sign Out", SignOut));
266
267        self.popup_menu.update(cx, |menu, cx| {
268            menu.show(
269                Default::default(),
270                AnchorCorner::BottomRight,
271                menu_options,
272                cx,
273            );
274        });
275    }
276
277    pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
278        let editor = editor.read(cx);
279        let snapshot = editor.buffer().read(cx).snapshot(cx);
280        let suggestion_anchor = editor.selections.newest_anchor().start;
281        let language = snapshot.language_at(suggestion_anchor);
282        let file = snapshot.file_at(suggestion_anchor).cloned();
283
284        self.editor_enabled = Some(
285            all_language_settings(self.file.as_ref(), cx)
286                .copilot_enabled(language, file.as_ref().map(|file| file.path().as_ref())),
287        );
288        self.language = language.cloned();
289        self.file = file;
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().as_ref().into()
319            })
320        })?
321        .await?
322        .downcast::<Editor>()
323        .unwrap();
324
325    settings_editor.downgrade().update(&mut cx, |item, cx| {
326        let text = item.buffer().read(cx).snapshot(cx).text();
327
328        let settings = cx.global::<SettingsStore>();
329        let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
330            let copilot = file.copilot.get_or_insert_with(Default::default);
331            let globs = copilot.disabled_globs.get_or_insert_with(|| {
332                settings
333                    .get::<AllLanguageSettings>(None)
334                    .copilot
335                    .disabled_globs
336                    .iter()
337                    .map(|glob| glob.glob().to_string())
338                    .collect()
339            });
340
341            if let Some(path_to_disable) = &path_to_disable {
342                globs.push(path_to_disable.to_string_lossy().into_owned());
343            } else {
344                globs.clear();
345            }
346        });
347
348        if !edits.is_empty() {
349            item.change_selections(Some(Autoscroll::newest()), cx, |selections| {
350                selections.select_ranges(edits.iter().map(|e| e.0.clone()));
351            });
352
353            // When *enabling* a path, don't actually perform an edit, just select the range.
354            if path_to_disable.is_some() {
355                item.edit(edits.iter().cloned(), cx);
356            }
357        }
358    })?;
359
360    anyhow::Ok(())
361}
362
363fn toggle_copilot_globally(fs: Arc<dyn Fs>, cx: &mut AppContext) {
364    let show_copilot_suggestions = all_language_settings(None, cx).copilot_enabled(None, None);
365    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
366        file.defaults.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
367    });
368}
369
370fn toggle_copilot_for_language(language: Arc<Language>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
371    let show_copilot_suggestions =
372        all_language_settings(None, cx).copilot_enabled(Some(&language), None);
373    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
374        file.languages
375            .entry(language.name())
376            .or_default()
377            .show_copilot_suggestions = Some(!show_copilot_suggestions);
378    });
379}
380
381fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
382    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
383        file.features.get_or_insert(Default::default()).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}