copilot_button.rs

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