1use anyhow::Result;
2use context_menu::{ContextMenu, ContextMenuItem};
3use copilot::{Copilot, SignOut, Status};
4use editor::{scroll::autoscroll::Autoscroll, Editor};
5use fs::Fs;
6use gpui::{
7 elements::*,
8 platform::{CursorStyle, MouseButton},
9 AnyElement, AppContext, AsyncAppContext, Element, Entity, MouseState, Subscription, View,
10 ViewContext, ViewHandle, WeakViewHandle, WindowContext,
11};
12use language::{
13 language_settings::{self, all_language_settings, AllLanguageSettings},
14 File, Language,
15};
16use settings::{update_settings_file, SettingsStore};
17use std::{path::Path, sync::Arc};
18use util::{paths, ResultExt};
19use workspace::{
20 create_and_open_local_file, item::ItemHandle,
21 notifications::simple_message_notification::OsOpen, StatusItemView, Toast, Workspace,
22};
23
24const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
25const COPILOT_STARTING_TOAST_ID: usize = 1337;
26const COPILOT_ERROR_TOAST_ID: usize = 1338;
27
28pub struct CopilotButton {
29 popup_menu: ViewHandle<ContextMenu>,
30 editor_subscription: Option<(Subscription, usize)>,
31 editor_enabled: Option<bool>,
32 language: Option<Arc<Language>>,
33 file: Option<Arc<dyn File>>,
34 fs: Arc<dyn Fs>,
35}
36
37impl Entity for CopilotButton {
38 type Event = ();
39}
40
41impl View for CopilotButton {
42 fn ui_name() -> &'static str {
43 "CopilotButton"
44 }
45
46 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
47 let all_language_settings = all_language_settings(None, cx);
48 if !all_language_settings.copilot.feature_enabled {
49 return Empty::new().into_any();
50 }
51
52 let theme = theme::current(cx).clone();
53 let active = self.popup_menu.read(cx).visible();
54 let Some(copilot) = Copilot::global(cx) else {
55 return Empty::new().into_any();
56 };
57 let status = copilot.read(cx).status();
58
59 let enabled = self
60 .editor_enabled
61 .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None));
62
63 Stack::new()
64 .with_child(
65 MouseEventHandler::new::<Self, _>(0, cx, {
66 let theme = theme.clone();
67 let status = status.clone();
68 move |state, _cx| {
69 let style = theme
70 .workspace
71 .status_bar
72 .panel_buttons
73 .button
74 .in_state(active)
75 .style_for(state);
76
77 Flex::row()
78 .with_child(
79 Svg::new({
80 match status {
81 Status::Error(_) => "icons/copilot_error.svg",
82 Status::Authorized => {
83 if enabled {
84 "icons/copilot.svg"
85 } else {
86 "icons/copilot_disabled.svg"
87 }
88 }
89 _ => "icons/copilot_init.svg",
90 }
91 })
92 .with_color(style.icon_color)
93 .constrained()
94 .with_width(style.icon_size)
95 .aligned()
96 .into_any_named("copilot-icon"),
97 )
98 .constrained()
99 .with_height(style.icon_size)
100 .contained()
101 .with_style(style.container)
102 }
103 })
104 .with_cursor_style(CursorStyle::PointingHand)
105 .on_down(MouseButton::Left, |_, this, cx| {
106 this.popup_menu.update(cx, |menu, _| menu.delay_cancel());
107 })
108 .on_click(MouseButton::Left, {
109 let status = status.clone();
110 move |_, this, cx| match status {
111 Status::Authorized => this.deploy_copilot_menu(cx),
112 Status::Error(ref e) => {
113 if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>()
114 {
115 workspace.update(cx, |workspace, cx| {
116 workspace.show_toast(
117 Toast::new(
118 COPILOT_ERROR_TOAST_ID,
119 format!("Copilot can't be started: {}", e),
120 )
121 .on_click(
122 "Reinstall Copilot",
123 |cx| {
124 if let Some(copilot) = Copilot::global(cx) {
125 copilot
126 .update(cx, |copilot, cx| {
127 copilot.reinstall(cx)
128 })
129 .detach();
130 }
131 },
132 ),
133 cx,
134 );
135 });
136 }
137 }
138 _ => this.deploy_copilot_start_menu(cx),
139 }
140 })
141 .with_tooltip::<Self>(
142 0,
143 "GitHub Copilot",
144 None,
145 theme.tooltip.clone(),
146 cx,
147 ),
148 )
149 .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
150 .into_any()
151 }
152}
153
154impl CopilotButton {
155 pub fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<Self>) -> Self {
156 let button_view_id = cx.view_id();
157 let menu = cx.add_view(|cx| {
158 let mut menu = ContextMenu::new(button_view_id, cx);
159 menu.set_position_mode(OverlayPositionMode::Local);
160 menu
161 });
162
163 cx.observe(&menu, |_, _, cx| cx.notify()).detach();
164
165 Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
166
167 cx.observe_global::<SettingsStore, _>(move |_, cx| cx.notify())
168 .detach();
169
170 Self {
171 popup_menu: menu,
172 editor_subscription: None,
173 editor_enabled: None,
174 language: None,
175 file: None,
176 fs,
177 }
178 }
179
180 pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
181 let mut menu_options = Vec::with_capacity(2);
182 let fs = self.fs.clone();
183
184 menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
185 initiate_sign_in(cx)
186 }));
187 menu_options.push(ContextMenuItem::handler("Disable Copilot", move |cx| {
188 hide_copilot(fs.clone(), cx)
189 }));
190
191 self.popup_menu.update(cx, |menu, cx| {
192 menu.toggle(
193 Default::default(),
194 AnchorCorner::BottomRight,
195 menu_options,
196 cx,
197 );
198 });
199 }
200
201 pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
202 let fs = self.fs.clone();
203 let mut menu_options = Vec::with_capacity(8);
204
205 if let Some(language) = self.language.clone() {
206 let fs = fs.clone();
207 let language_enabled = language_settings::language_settings(Some(&language), None, cx)
208 .show_copilot_suggestions;
209 menu_options.push(ContextMenuItem::handler(
210 format!(
211 "{} Suggestions for {}",
212 if language_enabled { "Hide" } else { "Show" },
213 language.name()
214 ),
215 move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
216 ));
217 }
218
219 let settings = settings::get::<AllLanguageSettings>(cx);
220
221 if let Some(file) = &self.file {
222 let path = file.path().clone();
223 let path_enabled = settings.copilot_enabled_for_path(&path);
224 menu_options.push(ContextMenuItem::handler(
225 format!(
226 "{} Suggestions for This Path",
227 if path_enabled { "Hide" } else { "Show" }
228 ),
229 move |cx| {
230 if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() {
231 let workspace = workspace.downgrade();
232 cx.spawn(|_, cx| {
233 configure_disabled_globs(
234 workspace,
235 path_enabled.then_some(path.clone()),
236 cx,
237 )
238 })
239 .detach_and_log_err(cx);
240 }
241 },
242 ));
243 }
244
245 let globally_enabled = settings.copilot_enabled(None, None);
246 menu_options.push(ContextMenuItem::handler(
247 if globally_enabled {
248 "Hide Suggestions for All Files"
249 } else {
250 "Show Suggestions for All Files"
251 },
252 move |cx| toggle_copilot_globally(fs.clone(), cx),
253 ));
254
255 menu_options.push(ContextMenuItem::Separator);
256
257 let icon_style = theme::current(cx).copilot.out_link_icon.clone();
258 menu_options.push(ContextMenuItem::action(
259 move |state: &mut MouseState, style: &theme::ContextMenuItem| {
260 Flex::row()
261 .with_child(Label::new("Copilot Settings", style.label.clone()))
262 .with_child(theme::ui::icon(icon_style.style_for(state)))
263 .align_children_center()
264 .into_any()
265 },
266 OsOpen::new(COPILOT_SETTINGS_URL),
267 ));
268
269 menu_options.push(ContextMenuItem::action("Sign Out", SignOut));
270
271 self.popup_menu.update(cx, |menu, cx| {
272 menu.toggle(
273 Default::default(),
274 AnchorCorner::BottomRight,
275 menu_options,
276 cx,
277 );
278 });
279 }
280
281 pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
282 let editor = editor.read(cx);
283 let snapshot = editor.buffer().read(cx).snapshot(cx);
284 let suggestion_anchor = editor.selections.newest_anchor().start;
285 let language = snapshot.language_at(suggestion_anchor);
286 let file = snapshot.file_at(suggestion_anchor).cloned();
287
288 self.editor_enabled = Some(
289 all_language_settings(self.file.as_ref(), cx)
290 .copilot_enabled(language, file.as_ref().map(|file| file.path().as_ref())),
291 );
292 self.language = language.cloned();
293 self.file = file;
294
295 cx.notify()
296 }
297}
298
299impl StatusItemView for CopilotButton {
300 fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
301 if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
302 self.editor_subscription =
303 Some((cx.observe(&editor, Self::update_enabled), editor.id()));
304 self.update_enabled(editor, cx);
305 } else {
306 self.language = None;
307 self.editor_subscription = None;
308 self.editor_enabled = None;
309 }
310 cx.notify();
311 }
312}
313
314async fn configure_disabled_globs(
315 workspace: WeakViewHandle<Workspace>,
316 path_to_disable: Option<Arc<Path>>,
317 mut cx: AsyncAppContext,
318) -> Result<()> {
319 let settings_editor = workspace
320 .update(&mut cx, |_, cx| {
321 create_and_open_local_file(&paths::SETTINGS, cx, || {
322 settings::initial_user_settings_content().as_ref().into()
323 })
324 })?
325 .await?
326 .downcast::<Editor>()
327 .unwrap();
328
329 settings_editor.downgrade().update(&mut cx, |item, cx| {
330 let text = item.buffer().read(cx).snapshot(cx).text();
331
332 let settings = cx.global::<SettingsStore>();
333 let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
334 let copilot = file.copilot.get_or_insert_with(Default::default);
335 let globs = copilot.disabled_globs.get_or_insert_with(|| {
336 settings
337 .get::<AllLanguageSettings>(None)
338 .copilot
339 .disabled_globs
340 .iter()
341 .map(|glob| glob.glob().to_string())
342 .collect()
343 });
344
345 if let Some(path_to_disable) = &path_to_disable {
346 globs.push(path_to_disable.to_string_lossy().into_owned());
347 } else {
348 globs.clear();
349 }
350 });
351
352 if !edits.is_empty() {
353 item.change_selections(Some(Autoscroll::newest()), cx, |selections| {
354 selections.select_ranges(edits.iter().map(|e| e.0.clone()));
355 });
356
357 // When *enabling* a path, don't actually perform an edit, just select the range.
358 if path_to_disable.is_some() {
359 item.edit(edits.iter().cloned(), cx);
360 }
361 }
362 })?;
363
364 anyhow::Ok(())
365}
366
367fn toggle_copilot_globally(fs: Arc<dyn Fs>, cx: &mut AppContext) {
368 let show_copilot_suggestions = all_language_settings(None, cx).copilot_enabled(None, None);
369 update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
370 file.defaults.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
371 });
372}
373
374fn toggle_copilot_for_language(language: Arc<Language>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
375 let show_copilot_suggestions =
376 all_language_settings(None, cx).copilot_enabled(Some(&language), None);
377 update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
378 file.languages
379 .entry(language.name())
380 .or_default()
381 .show_copilot_suggestions = Some(!show_copilot_suggestions);
382 });
383}
384
385fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
386 update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
387 file.features.get_or_insert(Default::default()).copilot = Some(false);
388 });
389}
390
391fn initiate_sign_in(cx: &mut WindowContext) {
392 let Some(copilot) = Copilot::global(cx) else {
393 return;
394 };
395 let status = copilot.read(cx).status();
396
397 match status {
398 Status::Starting { task } => {
399 let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() else {
400 return;
401 };
402
403 workspace.update(cx, |workspace, cx| {
404 workspace.show_toast(
405 Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
406 cx,
407 )
408 });
409 let workspace = workspace.downgrade();
410 cx.spawn(|mut cx| async move {
411 task.await;
412 if let Some(copilot) = cx.read(Copilot::global) {
413 workspace
414 .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
415 Status::Authorized => workspace.show_toast(
416 Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
417 cx,
418 ),
419 _ => {
420 workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
421 copilot
422 .update(cx, |copilot, cx| copilot.sign_in(cx))
423 .detach_and_log_err(cx);
424 }
425 })
426 .log_err();
427 }
428 })
429 .detach();
430 }
431 _ => {
432 copilot
433 .update(cx, |copilot, cx| copilot.sign_in(cx))
434 .detach_and_log_err(cx);
435 }
436 }
437}