copilot_button.rs

  1use crate::sign_in::CopilotCodeVerification;
  2use anyhow::Result;
  3use copilot::{Copilot, SignOut, Status};
  4use editor::{scroll::Autoscroll, Editor};
  5use fs::Fs;
  6use gpui::{
  7    div, Action, AnchorCorner, AppContext, AsyncWindowContext, Entity, IntoElement, ParentElement,
  8    Render, Subscription, View, ViewContext, WeakView, WindowContext,
  9};
 10use language::{
 11    language_settings::{self, all_language_settings, AllLanguageSettings},
 12    File, Language,
 13};
 14use settings::{update_settings_file, Settings, SettingsStore};
 15use std::{path::Path, sync::Arc};
 16use util::{paths, ResultExt};
 17use workspace::{
 18    create_and_open_local_file,
 19    item::ItemHandle,
 20    ui::{
 21        popover_menu, ButtonCommon, Clickable, ContextMenu, IconButton, IconName, IconSize, Tooltip,
 22    },
 23    StatusItemView, Toast, Workspace,
 24};
 25use zed_actions::OpenBrowser;
 26
 27const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
 28const COPILOT_STARTING_TOAST_ID: usize = 1337;
 29const COPILOT_ERROR_TOAST_ID: usize = 1338;
 30
 31pub struct CopilotButton {
 32    editor_subscription: Option<(Subscription, usize)>,
 33    editor_enabled: Option<bool>,
 34    language: Option<Arc<Language>>,
 35    file: Option<Arc<dyn File>>,
 36    fs: Arc<dyn Fs>,
 37}
 38
 39impl Render for CopilotButton {
 40    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 41        let all_language_settings = all_language_settings(None, cx);
 42        if !all_language_settings.copilot.feature_enabled {
 43            return div();
 44        }
 45
 46        let Some(copilot) = Copilot::global(cx) else {
 47            return div();
 48        };
 49        let status = copilot.read(cx).status();
 50
 51        let enabled = self
 52            .editor_enabled
 53            .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None));
 54
 55        let icon = match status {
 56            Status::Error(_) => IconName::CopilotError,
 57            Status::Authorized => {
 58                if enabled {
 59                    IconName::Copilot
 60                } else {
 61                    IconName::CopilotDisabled
 62                }
 63            }
 64            _ => IconName::CopilotInit,
 65        };
 66
 67        if let Status::Error(e) = status {
 68            return div().child(
 69                IconButton::new("copilot-error", icon)
 70                    .icon_size(IconSize::Small)
 71                    .on_click(cx.listener(move |_, _, cx| {
 72                        if let Some(workspace) = cx.window_handle().downcast::<Workspace>() {
 73                            workspace
 74                                .update(cx, |workspace, cx| {
 75                                    workspace.show_toast(
 76                                        Toast::new(
 77                                            COPILOT_ERROR_TOAST_ID,
 78                                            format!("Copilot can't be started: {}", e),
 79                                        )
 80                                        .on_click(
 81                                            "Reinstall Copilot",
 82                                            |cx| {
 83                                                if let Some(copilot) = Copilot::global(cx) {
 84                                                    copilot
 85                                                        .update(cx, |copilot, cx| {
 86                                                            copilot.reinstall(cx)
 87                                                        })
 88                                                        .detach();
 89                                                }
 90                                            },
 91                                        ),
 92                                        cx,
 93                                    );
 94                                })
 95                                .ok();
 96                        }
 97                    }))
 98                    .tooltip(|cx| Tooltip::text("GitHub Copilot", cx)),
 99            );
100        }
101        let this = cx.view().clone();
102
103        div().child(
104            popover_menu("copilot")
105                .menu(move |cx| match status {
106                    Status::Authorized => {
107                        Some(this.update(cx, |this, cx| this.build_copilot_menu(cx)))
108                    }
109                    _ => Some(this.update(cx, |this, cx| this.build_copilot_start_menu(cx))),
110                })
111                .anchor(AnchorCorner::BottomRight)
112                .trigger(
113                    IconButton::new("copilot-icon", icon)
114                        .tooltip(|cx| Tooltip::text("GitHub Copilot", cx)),
115                ),
116        )
117    }
118}
119
120impl CopilotButton {
121    pub fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<Self>) -> Self {
122        Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
123
124        cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
125            .detach();
126
127        Self {
128            editor_subscription: None,
129            editor_enabled: None,
130            language: None,
131            file: None,
132            fs,
133        }
134    }
135
136    pub fn build_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
137        let fs = self.fs.clone();
138        ContextMenu::build(cx, |menu, _| {
139            menu.entry("Sign In", None, initiate_sign_in).entry(
140                "Disable Copilot",
141                None,
142                move |cx| hide_copilot(fs.clone(), cx),
143            )
144        })
145    }
146
147    pub fn build_copilot_menu(&mut self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
148        let fs = self.fs.clone();
149
150        return ContextMenu::build(cx, move |mut menu, cx| {
151            if let Some(language) = self.language.clone() {
152                let fs = fs.clone();
153                let language_enabled =
154                    language_settings::language_settings(Some(&language), None, cx)
155                        .show_copilot_suggestions;
156
157                menu = menu.entry(
158                    format!(
159                        "{} Suggestions for {}",
160                        if language_enabled { "Hide" } else { "Show" },
161                        language.name()
162                    ),
163                    None,
164                    move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
165                );
166            }
167
168            let settings = AllLanguageSettings::get_global(cx);
169
170            if let Some(file) = &self.file {
171                let path = file.path().clone();
172                let path_enabled = settings.copilot_enabled_for_path(&path);
173
174                menu = menu.entry(
175                    format!(
176                        "{} Suggestions for This Path",
177                        if path_enabled { "Hide" } else { "Show" }
178                    ),
179                    None,
180                    move |cx| {
181                        if let Some(workspace) = cx.window_handle().downcast::<Workspace>() {
182                            if let Ok(workspace) = workspace.root_view(cx) {
183                                let workspace = workspace.downgrade();
184                                cx.spawn(|cx| {
185                                    configure_disabled_globs(
186                                        workspace,
187                                        path_enabled.then_some(path.clone()),
188                                        cx,
189                                    )
190                                })
191                                .detach_and_log_err(cx);
192                            }
193                        }
194                    },
195                );
196            }
197
198            let globally_enabled = settings.copilot_enabled(None, None);
199            menu.entry(
200                if globally_enabled {
201                    "Hide Suggestions for All Files"
202                } else {
203                    "Show Suggestions for All Files"
204                },
205                None,
206                move |cx| toggle_copilot_globally(fs.clone(), cx),
207            )
208            .separator()
209            .link(
210                "Copilot Settings",
211                OpenBrowser {
212                    url: COPILOT_SETTINGS_URL.to_string(),
213                }
214                .boxed_clone(),
215            )
216            .action("Sign Out", SignOut.boxed_clone())
217        });
218    }
219
220    pub fn update_enabled(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
221        let editor = editor.read(cx);
222        let snapshot = editor.buffer().read(cx).snapshot(cx);
223        let suggestion_anchor = editor.selections.newest_anchor().start;
224        let language = snapshot.language_at(suggestion_anchor);
225        let file = snapshot.file_at(suggestion_anchor).cloned();
226
227        self.editor_enabled = Some(
228            all_language_settings(self.file.as_ref(), cx)
229                .copilot_enabled(language, file.as_ref().map(|file| file.path().as_ref())),
230        );
231        self.language = language.cloned();
232        self.file = file;
233
234        cx.notify()
235    }
236}
237
238impl StatusItemView for CopilotButton {
239    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
240        if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
241            self.editor_subscription = Some((
242                cx.observe(&editor, Self::update_enabled),
243                editor.entity_id().as_u64() as usize,
244            ));
245            self.update_enabled(editor, cx);
246        } else {
247            self.language = None;
248            self.editor_subscription = None;
249            self.editor_enabled = None;
250        }
251        cx.notify();
252    }
253}
254
255async fn configure_disabled_globs(
256    workspace: WeakView<Workspace>,
257    path_to_disable: Option<Arc<Path>>,
258    mut cx: AsyncWindowContext,
259) -> Result<()> {
260    let settings_editor = workspace
261        .update(&mut cx, |_, cx| {
262            create_and_open_local_file(&paths::SETTINGS, cx, || {
263                settings::initial_user_settings_content().as_ref().into()
264            })
265        })?
266        .await?
267        .downcast::<Editor>()
268        .unwrap();
269
270    settings_editor.downgrade().update(&mut cx, |item, cx| {
271        let text = item.buffer().read(cx).snapshot(cx).text();
272
273        let settings = cx.global::<SettingsStore>();
274        let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
275            let copilot = file.copilot.get_or_insert_with(Default::default);
276            let globs = copilot.disabled_globs.get_or_insert_with(|| {
277                settings
278                    .get::<AllLanguageSettings>(None)
279                    .copilot
280                    .disabled_globs
281                    .iter()
282                    .map(|glob| glob.glob().to_string())
283                    .collect()
284            });
285
286            if let Some(path_to_disable) = &path_to_disable {
287                globs.push(path_to_disable.to_string_lossy().into_owned());
288            } else {
289                globs.clear();
290            }
291        });
292
293        if !edits.is_empty() {
294            item.change_selections(Some(Autoscroll::newest()), cx, |selections| {
295                selections.select_ranges(edits.iter().map(|e| e.0.clone()));
296            });
297
298            // When *enabling* a path, don't actually perform an edit, just select the range.
299            if path_to_disable.is_some() {
300                item.edit(edits.iter().cloned(), cx);
301            }
302        }
303    })?;
304
305    anyhow::Ok(())
306}
307
308fn toggle_copilot_globally(fs: Arc<dyn Fs>, cx: &mut AppContext) {
309    let show_copilot_suggestions = all_language_settings(None, cx).copilot_enabled(None, None);
310    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
311        file.defaults.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
312    });
313}
314
315fn toggle_copilot_for_language(language: Arc<Language>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
316    let show_copilot_suggestions =
317        all_language_settings(None, cx).copilot_enabled(Some(&language), None);
318    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
319        file.languages
320            .entry(language.name())
321            .or_default()
322            .show_copilot_suggestions = Some(!show_copilot_suggestions);
323    });
324}
325
326fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
327    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
328        file.features.get_or_insert(Default::default()).copilot = Some(false);
329    });
330}
331
332fn initiate_sign_in(cx: &mut WindowContext) {
333    let Some(copilot) = Copilot::global(cx) else {
334        return;
335    };
336    let status = copilot.read(cx).status();
337    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
338        return;
339    };
340    match status {
341        Status::Starting { task } => {
342            let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
343                return;
344            };
345
346            let Ok(workspace) = workspace.update(cx, |workspace, cx| {
347                workspace.show_toast(
348                    Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
349                    cx,
350                );
351                workspace.weak_handle()
352            }) else {
353                return;
354            };
355
356            cx.spawn(|mut cx| async move {
357                task.await;
358                if let Some(copilot) = cx.update(|cx| Copilot::global(cx)).ok().flatten() {
359                    workspace
360                        .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
361                            Status::Authorized => workspace.show_toast(
362                                Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
363                                cx,
364                            ),
365                            _ => {
366                                workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
367                                copilot
368                                    .update(cx, |copilot, cx| copilot.sign_in(cx))
369                                    .detach_and_log_err(cx);
370                            }
371                        })
372                        .log_err();
373                }
374            })
375            .detach();
376        }
377        _ => {
378            copilot.update(cx, |this, cx| this.sign_in(cx)).detach();
379            workspace
380                .update(cx, |this, cx| {
381                    this.toggle_modal(cx, |cx| CopilotCodeVerification::new(&copilot, cx));
382                })
383                .ok();
384        }
385    }
386}