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