inline_completion_button.rs

  1use anyhow::Result;
  2use client::UserStore;
  3use copilot::{Copilot, Status};
  4use editor::{scroll::Autoscroll, Editor};
  5use feature_flags::{FeatureFlagAppExt, PredictEditsFeatureFlag};
  6use fs::Fs;
  7use gpui::{
  8    actions, div, pulsating_between, Action, Animation, AnimationExt, App, AsyncWindowContext,
  9    Corner, Entity, IntoElement, ParentElement, Render, Subscription, WeakEntity,
 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 ui::{prelude::*, ButtonLike, Color, Icon, IconWithIndicator, Indicator, PopoverMenuHandle};
 21use workspace::{
 22    create_and_open_local_file,
 23    item::ItemHandle,
 24    notifications::NotificationId,
 25    ui::{
 26        ButtonCommon, Clickable, ContextMenu, IconButton, IconName, IconSize, PopoverMenu, Tooltip,
 27    },
 28    StatusItemView, Toast, Workspace,
 29};
 30use zed_actions::OpenBrowser;
 31use zed_predict_tos::ZedPredictTos;
 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    language: Option<Arc<Language>>,
 45    file: Option<Arc<dyn File>>,
 46    inline_completion_provider: Option<Arc<dyn inline_completion::InlineCompletionProviderHandle>>,
 47    fs: Arc<dyn Fs>,
 48    workspace: WeakEntity<Workspace>,
 49    user_store: Entity<UserStore>,
 50    popover_menu_handle: PopoverMenuHandle<ContextMenu>,
 51}
 52
 53enum SupermavenButtonStatus {
 54    Ready,
 55    Errored(String),
 56    NeedsActivation(String),
 57    Initializing,
 58}
 59
 60impl Render for InlineCompletionButton {
 61    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 62        let all_language_settings = all_language_settings(None, cx);
 63
 64        match all_language_settings.inline_completions.provider {
 65            InlineCompletionProvider::None => div(),
 66
 67            InlineCompletionProvider::Copilot => {
 68                let Some(copilot) = Copilot::global(cx) else {
 69                    return div();
 70                };
 71                let status = copilot.read(cx).status();
 72
 73                let enabled = self.editor_enabled.unwrap_or_else(|| {
 74                    all_language_settings.inline_completions_enabled(None, None, cx)
 75                });
 76
 77                let icon = match status {
 78                    Status::Error(_) => IconName::CopilotError,
 79                    Status::Authorized => {
 80                        if enabled {
 81                            IconName::Copilot
 82                        } else {
 83                            IconName::CopilotDisabled
 84                        }
 85                    }
 86                    _ => IconName::CopilotInit,
 87                };
 88
 89                if let Status::Error(e) = status {
 90                    return div().child(
 91                        IconButton::new("copilot-error", icon)
 92                            .icon_size(IconSize::Small)
 93                            .on_click(cx.listener(move |_, _, window, cx| {
 94                                if let Some(workspace) =
 95                                    window.window_handle().downcast::<Workspace>()
 96                                {
 97                                    workspace
 98                                        .update(cx, |workspace, _, cx| {
 99                                            workspace.show_toast(
100                                                Toast::new(
101                                                    NotificationId::unique::<CopilotErrorToast>(),
102                                                    format!("Copilot can't be started: {}", e),
103                                                )
104                                                .on_click("Reinstall Copilot", |_, 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                                                cx,
114                                            );
115                                        })
116                                        .ok();
117                                }
118                            }))
119                            .tooltip(|window, cx| {
120                                Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx)
121                            }),
122                    );
123                }
124                let this = cx.model().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.model().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                if !self
232                    .user_store
233                    .read(cx)
234                    .current_user_has_accepted_terms()
235                    .unwrap_or(false)
236                {
237                    let workspace = self.workspace.clone();
238                    let user_store = self.user_store.clone();
239
240                    return div().child(
241                        ButtonLike::new("zeta-pending-tos-icon")
242                            .child(
243                                IconWithIndicator::new(
244                                    Icon::new(IconName::ZedPredict),
245                                    Some(Indicator::dot().color(Color::Error)),
246                                )
247                                .indicator_border_color(Some(
248                                    cx.theme().colors().status_bar_background,
249                                ))
250                                .into_any_element(),
251                            )
252                            .tooltip(|window, cx| {
253                                Tooltip::with_meta(
254                                    "Edit Predictions",
255                                    None,
256                                    "Read Terms of Service",
257                                    window,
258                                    cx,
259                                )
260                            })
261                            .on_click(cx.listener(move |_, _, window, cx| {
262                                let user_store = user_store.clone();
263
264                                if let Some(workspace) = workspace.upgrade() {
265                                    ZedPredictTos::toggle(workspace, user_store, window, cx);
266                                }
267                            })),
268                    );
269                }
270
271                let this = cx.model().clone();
272                let button = IconButton::new("zeta", IconName::ZedPredict).when(
273                    !self.popover_menu_handle.is_deployed(),
274                    |button| {
275                        button.tooltip(|window, cx| {
276                            Tooltip::for_action("Edit Prediction", &ToggleMenu, window, cx)
277                        })
278                    },
279                );
280
281                let is_refreshing = self
282                    .inline_completion_provider
283                    .as_ref()
284                    .map_or(false, |provider| provider.is_refreshing(cx));
285
286                let mut popover_menu = PopoverMenu::new("zeta")
287                    .menu(move |window, cx| {
288                        Some(this.update(cx, |this, cx| this.build_zeta_context_menu(window, cx)))
289                    })
290                    .anchor(Corner::BottomRight)
291                    .with_handle(self.popover_menu_handle.clone());
292
293                if is_refreshing {
294                    popover_menu = popover_menu.trigger(
295                        button.with_animation(
296                            "pulsating-label",
297                            Animation::new(Duration::from_secs(2))
298                                .repeat()
299                                .with_easing(pulsating_between(0.2, 1.0)),
300                            |icon_button, delta| icon_button.alpha(delta),
301                        ),
302                    );
303                } else {
304                    popover_menu = popover_menu.trigger(button);
305                }
306
307                div().child(popover_menu.into_any_element())
308            }
309        }
310    }
311}
312
313impl InlineCompletionButton {
314    pub fn new(
315        workspace: WeakEntity<Workspace>,
316        fs: Arc<dyn Fs>,
317        user_store: Entity<UserStore>,
318        popover_menu_handle: PopoverMenuHandle<ContextMenu>,
319        cx: &mut Context<Self>,
320    ) -> Self {
321        if let Some(copilot) = Copilot::global(cx) {
322            cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
323        }
324
325        cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
326            .detach();
327
328        Self {
329            editor_subscription: None,
330            editor_enabled: None,
331            language: None,
332            file: None,
333            inline_completion_provider: None,
334            popover_menu_handle,
335            workspace,
336            fs,
337            user_store,
338        }
339    }
340
341    pub fn build_copilot_start_menu(
342        &mut self,
343        window: &mut Window,
344        cx: &mut Context<Self>,
345    ) -> Entity<ContextMenu> {
346        let fs = self.fs.clone();
347        ContextMenu::build(window, cx, |menu, _, _| {
348            menu.entry("Sign In", None, copilot::initiate_sign_in)
349                .entry("Disable Copilot", None, {
350                    let fs = fs.clone();
351                    move |_window, cx| hide_copilot(fs.clone(), cx)
352                })
353                .entry("Use Supermaven", None, {
354                    let fs = fs.clone();
355                    move |_window, cx| {
356                        set_completion_provider(
357                            fs.clone(),
358                            cx,
359                            InlineCompletionProvider::Supermaven,
360                        )
361                    }
362                })
363        })
364    }
365
366    pub fn build_language_settings_menu(&self, mut menu: ContextMenu, cx: &mut App) -> ContextMenu {
367        let fs = self.fs.clone();
368
369        if let Some(language) = self.language.clone() {
370            let fs = fs.clone();
371            let language_enabled =
372                language_settings::language_settings(Some(language.name()), None, cx)
373                    .show_inline_completions;
374
375            menu = menu.entry(
376                format!(
377                    "{} Inline Completions for {}",
378                    if language_enabled { "Hide" } else { "Show" },
379                    language.name()
380                ),
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
390        if let Some(file) = &self.file {
391            let path = file.path().clone();
392            let path_enabled = settings.inline_completions_enabled_for_path(&path);
393
394            menu = menu.entry(
395                format!(
396                    "{} Inline Completions for This Path",
397                    if path_enabled { "Hide" } else { "Show" }
398                ),
399                None,
400                move |window, cx| {
401                    if let Some(workspace) = window.window_handle().downcast::<Workspace>() {
402                        if let Ok(workspace) = workspace.root_model(cx) {
403                            let workspace = workspace.downgrade();
404                            window
405                                .spawn(cx, |cx| {
406                                    configure_disabled_globs(
407                                        workspace,
408                                        path_enabled.then_some(path.clone()),
409                                        cx,
410                                    )
411                                })
412                                .detach_and_log_err(cx);
413                        }
414                    }
415                },
416            );
417        }
418
419        let globally_enabled = settings.inline_completions_enabled(None, None, cx);
420        menu.entry(
421            if globally_enabled {
422                "Hide Inline Completions for All Files"
423            } else {
424                "Show Inline Completions for All Files"
425            },
426            None,
427            move |_, cx| toggle_inline_completions_globally(fs.clone(), cx),
428        )
429    }
430
431    fn build_copilot_context_menu(
432        &self,
433        window: &mut Window,
434        cx: &mut Context<Self>,
435    ) -> Entity<ContextMenu> {
436        ContextMenu::build(window, cx, |menu, _, cx| {
437            self.build_language_settings_menu(menu, cx)
438                .separator()
439                .link(
440                    "Go to Copilot Settings",
441                    OpenBrowser {
442                        url: COPILOT_SETTINGS_URL.to_string(),
443                    }
444                    .boxed_clone(),
445                )
446                .action("Sign Out", copilot::SignOut.boxed_clone())
447        })
448    }
449
450    fn build_supermaven_context_menu(
451        &self,
452        window: &mut Window,
453        cx: &mut Context<Self>,
454    ) -> Entity<ContextMenu> {
455        ContextMenu::build(window, cx, |menu, _, cx| {
456            self.build_language_settings_menu(menu, cx)
457                .separator()
458                .action("Sign Out", supermaven::SignOut.boxed_clone())
459        })
460    }
461
462    fn build_zeta_context_menu(
463        &self,
464        window: &mut Window,
465        cx: &mut Context<Self>,
466    ) -> Entity<ContextMenu> {
467        let workspace = self.workspace.clone();
468        ContextMenu::build(window, cx, |menu, _window, cx| {
469            self.build_language_settings_menu(menu, cx)
470                .separator()
471                .entry(
472                    "Rate Completions",
473                    Some(RateCompletions.boxed_clone()),
474                    move |window, cx| {
475                        workspace
476                            .update(cx, |workspace, cx| {
477                                RateCompletionModal::toggle(workspace, window, cx)
478                            })
479                            .ok();
480                    },
481                )
482        })
483    }
484
485    pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
486        let editor = editor.read(cx);
487        let snapshot = editor.buffer().read(cx).snapshot(cx);
488        let suggestion_anchor = editor.selections.newest_anchor().start;
489        let language = snapshot.language_at(suggestion_anchor);
490        let file = snapshot.file_at(suggestion_anchor).cloned();
491        self.editor_enabled = {
492            let file = file.as_ref();
493            Some(
494                file.map(|file| !file.is_private()).unwrap_or(true)
495                    && all_language_settings(file, cx).inline_completions_enabled(
496                        language,
497                        file.map(|file| file.path().as_ref()),
498                        cx,
499                    ),
500            )
501        };
502        self.inline_completion_provider = editor.inline_completion_provider();
503        self.language = language.cloned();
504        self.file = file;
505
506        cx.notify();
507    }
508
509    pub fn toggle_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
510        self.popover_menu_handle.toggle(window, cx);
511    }
512}
513
514impl StatusItemView for InlineCompletionButton {
515    fn set_active_pane_item(
516        &mut self,
517        item: Option<&dyn ItemHandle>,
518        _: &mut Window,
519        cx: &mut Context<Self>,
520    ) {
521        if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
522            self.editor_subscription = Some((
523                cx.observe(&editor, Self::update_enabled),
524                editor.entity_id().as_u64() as usize,
525            ));
526            self.update_enabled(editor, cx);
527        } else {
528            self.language = None;
529            self.editor_subscription = None;
530            self.editor_enabled = None;
531        }
532        cx.notify();
533    }
534}
535
536impl SupermavenButtonStatus {
537    fn to_icon(&self) -> IconName {
538        match self {
539            SupermavenButtonStatus::Ready => IconName::Supermaven,
540            SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
541            SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
542            SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
543        }
544    }
545
546    fn to_tooltip(&self) -> String {
547        match self {
548            SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
549            SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
550            SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
551            SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
552        }
553    }
554
555    fn has_menu(&self) -> bool {
556        match self {
557            SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
558            SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
559        }
560    }
561}
562
563async fn configure_disabled_globs(
564    workspace: WeakEntity<Workspace>,
565    path_to_disable: Option<Arc<Path>>,
566    mut cx: AsyncWindowContext,
567) -> Result<()> {
568    let settings_editor = workspace
569        .update_in(&mut cx, |_, window, cx| {
570            create_and_open_local_file(paths::settings_file(), window, cx, || {
571                settings::initial_user_settings_content().as_ref().into()
572            })
573        })?
574        .await?
575        .downcast::<Editor>()
576        .unwrap();
577
578    settings_editor
579        .downgrade()
580        .update_in(&mut cx, |item, window, cx| {
581            let text = item.buffer().read(cx).snapshot(cx).text();
582
583            let settings = cx.global::<SettingsStore>();
584            let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
585                let copilot = file.inline_completions.get_or_insert_with(Default::default);
586                let globs = copilot.disabled_globs.get_or_insert_with(|| {
587                    settings
588                        .get::<AllLanguageSettings>(None)
589                        .inline_completions
590                        .disabled_globs
591                        .iter()
592                        .map(|glob| glob.glob().to_string())
593                        .collect()
594                });
595
596                if let Some(path_to_disable) = &path_to_disable {
597                    globs.push(path_to_disable.to_string_lossy().into_owned());
598                } else {
599                    globs.clear();
600                }
601            });
602
603            if !edits.is_empty() {
604                item.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
605                    selections.select_ranges(edits.iter().map(|e| e.0.clone()));
606                });
607
608                // When *enabling* a path, don't actually perform an edit, just select the range.
609                if path_to_disable.is_some() {
610                    item.edit(edits.iter().cloned(), cx);
611                }
612            }
613        })?;
614
615    anyhow::Ok(())
616}
617
618fn toggle_inline_completions_globally(fs: Arc<dyn Fs>, cx: &mut App) {
619    let show_inline_completions =
620        all_language_settings(None, cx).inline_completions_enabled(None, None, cx);
621    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
622        file.defaults.show_inline_completions = Some(!show_inline_completions)
623    });
624}
625
626fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: InlineCompletionProvider) {
627    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
628        file.features
629            .get_or_insert(Default::default())
630            .inline_completion_provider = Some(provider);
631    });
632}
633
634fn toggle_inline_completions_for_language(language: Arc<Language>, fs: Arc<dyn Fs>, cx: &mut App) {
635    let show_inline_completions =
636        all_language_settings(None, cx).inline_completions_enabled(Some(&language), None, cx);
637    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
638        file.languages
639            .entry(language.name())
640            .or_default()
641            .show_inline_completions = Some(!show_inline_completions);
642    });
643}
644
645fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
646    update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
647        file.features
648            .get_or_insert(Default::default())
649            .inline_completion_provider = Some(InlineCompletionProvider::None);
650    });
651}