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