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 settings::{update_settings_file, Settings, SettingsStore};
 13use std::{path::Path, sync::Arc};
 14use util::{paths, ResultExt};
 15use workspace::{
 16    create_and_open_local_file, item::ItemHandle,
 17    notifications::simple_message_notification::OsOpen, StatusItemView, Toast, Workspace,
 18};
 19
 20const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
 21const COPILOT_STARTING_TOAST_ID: usize = 1337;
 22const COPILOT_ERROR_TOAST_ID: usize = 1338;
 23
 24pub struct CopilotButton {
 25    popup_menu: ViewHandle<ContextMenu>,
 26    editor_subscription: Option<(Subscription, usize)>,
 27    editor_enabled: Option<bool>,
 28    language: Option<Arc<str>>,
 29    path: Option<Arc<Path>>,
 30    fs: Arc<dyn Fs>,
 31}
 32
 33impl Entity for CopilotButton {
 34    type Event = ();
 35}
 36
 37impl View for CopilotButton {
 38    fn ui_name() -> &'static str {
 39        "CopilotButton"
 40    }
 41
 42    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
 43        let settings = cx.global::<Settings>();
 44
 45        if !settings.features.copilot {
 46            return Empty::new().into_any();
 47        }
 48
 49        let theme = settings.theme.clone();
 50        let active = self.popup_menu.read(cx).visible();
 51        let Some(copilot) = Copilot::global(cx) else {
 52            return Empty::new().into_any();
 53        };
 54        let status = copilot.read(cx).status();
 55
 56        let enabled = self
 57            .editor_enabled
 58            .unwrap_or(settings.show_copilot_suggestions(None, None));
 59
 60        Stack::new()
 61            .with_child(
 62                MouseEventHandler::<Self, _>::new(0, cx, {
 63                    let theme = theme.clone();
 64                    let status = status.clone();
 65                    move |state, _cx| {
 66                        let style = theme
 67                            .workspace
 68                            .status_bar
 69                            .sidebar_buttons
 70                            .item
 71                            .style_for(state, active);
 72
 73                        Flex::row()
 74                            .with_child(
 75                                Svg::new({
 76                                    match status {
 77                                        Status::Error(_) => "icons/copilot_error_16.svg",
 78                                        Status::Authorized => {
 79                                            if enabled {
 80                                                "icons/copilot_16.svg"
 81                                            } else {
 82                                                "icons/copilot_disabled_16.svg"
 83                                            }
 84                                        }
 85                                        _ => "icons/copilot_init_16.svg",
 86                                    }
 87                                })
 88                                .with_color(style.icon_color)
 89                                .constrained()
 90                                .with_width(style.icon_size)
 91                                .aligned()
 92                                .into_any_named("copilot-icon"),
 93                            )
 94                            .constrained()
 95                            .with_height(style.icon_size)
 96                            .contained()
 97                            .with_style(style.container)
 98                    }
 99                })
100                .with_cursor_style(CursorStyle::PointingHand)
101                .on_click(MouseButton::Left, {
102                    let status = status.clone();
103                    move |_, this, cx| match status {
104                        Status::Authorized => this.deploy_copilot_menu(cx),
105                        Status::Error(ref e) => {
106                            if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>()
107                            {
108                                workspace.update(cx, |workspace, cx| {
109                                    workspace.show_toast(
110                                        Toast::new(
111                                            COPILOT_ERROR_TOAST_ID,
112                                            format!("Copilot can't be started: {}", e),
113                                        )
114                                        .on_click(
115                                            "Reinstall Copilot",
116                                            |cx| {
117                                                if let Some(copilot) = Copilot::global(cx) {
118                                                    copilot
119                                                        .update(cx, |copilot, cx| {
120                                                            copilot.reinstall(cx)
121                                                        })
122                                                        .detach();
123                                                }
124                                            },
125                                        ),
126                                        cx,
127                                    );
128                                });
129                            }
130                        }
131                        _ => this.deploy_copilot_start_menu(cx),
132                    }
133                })
134                .with_tooltip::<Self>(
135                    0,
136                    "GitHub Copilot".into(),
137                    None,
138                    theme.tooltip.clone(),
139                    cx,
140                ),
141            )
142            .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
143            .into_any()
144    }
145}
146
147impl CopilotButton {
148    pub fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<Self>) -> Self {
149        let button_view_id = cx.view_id();
150        let menu = cx.add_view(|cx| {
151            let mut menu = ContextMenu::new(button_view_id, cx);
152            menu.set_position_mode(OverlayPositionMode::Local);
153            menu
154        });
155
156        cx.observe(&menu, |_, _, cx| cx.notify()).detach();
157
158        Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
159
160        cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
161            .detach();
162
163        Self {
164            popup_menu: menu,
165            editor_subscription: None,
166            editor_enabled: None,
167            language: None,
168            path: None,
169            fs,
170        }
171    }
172
173    pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
174        let mut menu_options = Vec::with_capacity(2);
175        let fs = self.fs.clone();
176
177        menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
178            initiate_sign_in(cx)
179        }));
180        menu_options.push(ContextMenuItem::handler("Disable Copilot", move |cx| {
181            hide_copilot(fs.clone(), cx)
182        }));
183
184        self.popup_menu.update(cx, |menu, cx| {
185            menu.show(
186                Default::default(),
187                AnchorCorner::BottomRight,
188                menu_options,
189                cx,
190            );
191        });
192    }
193
194    pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
195        let settings = cx.global::<Settings>();
196        let fs = self.fs.clone();
197
198        let mut menu_options = Vec::with_capacity(8);
199
200        if let Some(language) = self.language.clone() {
201            let fs = fs.clone();
202            let language_enabled = settings.copilot_enabled_for_language(Some(language.as_ref()));
203            menu_options.push(ContextMenuItem::handler(
204                format!(
205                    "{} Suggestions for {}",
206                    if language_enabled { "Hide" } else { "Show" },
207                    language
208                ),
209                move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
210            ));
211        }
212
213        if let Some(path) = self.path.as_ref() {
214            let path_enabled = settings.copilot_enabled_for_path(path);
215            let path = path.clone();
216            menu_options.push(ContextMenuItem::handler(
217                format!(
218                    "{} Suggestions for This Path",
219                    if path_enabled { "Hide" } else { "Show" }
220                ),
221                move |cx| {
222                    if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() {
223                        let workspace = workspace.downgrade();
224                        cx.spawn(|_, cx| {
225                            configure_disabled_globs(
226                                workspace,
227                                path_enabled.then_some(path.clone()),
228                                cx,
229                            )
230                        })
231                        .detach_and_log_err(cx);
232                    }
233                },
234            ));
235        }
236
237        let globally_enabled = cx.global::<Settings>().features.copilot;
238        menu_options.push(ContextMenuItem::handler(
239            if globally_enabled {
240                "Hide Suggestions for All Files"
241            } else {
242                "Show Suggestions for All Files"
243            },
244            move |cx| toggle_copilot_globally(fs.clone(), cx),
245        ));
246
247        menu_options.push(ContextMenuItem::Separator);
248
249        let icon_style = settings.theme.copilot.out_link_icon.clone();
250        menu_options.push(ContextMenuItem::action(
251            move |state: &mut MouseState, style: &theme::ContextMenuItem| {
252                Flex::row()
253                    .with_child(Label::new("Copilot Settings", style.label.clone()))
254                    .with_child(theme::ui::icon(icon_style.style_for(state, false)))
255                    .align_children_center()
256                    .into_any()
257            },
258            OsOpen::new(COPILOT_SETTINGS_URL),
259        ));
260
261        menu_options.push(ContextMenuItem::action("Sign Out", SignOut));
262
263        self.popup_menu.update(cx, |menu, cx| {
264            menu.show(
265                Default::default(),
266                AnchorCorner::BottomRight,
267                menu_options,
268                cx,
269            );
270        });
271    }
272
273    pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
274        let editor = editor.read(cx);
275
276        let snapshot = editor.buffer().read(cx).snapshot(cx);
277        let settings = cx.global::<Settings>();
278        let suggestion_anchor = editor.selections.newest_anchor().start;
279
280        let language_name = snapshot
281            .language_at(suggestion_anchor)
282            .map(|language| language.name());
283        let path = snapshot
284            .file_at(suggestion_anchor)
285            .map(|file| file.path().clone());
286
287        self.editor_enabled =
288            Some(settings.show_copilot_suggestions(language_name.as_deref(), path.as_deref()));
289        self.language = language_name;
290        self.path = path;
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 edits = cx
332            .global::<SettingsStore>()
333            .update::<Settings>(&text, |file| {
334                let copilot = file.copilot.get_or_insert_with(Default::default);
335                let globs = copilot.disabled_globs.get_or_insert_with(|| {
336                    cx.global::<Settings>()
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 = cx.global::<Settings>().show_copilot_suggestions(None, None);
369    update_settings_file::<Settings>(fs, cx, move |file_contents| {
370        file_contents.editor.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 = cx
376        .global::<Settings>()
377        .show_copilot_suggestions(Some(&language), None);
378
379    update_settings_file::<Settings>(fs, cx, move |file_contents| {
380        file_contents.languages.insert(
381            language,
382            settings::EditorSettings {
383                show_copilot_suggestions: Some((!show_copilot_suggestions).into()),
384                ..Default::default()
385            },
386        );
387    });
388}
389
390fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
391    update_settings_file::<Settings>(fs, cx, move |file_contents| {
392        file_contents.features.copilot = Some(false)
393    });
394}
395
396fn initiate_sign_in(cx: &mut WindowContext) {
397    let Some(copilot) = Copilot::global(cx) else {
398        return;
399    };
400    let status = copilot.read(cx).status();
401
402    match status {
403        Status::Starting { task } => {
404            let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() else {
405                return;
406            };
407
408            workspace.update(cx, |workspace, cx| {
409                workspace.show_toast(
410                    Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
411                    cx,
412                )
413            });
414            let workspace = workspace.downgrade();
415            cx.spawn(|mut cx| async move {
416                task.await;
417                if let Some(copilot) = cx.read(Copilot::global) {
418                    workspace
419                        .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
420                            Status::Authorized => workspace.show_toast(
421                                Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
422                                cx,
423                            ),
424                            _ => {
425                                workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
426                                copilot
427                                    .update(cx, |copilot, cx| copilot.sign_in(cx))
428                                    .detach_and_log_err(cx);
429                            }
430                        })
431                        .log_err();
432                }
433            })
434            .detach();
435        }
436        _ => {
437            copilot
438                .update(cx, |copilot, cx| copilot.sign_in(cx))
439                .detach_and_log_err(cx);
440        }
441    }
442}