inline_completion_button.rs

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