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