inline_completion_button.rs

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