inline_completion_button.rs

  1use anyhow::Result;
  2use client::UserStore;
  3use copilot::{Copilot, Status};
  4use editor::{actions::ShowInlineCompletion, scroll::Autoscroll, Editor};
  5use feature_flags::{
  6    FeatureFlagAppExt, PredictEditsFeatureFlag, PredictEditsRateCompletionsFeatureFlag,
  7};
  8use fs::Fs;
  9use gpui::{
 10    actions, div, pulsating_between, Action, Animation, AnimationExt, App, AsyncWindowContext,
 11    Corner, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, Subscription,
 12    WeakEntity,
 13};
 14use language::{
 15    language_settings::{
 16        self, all_language_settings, AllLanguageSettings, InlineCompletionProvider,
 17    },
 18    File, Language,
 19};
 20use settings::{update_settings_file, Settings, SettingsStore};
 21use std::{path::Path, sync::Arc, time::Duration};
 22use supermaven::{AccountStatus, Supermaven};
 23use ui::{
 24    prelude::*, Clickable, ContextMenu, ContextMenuEntry, IconButton, IconButtonShape, PopoverMenu,
 25    PopoverMenuHandle, Tooltip,
 26};
 27use workspace::{
 28    create_and_open_local_file, item::ItemHandle, notifications::NotificationId, StatusItemView,
 29    Toast, Workspace,
 30};
 31use zed_actions::OpenBrowser;
 32use zeta::RateCompletionModal;
 33
 34actions!(zeta, [RateCompletions]);
 35actions!(inline_completion, [ToggleMenu]);
 36
 37const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
 38
 39struct CopilotErrorToast;
 40
 41pub struct InlineCompletionButton {
 42    editor_subscription: Option<(Subscription, usize)>,
 43    editor_enabled: Option<bool>,
 44    editor_focus_handle: Option<FocusHandle>,
 45    language: Option<Arc<Language>>,
 46    file: Option<Arc<dyn File>>,
 47    inline_completion_provider: Option<Arc<dyn inline_completion::InlineCompletionProviderHandle>>,
 48    fs: Arc<dyn Fs>,
 49    workspace: WeakEntity<Workspace>,
 50    user_store: Entity<UserStore>,
 51    popover_menu_handle: PopoverMenuHandle<ContextMenu>,
 52}
 53
 54enum SupermavenButtonStatus {
 55    Ready,
 56    Errored(String),
 57    NeedsActivation(String),
 58    Initializing,
 59}
 60
 61impl Render for InlineCompletionButton {
 62    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 63        let all_language_settings = all_language_settings(None, cx);
 64
 65        match all_language_settings.inline_completions.provider {
 66            InlineCompletionProvider::None => div(),
 67
 68            InlineCompletionProvider::Copilot => {
 69                let Some(copilot) = Copilot::global(cx) else {
 70                    return div();
 71                };
 72                let status = copilot.read(cx).status();
 73
 74                let enabled = self.editor_enabled.unwrap_or_else(|| {
 75                    all_language_settings.inline_completions_enabled(None, None, cx)
 76                });
 77
 78                let icon = match status {
 79                    Status::Error(_) => IconName::CopilotError,
 80                    Status::Authorized => {
 81                        if enabled {
 82                            IconName::Copilot
 83                        } else {
 84                            IconName::CopilotDisabled
 85                        }
 86                    }
 87                    _ => IconName::CopilotInit,
 88                };
 89
 90                if let Status::Error(e) = status {
 91                    return div().child(
 92                        IconButton::new("copilot-error", icon)
 93                            .icon_size(IconSize::Small)
 94                            .on_click(cx.listener(move |_, _, window, cx| {
 95                                if let Some(workspace) = window.root::<Workspace>().flatten() {
 96                                    workspace.update(cx, |workspace, cx| {
 97                                        workspace.show_toast(
 98                                            Toast::new(
 99                                                NotificationId::unique::<CopilotErrorToast>(),
100                                                format!("Copilot can't be started: {}", e),
101                                            )
102                                            .on_click(
103                                                "Reinstall Copilot",
104                                                |_, cx| {
105                                                    if let Some(copilot) = Copilot::global(cx) {
106                                                        copilot
107                                                            .update(cx, |copilot, cx| {
108                                                                copilot.reinstall(cx)
109                                                            })
110                                                            .detach();
111                                                    }
112                                                },
113                                            ),
114                                            cx,
115                                        );
116                                    });
117                                }
118                            }))
119                            .tooltip(|window, cx| {
120                                Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx)
121                            }),
122                    );
123                }
124                let this = cx.entity().clone();
125
126                div().child(
127                    PopoverMenu::new("copilot")
128                        .menu(move |window, cx| {
129                            Some(match status {
130                                Status::Authorized => this.update(cx, |this, cx| {
131                                    this.build_copilot_context_menu(window, cx)
132                                }),
133                                _ => this.update(cx, |this, cx| {
134                                    this.build_copilot_start_menu(window, cx)
135                                }),
136                            })
137                        })
138                        .anchor(Corner::BottomRight)
139                        .trigger(IconButton::new("copilot-icon", icon).tooltip(|window, cx| {
140                            Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx)
141                        }))
142                        .with_handle(self.popover_menu_handle.clone()),
143                )
144            }
145
146            InlineCompletionProvider::Supermaven => {
147                let Some(supermaven) = Supermaven::global(cx) else {
148                    return div();
149                };
150
151                let supermaven = supermaven.read(cx);
152
153                let status = match supermaven {
154                    Supermaven::Starting => SupermavenButtonStatus::Initializing,
155                    Supermaven::FailedDownload { error } => {
156                        SupermavenButtonStatus::Errored(error.to_string())
157                    }
158                    Supermaven::Spawned(agent) => {
159                        let account_status = agent.account_status.clone();
160                        match account_status {
161                            AccountStatus::NeedsActivation { activate_url } => {
162                                SupermavenButtonStatus::NeedsActivation(activate_url.clone())
163                            }
164                            AccountStatus::Unknown => SupermavenButtonStatus::Initializing,
165                            AccountStatus::Ready => SupermavenButtonStatus::Ready,
166                        }
167                    }
168                    Supermaven::Error { error } => {
169                        SupermavenButtonStatus::Errored(error.to_string())
170                    }
171                };
172
173                let icon = status.to_icon();
174                let tooltip_text = status.to_tooltip();
175                let has_menu = status.has_menu();
176                let this = cx.entity().clone();
177                let fs = self.fs.clone();
178
179                return div().child(
180                    PopoverMenu::new("supermaven")
181                        .menu(move |window, cx| match &status {
182                            SupermavenButtonStatus::NeedsActivation(activate_url) => {
183                                Some(ContextMenu::build(window, cx, |menu, _, _| {
184                                    let fs = fs.clone();
185                                    let activate_url = activate_url.clone();
186                                    menu.entry("Sign In", None, move |_, cx| {
187                                        cx.open_url(activate_url.as_str())
188                                    })
189                                    .entry(
190                                        "Use Copilot",
191                                        None,
192                                        move |_, cx| {
193                                            set_completion_provider(
194                                                fs.clone(),
195                                                cx,
196                                                InlineCompletionProvider::Copilot,
197                                            )
198                                        },
199                                    )
200                                }))
201                            }
202                            SupermavenButtonStatus::Ready => Some(this.update(cx, |this, cx| {
203                                this.build_supermaven_context_menu(window, cx)
204                            })),
205                            _ => None,
206                        })
207                        .anchor(Corner::BottomRight)
208                        .trigger(IconButton::new("supermaven-icon", icon).tooltip(
209                            move |window, cx| {
210                                if has_menu {
211                                    Tooltip::for_action(
212                                        tooltip_text.clone(),
213                                        &ToggleMenu,
214                                        window,
215                                        cx,
216                                    )
217                                } else {
218                                    Tooltip::text(tooltip_text.clone())(window, cx)
219                                }
220                            },
221                        ))
222                        .with_handle(self.popover_menu_handle.clone()),
223                );
224            }
225
226            InlineCompletionProvider::Zed => {
227                if !cx.has_flag::<PredictEditsFeatureFlag>() {
228                    return div();
229                }
230
231                fn icon_button() -> IconButton {
232                    IconButton::new("zed-predict-pending-button", IconName::ZedPredict)
233                        .shape(IconButtonShape::Square)
234                }
235
236                let current_user_terms_accepted =
237                    self.user_store.read(cx).current_user_has_accepted_terms();
238
239                if !current_user_terms_accepted.unwrap_or(false) {
240                    let signed_in = current_user_terms_accepted.is_some();
241                    let tooltip_meta = if signed_in {
242                        "Read Terms of Service"
243                    } else {
244                        "Sign in to use"
245                    };
246
247                    return div().child(
248                        icon_button()
249                            .tooltip(move |window, cx| {
250                                Tooltip::with_meta(
251                                    "Edit Predictions",
252                                    None,
253                                    tooltip_meta,
254                                    window,
255                                    cx,
256                                )
257                            })
258                            .on_click(cx.listener(move |_, _, window, cx| {
259                                window.dispatch_action(
260                                    zed_actions::OpenZedPredictOnboarding.boxed_clone(),
261                                    cx,
262                                );
263                            })),
264                    );
265                }
266
267                let this = cx.entity().clone();
268
269                if !self.popover_menu_handle.is_deployed() {
270                    icon_button().tooltip(|window, cx| {
271                        Tooltip::for_action("Edit Prediction", &ToggleMenu, window, cx)
272                    });
273                }
274
275                let mut popover_menu = PopoverMenu::new("zeta")
276                    .menu(move |window, cx| {
277                        Some(this.update(cx, |this, cx| this.build_zeta_context_menu(window, cx)))
278                    })
279                    .anchor(Corner::BottomRight)
280                    .with_handle(self.popover_menu_handle.clone());
281
282                let is_refreshing = self
283                    .inline_completion_provider
284                    .as_ref()
285                    .map_or(false, |provider| provider.is_refreshing(cx));
286
287                if is_refreshing {
288                    popover_menu = popover_menu.trigger(
289                        icon_button().with_animation(
290                            "pulsating-label",
291                            Animation::new(Duration::from_secs(2))
292                                .repeat()
293                                .with_easing(pulsating_between(0.2, 1.0)),
294                            |icon_button, delta| icon_button.alpha(delta),
295                        ),
296                    );
297                } else {
298                    popover_menu = popover_menu.trigger(icon_button());
299                }
300
301                div().child(popover_menu.into_any_element())
302            }
303        }
304    }
305}
306
307impl InlineCompletionButton {
308    pub fn new(
309        workspace: WeakEntity<Workspace>,
310        fs: Arc<dyn Fs>,
311        user_store: Entity<UserStore>,
312        popover_menu_handle: PopoverMenuHandle<ContextMenu>,
313        cx: &mut Context<Self>,
314    ) -> Self {
315        if let Some(copilot) = Copilot::global(cx) {
316            cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
317        }
318
319        cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
320            .detach();
321
322        Self {
323            editor_subscription: None,
324            editor_enabled: None,
325            editor_focus_handle: None,
326            language: None,
327            file: None,
328            inline_completion_provider: None,
329            popover_menu_handle,
330            workspace,
331            fs,
332            user_store,
333        }
334    }
335
336    pub fn build_copilot_start_menu(
337        &mut self,
338        window: &mut Window,
339        cx: &mut Context<Self>,
340    ) -> Entity<ContextMenu> {
341        let fs = self.fs.clone();
342        ContextMenu::build(window, cx, |menu, _, _| {
343            menu.entry("Sign In", None, copilot::initiate_sign_in)
344                .entry("Disable Copilot", None, {
345                    let fs = fs.clone();
346                    move |_window, cx| hide_copilot(fs.clone(), cx)
347                })
348                .entry("Use Supermaven", None, {
349                    let fs = fs.clone();
350                    move |_window, cx| {
351                        set_completion_provider(
352                            fs.clone(),
353                            cx,
354                            InlineCompletionProvider::Supermaven,
355                        )
356                    }
357                })
358        })
359    }
360
361    // Predict Edits at Cursor – alt-tab
362    // Automatically Predict:
363    // ✓ PATH
364    // ✓ Rust
365    // ✓ All Files
366    pub fn build_language_settings_menu(&self, mut menu: ContextMenu, cx: &mut App) -> ContextMenu {
367        let fs = self.fs.clone();
368
369        menu = menu.header("Predict Edits For:");
370
371        if let Some(language) = self.language.clone() {
372            let fs = fs.clone();
373            let language_enabled =
374                language_settings::language_settings(Some(language.name()), None, cx)
375                    .show_inline_completions;
376
377            menu = menu.toggleable_entry(
378                language.name(),
379                language_enabled,
380                IconPosition::Start,
381                None,
382                move |_, cx| {
383                    toggle_inline_completions_for_language(language.clone(), fs.clone(), cx)
384                },
385            );
386        }
387
388        let settings = AllLanguageSettings::get_global(cx);
389        if let Some(file) = &self.file {
390            let path = file.path().clone();
391            let path_enabled = settings.inline_completions_enabled_for_path(&path);
392
393            menu = menu.toggleable_entry(
394                "This File",
395                path_enabled,
396                IconPosition::Start,
397                None,
398                move |window, cx| {
399                    if let Some(workspace) = window.root().flatten() {
400                        let workspace = workspace.downgrade();
401                        window
402                            .spawn(cx, |cx| {
403                                configure_disabled_globs(
404                                    workspace,
405                                    path_enabled.then_some(path.clone()),
406                                    cx,
407                                )
408                            })
409                            .detach_and_log_err(cx);
410                    }
411                },
412            );
413        }
414
415        let globally_enabled = settings.inline_completions_enabled(None, None, cx);
416        menu = menu.toggleable_entry(
417            "All Files",
418            globally_enabled,
419            IconPosition::Start,
420            None,
421            move |_, cx| toggle_inline_completions_globally(fs.clone(), cx),
422        );
423
424        if let Some(provider) = &self.inline_completion_provider {
425            let data_collection = provider.data_collection_state(cx);
426
427            if data_collection.is_supported() {
428                let provider = provider.clone();
429                menu = menu
430                    .separator()
431                    .header("Help Improve The Model")
432                    .header("Valid Only For OSS Projects");
433                menu = menu.item(
434                    // TODO: We want to add something later that communicates whether
435                    // the current project is open-source.
436                    ContextMenuEntry::new("Share Training Data")
437                        .toggleable(IconPosition::Start, data_collection.is_enabled())
438                        .handler(move |_, cx| {
439                            provider.toggle_data_collection(cx);
440                        }),
441                );
442            }
443        }
444
445        if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
446            menu = menu
447                .separator()
448                .entry(
449                    "Predict Edit at Cursor",
450                    Some(Box::new(ShowInlineCompletion)),
451                    {
452                        let editor_focus_handle = editor_focus_handle.clone();
453
454                        move |window, cx| {
455                            editor_focus_handle.dispatch_action(&ShowInlineCompletion, window, cx);
456                        }
457                    },
458                )
459                .context(editor_focus_handle);
460        }
461
462        menu
463    }
464
465    fn build_copilot_context_menu(
466        &self,
467        window: &mut Window,
468        cx: &mut Context<Self>,
469    ) -> Entity<ContextMenu> {
470        ContextMenu::build(window, cx, |menu, _, cx| {
471            self.build_language_settings_menu(menu, cx)
472                .separator()
473                .link(
474                    "Go to Copilot Settings",
475                    OpenBrowser {
476                        url: COPILOT_SETTINGS_URL.to_string(),
477                    }
478                    .boxed_clone(),
479                )
480                .action("Sign Out", copilot::SignOut.boxed_clone())
481        })
482    }
483
484    fn build_supermaven_context_menu(
485        &self,
486        window: &mut Window,
487        cx: &mut Context<Self>,
488    ) -> Entity<ContextMenu> {
489        ContextMenu::build(window, cx, |menu, _, cx| {
490            self.build_language_settings_menu(menu, cx)
491                .separator()
492                .action("Sign Out", supermaven::SignOut.boxed_clone())
493        })
494    }
495
496    fn build_zeta_context_menu(
497        &self,
498        window: &mut Window,
499        cx: &mut Context<Self>,
500    ) -> Entity<ContextMenu> {
501        let workspace = self.workspace.clone();
502        ContextMenu::build(window, cx, |menu, _window, cx| {
503            self.build_language_settings_menu(menu, cx).when(
504                cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
505                |this| {
506                    this.entry(
507                        "Rate Completions",
508                        Some(RateCompletions.boxed_clone()),
509                        move |window, cx| {
510                            workspace
511                                .update(cx, |workspace, cx| {
512                                    RateCompletionModal::toggle(workspace, window, cx)
513                                })
514                                .ok();
515                        },
516                    )
517                },
518            )
519        })
520    }
521
522    pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
523        let editor = editor.read(cx);
524        let snapshot = editor.buffer().read(cx).snapshot(cx);
525        let suggestion_anchor = editor.selections.newest_anchor().start;
526        let language = snapshot.language_at(suggestion_anchor);
527        let file = snapshot.file_at(suggestion_anchor).cloned();
528        self.editor_enabled = {
529            let file = file.as_ref();
530            Some(
531                file.map(|file| !file.is_private()).unwrap_or(true)
532                    && all_language_settings(file, cx).inline_completions_enabled(
533                        language,
534                        file.map(|file| file.path().as_ref()),
535                        cx,
536                    ),
537            )
538        };
539        self.inline_completion_provider = editor.inline_completion_provider();
540        self.language = language.cloned();
541        self.file = file;
542        self.editor_focus_handle = Some(editor.focus_handle(cx));
543
544        cx.notify();
545    }
546
547    pub fn toggle_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
548        self.popover_menu_handle.toggle(window, cx);
549    }
550}
551
552impl StatusItemView for InlineCompletionButton {
553    fn set_active_pane_item(
554        &mut self,
555        item: Option<&dyn ItemHandle>,
556        _: &mut Window,
557        cx: &mut Context<Self>,
558    ) {
559        if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
560            self.editor_subscription = Some((
561                cx.observe(&editor, Self::update_enabled),
562                editor.entity_id().as_u64() as usize,
563            ));
564            self.update_enabled(editor, cx);
565        } else {
566            self.language = None;
567            self.editor_subscription = None;
568            self.editor_enabled = None;
569        }
570        cx.notify();
571    }
572}
573
574impl SupermavenButtonStatus {
575    fn to_icon(&self) -> IconName {
576        match self {
577            SupermavenButtonStatus::Ready => IconName::Supermaven,
578            SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
579            SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
580            SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
581        }
582    }
583
584    fn to_tooltip(&self) -> String {
585        match self {
586            SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
587            SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
588            SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
589            SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
590        }
591    }
592
593    fn has_menu(&self) -> bool {
594        match self {
595            SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
596            SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
597        }
598    }
599}
600
601async fn configure_disabled_globs(
602    workspace: WeakEntity<Workspace>,
603    path_to_disable: Option<Arc<Path>>,
604    mut cx: AsyncWindowContext,
605) -> Result<()> {
606    let settings_editor = workspace
607        .update_in(&mut cx, |_, window, cx| {
608            create_and_open_local_file(paths::settings_file(), window, cx, || {
609                settings::initial_user_settings_content().as_ref().into()
610            })
611        })?
612        .await?
613        .downcast::<Editor>()
614        .unwrap();
615
616    settings_editor
617        .downgrade()
618        .update_in(&mut cx, |item, window, cx| {
619            let text = item.buffer().read(cx).snapshot(cx).text();
620
621            let settings = cx.global::<SettingsStore>();
622            let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
623                let copilot = file.inline_completions.get_or_insert_with(Default::default);
624                let globs = copilot.disabled_globs.get_or_insert_with(|| {
625                    settings
626                        .get::<AllLanguageSettings>(None)
627                        .inline_completions
628                        .disabled_globs
629                        .iter()
630                        .map(|glob| glob.glob().to_string())
631                        .collect()
632                });
633
634                if let Some(path_to_disable) = &path_to_disable {
635                    globs.push(path_to_disable.to_string_lossy().into_owned());
636                } else {
637                    globs.clear();
638                }
639            });
640
641            if !edits.is_empty() {
642                item.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
643                    selections.select_ranges(edits.iter().map(|e| e.0.clone()));
644                });
645
646                // When *enabling* a path, don't actually perform an edit, just select the range.
647                if path_to_disable.is_some() {
648                    item.edit(edits.iter().cloned(), cx);
649                }
650            }
651        })?;
652
653    anyhow::Ok(())
654}
655
656fn toggle_inline_completions_globally(fs: Arc<dyn Fs>, cx: &mut App) {
657    let show_inline_completions =
658        all_language_settings(None, cx).inline_completions_enabled(None, None, cx);
659    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
660        file.defaults.show_inline_completions = Some(!show_inline_completions)
661    });
662}
663
664fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: InlineCompletionProvider) {
665    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
666        file.features
667            .get_or_insert(Default::default())
668            .inline_completion_provider = Some(provider);
669    });
670}
671
672fn toggle_inline_completions_for_language(language: Arc<Language>, fs: Arc<dyn Fs>, cx: &mut App) {
673    let show_inline_completions =
674        all_language_settings(None, cx).inline_completions_enabled(Some(&language), None, cx);
675    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
676        file.languages
677            .entry(language.name())
678            .or_default()
679            .show_inline_completions = Some(!show_inline_completions);
680    });
681}
682
683fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
684    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
685        file.features
686            .get_or_insert(Default::default())
687            .inline_completion_provider = Some(InlineCompletionProvider::None);
688    });
689}