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