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