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}