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