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,
 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<str>>,
 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 =
204                language_settings::language_settings(Some(language.as_ref()), 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
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, false)))
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_name = snapshot
283            .language_at(suggestion_anchor)
284            .map(|language| language.name());
285        let file = snapshot.file_at(suggestion_anchor).cloned();
286
287        self.editor_enabled = Some(
288            all_language_settings(self.file.as_ref().map(|f| f.as_ref()), cx).copilot_enabled(
289                language_name.as_deref(),
290                file.as_ref().map(|file| file.path().as_ref()),
291            ),
292        );
293        self.language = language_name;
294        self.file = file;
295
296        cx.notify()
297    }
298}
299
300impl StatusItemView for CopilotButton {
301    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
302        if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
303            self.editor_subscription =
304                Some((cx.observe(&editor, Self::update_enabled), editor.id()));
305            self.update_enabled(editor, cx);
306        } else {
307            self.language = None;
308            self.editor_subscription = None;
309            self.editor_enabled = None;
310        }
311        cx.notify();
312    }
313}
314
315async fn configure_disabled_globs(
316    workspace: WeakViewHandle<Workspace>,
317    path_to_disable: Option<Arc<Path>>,
318    mut cx: AsyncAppContext,
319) -> Result<()> {
320    let settings_editor = workspace
321        .update(&mut cx, |_, cx| {
322            create_and_open_local_file(&paths::SETTINGS, cx, || {
323                settings::initial_user_settings_content(&assets::Assets)
324                    .as_ref()
325                    .into()
326            })
327        })?
328        .await?
329        .downcast::<Editor>()
330        .unwrap();
331
332    settings_editor.downgrade().update(&mut cx, |item, cx| {
333        let text = item.buffer().read(cx).snapshot(cx).text();
334
335        let settings = cx.global::<SettingsStore>();
336        let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
337            let copilot = file.copilot.get_or_insert_with(Default::default);
338            let globs = copilot.disabled_globs.get_or_insert_with(|| {
339                settings
340                    .get::<AllLanguageSettings>(None)
341                    .copilot
342                    .disabled_globs
343                    .iter()
344                    .map(|glob| glob.glob().to_string())
345                    .collect()
346            });
347
348            if let Some(path_to_disable) = &path_to_disable {
349                globs.push(path_to_disable.to_string_lossy().into_owned());
350            } else {
351                globs.clear();
352            }
353        });
354
355        if !edits.is_empty() {
356            item.change_selections(Some(Autoscroll::newest()), cx, |selections| {
357                selections.select_ranges(edits.iter().map(|e| e.0.clone()));
358            });
359
360            // When *enabling* a path, don't actually perform an edit, just select the range.
361            if path_to_disable.is_some() {
362                item.edit(edits.iter().cloned(), cx);
363            }
364        }
365    })?;
366
367    anyhow::Ok(())
368}
369
370fn toggle_copilot_globally(fs: Arc<dyn Fs>, cx: &mut AppContext) {
371    let show_copilot_suggestions = all_language_settings(None, cx).copilot_enabled(None, None);
372    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
373        file.defaults.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
374    });
375}
376
377fn toggle_copilot_for_language(language: Arc<str>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
378    let show_copilot_suggestions =
379        all_language_settings(None, cx).copilot_enabled(Some(&language), None);
380    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
381        file.languages
382            .entry(language)
383            .or_default()
384            .show_copilot_suggestions = Some(!show_copilot_suggestions);
385    });
386}
387
388fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
389    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
390        file.features.get_or_insert(Default::default()).copilot = Some(false);
391    });
392}
393
394fn initiate_sign_in(cx: &mut WindowContext) {
395    let Some(copilot) = Copilot::global(cx) else {
396        return;
397    };
398    let status = copilot.read(cx).status();
399
400    match status {
401        Status::Starting { task } => {
402            let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() else {
403                return;
404            };
405
406            workspace.update(cx, |workspace, cx| {
407                workspace.show_toast(
408                    Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
409                    cx,
410                )
411            });
412            let workspace = workspace.downgrade();
413            cx.spawn(|mut cx| async move {
414                task.await;
415                if let Some(copilot) = cx.read(Copilot::global) {
416                    workspace
417                        .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
418                            Status::Authorized => workspace.show_toast(
419                                Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
420                                cx,
421                            ),
422                            _ => {
423                                workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
424                                copilot
425                                    .update(cx, |copilot, cx| copilot.sign_in(cx))
426                                    .detach_and_log_err(cx);
427                            }
428                        })
429                        .log_err();
430                }
431            })
432            .detach();
433        }
434        _ => {
435            copilot
436                .update(cx, |copilot, cx| copilot.sign_in(cx))
437                .detach_and_log_err(cx);
438        }
439    }
440}