inline_completion_button.rs

  1use anyhow::Result;
  2use copilot::{Copilot, Status};
  3use editor::{scroll::Autoscroll, Editor};
  4use feature_flags::FeatureFlagAppExt;
  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::{
 12        self, all_language_settings, AllLanguageSettings, InlineCompletionProvider,
 13    },
 14    File, Language,
 15};
 16use settings::{update_settings_file, Settings, SettingsStore};
 17use std::{path::Path, sync::Arc};
 18use supermaven::{AccountStatus, Supermaven};
 19use ui::{Button, LabelSize};
 20use workspace::{
 21    create_and_open_local_file,
 22    item::ItemHandle,
 23    notifications::NotificationId,
 24    ui::{
 25        ButtonCommon, Clickable, ContextMenu, IconButton, IconName, IconSize, PopoverMenu, Tooltip,
 26    },
 27    StatusItemView, Toast, Workspace,
 28};
 29use zed_actions::OpenBrowser;
 30use zeta::RateCompletionModal;
 31
 32const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
 33
 34struct CopilotErrorToast;
 35
 36pub struct InlineCompletionButton {
 37    editor_subscription: Option<(Subscription, usize)>,
 38    editor_enabled: Option<bool>,
 39    language: Option<Arc<Language>>,
 40    file: Option<Arc<dyn File>>,
 41    fs: Arc<dyn Fs>,
 42    workspace: WeakView<Workspace>,
 43}
 44
 45enum SupermavenButtonStatus {
 46    Ready,
 47    Errored(String),
 48    NeedsActivation(String),
 49    Initializing,
 50}
 51
 52impl Render for InlineCompletionButton {
 53    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 54        let all_language_settings = all_language_settings(None, cx);
 55
 56        match all_language_settings.inline_completions.provider {
 57            InlineCompletionProvider::None => div(),
 58
 59            InlineCompletionProvider::Copilot => {
 60                let Some(copilot) = Copilot::global(cx) else {
 61                    return div();
 62                };
 63                let status = copilot.read(cx).status();
 64
 65                let enabled = self.editor_enabled.unwrap_or_else(|| {
 66                    all_language_settings.inline_completions_enabled(None, None, cx)
 67                });
 68
 69                let icon = match status {
 70                    Status::Error(_) => IconName::CopilotError,
 71                    Status::Authorized => {
 72                        if enabled {
 73                            IconName::Copilot
 74                        } else {
 75                            IconName::CopilotDisabled
 76                        }
 77                    }
 78                    _ => IconName::CopilotInit,
 79                };
 80
 81                if let Status::Error(e) = status {
 82                    return div().child(
 83                        IconButton::new("copilot-error", icon)
 84                            .icon_size(IconSize::Small)
 85                            .on_click(cx.listener(move |_, _, cx| {
 86                                if let Some(workspace) = cx.window_handle().downcast::<Workspace>()
 87                                {
 88                                    workspace
 89                                        .update(cx, |workspace, cx| {
 90                                            workspace.show_toast(
 91                                                Toast::new(
 92                                                    NotificationId::unique::<CopilotErrorToast>(),
 93                                                    format!("Copilot can't be started: {}", e),
 94                                                )
 95                                                .on_click("Reinstall Copilot", |cx| {
 96                                                    if let Some(copilot) = Copilot::global(cx) {
 97                                                        copilot
 98                                                            .update(cx, |copilot, cx| {
 99                                                                copilot.reinstall(cx)
100                                                            })
101                                                            .detach();
102                                                    }
103                                                }),
104                                                cx,
105                                            );
106                                        })
107                                        .ok();
108                                }
109                            }))
110                            .tooltip(|cx| Tooltip::text("GitHub Copilot", cx)),
111                    );
112                }
113                let this = cx.view().clone();
114
115                div().child(
116                    PopoverMenu::new("copilot")
117                        .menu(move |cx| {
118                            Some(match status {
119                                Status::Authorized => {
120                                    this.update(cx, |this, cx| this.build_copilot_context_menu(cx))
121                                }
122                                _ => this.update(cx, |this, cx| this.build_copilot_start_menu(cx)),
123                            })
124                        })
125                        .anchor(AnchorCorner::BottomRight)
126                        .trigger(
127                            IconButton::new("copilot-icon", icon)
128                                .tooltip(|cx| Tooltip::text("GitHub Copilot", cx)),
129                        ),
130                )
131            }
132
133            InlineCompletionProvider::Supermaven => {
134                let Some(supermaven) = Supermaven::global(cx) else {
135                    return div();
136                };
137
138                let supermaven = supermaven.read(cx);
139
140                let status = match supermaven {
141                    Supermaven::Starting => SupermavenButtonStatus::Initializing,
142                    Supermaven::FailedDownload { error } => {
143                        SupermavenButtonStatus::Errored(error.to_string())
144                    }
145                    Supermaven::Spawned(agent) => {
146                        let account_status = agent.account_status.clone();
147                        match account_status {
148                            AccountStatus::NeedsActivation { activate_url } => {
149                                SupermavenButtonStatus::NeedsActivation(activate_url.clone())
150                            }
151                            AccountStatus::Unknown => SupermavenButtonStatus::Initializing,
152                            AccountStatus::Ready => SupermavenButtonStatus::Ready,
153                        }
154                    }
155                    Supermaven::Error { error } => {
156                        SupermavenButtonStatus::Errored(error.to_string())
157                    }
158                };
159
160                let icon = status.to_icon();
161                let tooltip_text = status.to_tooltip();
162                let this = cx.view().clone();
163                let fs = self.fs.clone();
164
165                return div().child(
166                    PopoverMenu::new("supermaven")
167                        .menu(move |cx| match &status {
168                            SupermavenButtonStatus::NeedsActivation(activate_url) => {
169                                Some(ContextMenu::build(cx, |menu, _| {
170                                    let fs = fs.clone();
171                                    let activate_url = activate_url.clone();
172                                    menu.entry("Sign In", None, move |cx| {
173                                        cx.open_url(activate_url.as_str())
174                                    })
175                                    .entry(
176                                        "Use Copilot",
177                                        None,
178                                        move |cx| {
179                                            set_completion_provider(
180                                                fs.clone(),
181                                                cx,
182                                                InlineCompletionProvider::Copilot,
183                                            )
184                                        },
185                                    )
186                                }))
187                            }
188                            SupermavenButtonStatus::Ready => Some(
189                                this.update(cx, |this, cx| this.build_supermaven_context_menu(cx)),
190                            ),
191                            _ => None,
192                        })
193                        .anchor(AnchorCorner::BottomRight)
194                        .trigger(
195                            IconButton::new("supermaven-icon", icon)
196                                .tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx)),
197                        ),
198                );
199            }
200
201            InlineCompletionProvider::Zeta => {
202                if !cx.is_staff() {
203                    return div();
204                }
205
206                div().child(
207                    Button::new("zeta", "Zeta")
208                        .label_size(LabelSize::Small)
209                        .on_click(cx.listener(|this, _, cx| {
210                            if let Some(workspace) = this.workspace.upgrade() {
211                                workspace.update(cx, |workspace, cx| {
212                                    RateCompletionModal::toggle(workspace, cx)
213                                });
214                            }
215                        }))
216                        .tooltip(|cx| Tooltip::text("Rate Completions", cx)),
217                )
218            }
219        }
220    }
221}
222
223impl InlineCompletionButton {
224    pub fn new(
225        workspace: WeakView<Workspace>,
226        fs: Arc<dyn Fs>,
227        cx: &mut ViewContext<Self>,
228    ) -> Self {
229        if let Some(copilot) = Copilot::global(cx) {
230            cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
231        }
232
233        cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
234            .detach();
235
236        Self {
237            editor_subscription: None,
238            editor_enabled: None,
239            language: None,
240            file: None,
241            workspace,
242            fs,
243        }
244    }
245
246    pub fn build_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
247        let fs = self.fs.clone();
248        ContextMenu::build(cx, |menu, _| {
249            menu.entry("Sign In", None, copilot::initiate_sign_in)
250                .entry("Disable Copilot", None, {
251                    let fs = fs.clone();
252                    move |cx| hide_copilot(fs.clone(), cx)
253                })
254                .entry("Use Supermaven", None, {
255                    let fs = fs.clone();
256                    move |cx| {
257                        set_completion_provider(
258                            fs.clone(),
259                            cx,
260                            InlineCompletionProvider::Supermaven,
261                        )
262                    }
263                })
264        })
265    }
266
267    pub fn build_language_settings_menu(
268        &self,
269        mut menu: ContextMenu,
270        cx: &mut WindowContext,
271    ) -> ContextMenu {
272        let fs = self.fs.clone();
273
274        if let Some(language) = self.language.clone() {
275            let fs = fs.clone();
276            let language_enabled =
277                language_settings::language_settings(Some(language.name()), None, cx)
278                    .show_inline_completions;
279
280            menu = menu.entry(
281                format!(
282                    "{} Inline Completions for {}",
283                    if language_enabled { "Hide" } else { "Show" },
284                    language.name()
285                ),
286                None,
287                move |cx| toggle_inline_completions_for_language(language.clone(), fs.clone(), cx),
288            );
289        }
290
291        let settings = AllLanguageSettings::get_global(cx);
292
293        if let Some(file) = &self.file {
294            let path = file.path().clone();
295            let path_enabled = settings.inline_completions_enabled_for_path(&path);
296
297            menu = menu.entry(
298                format!(
299                    "{} Inline Completions for This Path",
300                    if path_enabled { "Hide" } else { "Show" }
301                ),
302                None,
303                move |cx| {
304                    if let Some(workspace) = cx.window_handle().downcast::<Workspace>() {
305                        if let Ok(workspace) = workspace.root_view(cx) {
306                            let workspace = workspace.downgrade();
307                            cx.spawn(|cx| {
308                                configure_disabled_globs(
309                                    workspace,
310                                    path_enabled.then_some(path.clone()),
311                                    cx,
312                                )
313                            })
314                            .detach_and_log_err(cx);
315                        }
316                    }
317                },
318            );
319        }
320
321        let globally_enabled = settings.inline_completions_enabled(None, None, cx);
322        menu.entry(
323            if globally_enabled {
324                "Hide Inline Completions for All Files"
325            } else {
326                "Show Inline Completions for All Files"
327            },
328            None,
329            move |cx| toggle_inline_completions_globally(fs.clone(), cx),
330        )
331    }
332
333    fn build_copilot_context_menu(&self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
334        ContextMenu::build(cx, |menu, cx| {
335            self.build_language_settings_menu(menu, cx)
336                .separator()
337                .link(
338                    "Go to Copilot Settings",
339                    OpenBrowser {
340                        url: COPILOT_SETTINGS_URL.to_string(),
341                    }
342                    .boxed_clone(),
343                )
344                .action("Sign Out", copilot::SignOut.boxed_clone())
345        })
346    }
347
348    fn build_supermaven_context_menu(&self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
349        ContextMenu::build(cx, |menu, cx| {
350            self.build_language_settings_menu(menu, cx)
351                .separator()
352                .action("Sign Out", supermaven::SignOut.boxed_clone())
353        })
354    }
355
356    pub fn update_enabled(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
357        let editor = editor.read(cx);
358        let snapshot = editor.buffer().read(cx).snapshot(cx);
359        let suggestion_anchor = editor.selections.newest_anchor().start;
360        let language = snapshot.language_at(suggestion_anchor);
361        let file = snapshot.file_at(suggestion_anchor).cloned();
362        self.editor_enabled = {
363            let file = file.as_ref();
364            Some(
365                file.map(|file| !file.is_private()).unwrap_or(true)
366                    && all_language_settings(file, cx).inline_completions_enabled(
367                        language,
368                        file.map(|file| file.path().as_ref()),
369                        cx,
370                    ),
371            )
372        };
373        self.language = language.cloned();
374        self.file = file;
375
376        cx.notify()
377    }
378}
379
380impl StatusItemView for InlineCompletionButton {
381    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
382        if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
383            self.editor_subscription = Some((
384                cx.observe(&editor, Self::update_enabled),
385                editor.entity_id().as_u64() as usize,
386            ));
387            self.update_enabled(editor, cx);
388        } else {
389            self.language = None;
390            self.editor_subscription = None;
391            self.editor_enabled = None;
392        }
393        cx.notify();
394    }
395}
396
397impl SupermavenButtonStatus {
398    fn to_icon(&self) -> IconName {
399        match self {
400            SupermavenButtonStatus::Ready => IconName::Supermaven,
401            SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
402            SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
403            SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
404        }
405    }
406
407    fn to_tooltip(&self) -> String {
408        match self {
409            SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
410            SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
411            SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
412            SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
413        }
414    }
415}
416
417async fn configure_disabled_globs(
418    workspace: WeakView<Workspace>,
419    path_to_disable: Option<Arc<Path>>,
420    mut cx: AsyncWindowContext,
421) -> Result<()> {
422    let settings_editor = workspace
423        .update(&mut cx, |_, cx| {
424            create_and_open_local_file(paths::settings_file(), cx, || {
425                settings::initial_user_settings_content().as_ref().into()
426            })
427        })?
428        .await?
429        .downcast::<Editor>()
430        .unwrap();
431
432    settings_editor.downgrade().update(&mut cx, |item, cx| {
433        let text = item.buffer().read(cx).snapshot(cx).text();
434
435        let settings = cx.global::<SettingsStore>();
436        let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
437            let copilot = file.inline_completions.get_or_insert_with(Default::default);
438            let globs = copilot.disabled_globs.get_or_insert_with(|| {
439                settings
440                    .get::<AllLanguageSettings>(None)
441                    .inline_completions
442                    .disabled_globs
443                    .iter()
444                    .map(|glob| glob.glob().to_string())
445                    .collect()
446            });
447
448            if let Some(path_to_disable) = &path_to_disable {
449                globs.push(path_to_disable.to_string_lossy().into_owned());
450            } else {
451                globs.clear();
452            }
453        });
454
455        if !edits.is_empty() {
456            item.change_selections(Some(Autoscroll::newest()), cx, |selections| {
457                selections.select_ranges(edits.iter().map(|e| e.0.clone()));
458            });
459
460            // When *enabling* a path, don't actually perform an edit, just select the range.
461            if path_to_disable.is_some() {
462                item.edit(edits.iter().cloned(), cx);
463            }
464        }
465    })?;
466
467    anyhow::Ok(())
468}
469
470fn toggle_inline_completions_globally(fs: Arc<dyn Fs>, cx: &mut AppContext) {
471    let show_inline_completions =
472        all_language_settings(None, cx).inline_completions_enabled(None, None, cx);
473    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
474        file.defaults.show_inline_completions = Some(!show_inline_completions)
475    });
476}
477
478fn set_completion_provider(
479    fs: Arc<dyn Fs>,
480    cx: &mut AppContext,
481    provider: InlineCompletionProvider,
482) {
483    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
484        file.features
485            .get_or_insert(Default::default())
486            .inline_completion_provider = Some(provider);
487    });
488}
489
490fn toggle_inline_completions_for_language(
491    language: Arc<Language>,
492    fs: Arc<dyn Fs>,
493    cx: &mut AppContext,
494) {
495    let show_inline_completions =
496        all_language_settings(None, cx).inline_completions_enabled(Some(&language), None, cx);
497    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
498        file.languages
499            .entry(language.name())
500            .or_default()
501            .show_inline_completions = Some(!show_inline_completions);
502    });
503}
504
505fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
506    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
507        file.features
508            .get_or_insert(Default::default())
509            .inline_completion_provider = Some(InlineCompletionProvider::None);
510    });
511}