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        if let Some(copilot) = Copilot::global(cx) {
123            cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
124        }
125
126        cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
127            .detach();
128
129        Self {
130            editor_subscription: None,
131            editor_enabled: None,
132            language: None,
133            file: None,
134            fs,
135        }
136    }
137
138    pub fn build_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
139        let fs = self.fs.clone();
140        ContextMenu::build(cx, |menu, _| {
141            menu.entry("Sign In", None, initiate_sign_in).entry(
142                "Disable Copilot",
143                None,
144                move |cx| hide_copilot(fs.clone(), cx),
145            )
146        })
147    }
148
149    pub fn build_copilot_menu(&mut self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
150        let fs = self.fs.clone();
151
152        ContextMenu::build(cx, move |mut menu, cx| {
153            if let Some(language) = self.language.clone() {
154                let fs = fs.clone();
155                let language_enabled =
156                    language_settings::language_settings(Some(&language), None, cx)
157                        .show_copilot_suggestions;
158
159                menu = menu.entry(
160                    format!(
161                        "{} Suggestions for {}",
162                        if language_enabled { "Hide" } else { "Show" },
163                        language.name()
164                    ),
165                    None,
166                    move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
167                );
168            }
169
170            let settings = AllLanguageSettings::get_global(cx);
171
172            if let Some(file) = &self.file {
173                let path = file.path().clone();
174                let path_enabled = settings.copilot_enabled_for_path(&path);
175
176                menu = menu.entry(
177                    format!(
178                        "{} Suggestions for This Path",
179                        if path_enabled { "Hide" } else { "Show" }
180                    ),
181                    None,
182                    move |cx| {
183                        if let Some(workspace) = cx.window_handle().downcast::<Workspace>() {
184                            if let Ok(workspace) = workspace.root_view(cx) {
185                                let workspace = workspace.downgrade();
186                                cx.spawn(|cx| {
187                                    configure_disabled_globs(
188                                        workspace,
189                                        path_enabled.then_some(path.clone()),
190                                        cx,
191                                    )
192                                })
193                                .detach_and_log_err(cx);
194                            }
195                        }
196                    },
197                );
198            }
199
200            let globally_enabled = settings.copilot_enabled(None, None);
201            menu.entry(
202                if globally_enabled {
203                    "Hide Suggestions for All Files"
204                } else {
205                    "Show Suggestions for All Files"
206                },
207                None,
208                move |cx| toggle_copilot_globally(fs.clone(), cx),
209            )
210            .separator()
211            .link(
212                "Copilot Settings",
213                OpenBrowser {
214                    url: COPILOT_SETTINGS_URL.to_string(),
215                }
216                .boxed_clone(),
217            )
218            .action("Sign Out", SignOut.boxed_clone())
219        })
220    }
221
222    pub fn update_enabled(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
223        let editor = editor.read(cx);
224        let snapshot = editor.buffer().read(cx).snapshot(cx);
225        let suggestion_anchor = editor.selections.newest_anchor().start;
226        let language = snapshot.language_at(suggestion_anchor);
227        let file = snapshot.file_at(suggestion_anchor).cloned();
228        self.editor_enabled = {
229            let file = file.as_ref();
230            Some(
231                file.map(|file| !file.is_private()).unwrap_or(true)
232                    && all_language_settings(file, cx)
233                        .copilot_enabled(language, file.map(|file| file.path().as_ref())),
234            )
235        };
236        self.language = language.cloned();
237        self.file = file;
238
239        cx.notify()
240    }
241}
242
243impl StatusItemView for CopilotButton {
244    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
245        if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
246            self.editor_subscription = Some((
247                cx.observe(&editor, Self::update_enabled),
248                editor.entity_id().as_u64() as usize,
249            ));
250            self.update_enabled(editor, cx);
251        } else {
252            self.language = None;
253            self.editor_subscription = None;
254            self.editor_enabled = None;
255        }
256        cx.notify();
257    }
258}
259
260async fn configure_disabled_globs(
261    workspace: WeakView<Workspace>,
262    path_to_disable: Option<Arc<Path>>,
263    mut cx: AsyncWindowContext,
264) -> Result<()> {
265    let settings_editor = workspace
266        .update(&mut cx, |_, cx| {
267            create_and_open_local_file(&paths::SETTINGS, cx, || {
268                settings::initial_user_settings_content().as_ref().into()
269            })
270        })?
271        .await?
272        .downcast::<Editor>()
273        .unwrap();
274
275    settings_editor.downgrade().update(&mut cx, |item, cx| {
276        let text = item.buffer().read(cx).snapshot(cx).text();
277
278        let settings = cx.global::<SettingsStore>();
279        let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
280            let copilot = file.copilot.get_or_insert_with(Default::default);
281            let globs = copilot.disabled_globs.get_or_insert_with(|| {
282                settings
283                    .get::<AllLanguageSettings>(None)
284                    .copilot
285                    .disabled_globs
286                    .iter()
287                    .map(|glob| glob.glob().to_string())
288                    .collect()
289            });
290
291            if let Some(path_to_disable) = &path_to_disable {
292                globs.push(path_to_disable.to_string_lossy().into_owned());
293            } else {
294                globs.clear();
295            }
296        });
297
298        if !edits.is_empty() {
299            item.change_selections(Some(Autoscroll::newest()), cx, |selections| {
300                selections.select_ranges(edits.iter().map(|e| e.0.clone()));
301            });
302
303            // When *enabling* a path, don't actually perform an edit, just select the range.
304            if path_to_disable.is_some() {
305                item.edit(edits.iter().cloned(), cx);
306            }
307        }
308    })?;
309
310    anyhow::Ok(())
311}
312
313fn toggle_copilot_globally(fs: Arc<dyn Fs>, cx: &mut AppContext) {
314    let show_copilot_suggestions = all_language_settings(None, cx).copilot_enabled(None, None);
315    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
316        file.defaults.show_copilot_suggestions = Some(!show_copilot_suggestions)
317    });
318}
319
320fn toggle_copilot_for_language(language: Arc<Language>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
321    let show_copilot_suggestions =
322        all_language_settings(None, cx).copilot_enabled(Some(&language), None);
323    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
324        file.languages
325            .entry(language.name())
326            .or_default()
327            .show_copilot_suggestions = Some(!show_copilot_suggestions);
328    });
329}
330
331fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
332    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
333        file.features.get_or_insert(Default::default()).copilot = Some(false);
334    });
335}
336
337pub fn initiate_sign_in(cx: &mut WindowContext) {
338    let Some(copilot) = Copilot::global(cx) else {
339        return;
340    };
341    let status = copilot.read(cx).status();
342    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
343        return;
344    };
345    match status {
346        Status::Starting { task } => {
347            let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
348                return;
349            };
350
351            let Ok(workspace) = workspace.update(cx, |workspace, cx| {
352                workspace.show_toast(
353                    Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
354                    cx,
355                );
356                workspace.weak_handle()
357            }) else {
358                return;
359            };
360
361            cx.spawn(|mut cx| async move {
362                task.await;
363                if let Some(copilot) = cx.update(|cx| Copilot::global(cx)).ok().flatten() {
364                    workspace
365                        .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
366                            Status::Authorized => workspace.show_toast(
367                                Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
368                                cx,
369                            ),
370                            _ => {
371                                workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
372                                copilot
373                                    .update(cx, |copilot, cx| copilot.sign_in(cx))
374                                    .detach_and_log_err(cx);
375                            }
376                        })
377                        .log_err();
378                }
379            })
380            .detach();
381        }
382        _ => {
383            copilot.update(cx, |this, cx| this.sign_in(cx)).detach();
384            workspace
385                .update(cx, |this, cx| {
386                    this.toggle_modal(cx, |cx| CopilotCodeVerification::new(&copilot, cx));
387                })
388                .ok();
389        }
390    }
391}