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::language_settings::{self, all_language_settings, AllLanguageSettings};
13use settings::{update_settings_file, Settings, SettingsStore};
14use std::{path::Path, sync::Arc};
15use util::{paths, ResultExt};
16use workspace::{
17 create_and_open_local_file, item::ItemHandle,
18 notifications::simple_message_notification::OsOpen, StatusItemView, Toast, Workspace,
19};
20
21const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
22const COPILOT_STARTING_TOAST_ID: usize = 1337;
23const COPILOT_ERROR_TOAST_ID: usize = 1338;
24
25pub struct CopilotButton {
26 popup_menu: ViewHandle<ContextMenu>,
27 editor_subscription: Option<(Subscription, usize)>,
28 editor_enabled: Option<bool>,
29 language: Option<Arc<str>>,
30 path: Option<Arc<Path>>,
31 fs: Arc<dyn Fs>,
32}
33
34impl Entity for CopilotButton {
35 type Event = ();
36}
37
38impl View for CopilotButton {
39 fn ui_name() -> &'static str {
40 "CopilotButton"
41 }
42
43 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
44 let all_language_settings = &all_language_settings(None, cx);
45 if !all_language_settings.copilot.feature_enabled {
46 return Empty::new().into_any();
47 }
48
49 let settings = cx.global::<Settings>();
50 let theme = settings.theme.clone();
51 let active = self.popup_menu.read(cx).visible();
52 let Some(copilot) = Copilot::global(cx) else {
53 return Empty::new().into_any();
54 };
55 let status = copilot.read(cx).status();
56
57 let enabled = self
58 .editor_enabled
59 .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None));
60
61 Stack::new()
62 .with_child(
63 MouseEventHandler::<Self, _>::new(0, cx, {
64 let theme = theme.clone();
65 let status = status.clone();
66 move |state, _cx| {
67 let style = theme
68 .workspace
69 .status_bar
70 .sidebar_buttons
71 .item
72 .style_for(state, active);
73
74 Flex::row()
75 .with_child(
76 Svg::new({
77 match status {
78 Status::Error(_) => "icons/copilot_error_16.svg",
79 Status::Authorized => {
80 if enabled {
81 "icons/copilot_16.svg"
82 } else {
83 "icons/copilot_disabled_16.svg"
84 }
85 }
86 _ => "icons/copilot_init_16.svg",
87 }
88 })
89 .with_color(style.icon_color)
90 .constrained()
91 .with_width(style.icon_size)
92 .aligned()
93 .into_any_named("copilot-icon"),
94 )
95 .constrained()
96 .with_height(style.icon_size)
97 .contained()
98 .with_style(style.container)
99 }
100 })
101 .with_cursor_style(CursorStyle::PointingHand)
102 .on_click(MouseButton::Left, {
103 let status = status.clone();
104 move |_, this, cx| match status {
105 Status::Authorized => this.deploy_copilot_menu(cx),
106 Status::Error(ref e) => {
107 if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>()
108 {
109 workspace.update(cx, |workspace, cx| {
110 workspace.show_toast(
111 Toast::new(
112 COPILOT_ERROR_TOAST_ID,
113 format!("Copilot can't be started: {}", e),
114 )
115 .on_click(
116 "Reinstall Copilot",
117 |cx| {
118 if let Some(copilot) = Copilot::global(cx) {
119 copilot
120 .update(cx, |copilot, cx| {
121 copilot.reinstall(cx)
122 })
123 .detach();
124 }
125 },
126 ),
127 cx,
128 );
129 });
130 }
131 }
132 _ => this.deploy_copilot_start_menu(cx),
133 }
134 })
135 .with_tooltip::<Self>(
136 0,
137 "GitHub Copilot".into(),
138 None,
139 theme.tooltip.clone(),
140 cx,
141 ),
142 )
143 .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
144 .into_any()
145 }
146}
147
148impl CopilotButton {
149 pub fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<Self>) -> Self {
150 let button_view_id = cx.view_id();
151 let menu = cx.add_view(|cx| {
152 let mut menu = ContextMenu::new(button_view_id, cx);
153 menu.set_position_mode(OverlayPositionMode::Local);
154 menu
155 });
156
157 cx.observe(&menu, |_, _, cx| cx.notify()).detach();
158
159 Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
160
161 cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
162 .detach();
163
164 Self {
165 popup_menu: menu,
166 editor_subscription: None,
167 editor_enabled: None,
168 language: None,
169 path: None,
170 fs,
171 }
172 }
173
174 pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
175 let mut menu_options = Vec::with_capacity(2);
176 let fs = self.fs.clone();
177
178 menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
179 initiate_sign_in(cx)
180 }));
181 menu_options.push(ContextMenuItem::handler("Disable Copilot", move |cx| {
182 hide_copilot(fs.clone(), cx)
183 }));
184
185 self.popup_menu.update(cx, |menu, cx| {
186 menu.show(
187 Default::default(),
188 AnchorCorner::BottomRight,
189 menu_options,
190 cx,
191 );
192 });
193 }
194
195 pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
196 let fs = self.fs.clone();
197 let mut menu_options = Vec::with_capacity(8);
198
199 if let Some(language) = self.language.clone() {
200 let fs = fs.clone();
201 let language_enabled =
202 language_settings::language_settings(None, Some(language.as_ref()), cx)
203 .show_copilot_suggestions;
204 menu_options.push(ContextMenuItem::handler(
205 format!(
206 "{} Suggestions for {}",
207 if language_enabled { "Hide" } else { "Show" },
208 language
209 ),
210 move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
211 ));
212 }
213
214 let settings = settings::get_setting::<AllLanguageSettings>(None, cx);
215
216 if let Some(path) = self.path.as_ref() {
217 let path_enabled = settings.copilot_enabled_for_path(path);
218 let path = path.clone();
219 menu_options.push(ContextMenuItem::handler(
220 format!(
221 "{} Suggestions for This Path",
222 if path_enabled { "Hide" } else { "Show" }
223 ),
224 move |cx| {
225 if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() {
226 let workspace = workspace.downgrade();
227 cx.spawn(|_, cx| {
228 configure_disabled_globs(
229 workspace,
230 path_enabled.then_some(path.clone()),
231 cx,
232 )
233 })
234 .detach_and_log_err(cx);
235 }
236 },
237 ));
238 }
239
240 let globally_enabled = settings.copilot_enabled(None, None);
241 menu_options.push(ContextMenuItem::handler(
242 if globally_enabled {
243 "Hide Suggestions for All Files"
244 } else {
245 "Show Suggestions for All Files"
246 },
247 move |cx| toggle_copilot_globally(fs.clone(), cx),
248 ));
249
250 menu_options.push(ContextMenuItem::Separator);
251
252 let icon_style = cx.global::<Settings>().theme.copilot.out_link_icon.clone();
253 menu_options.push(ContextMenuItem::action(
254 move |state: &mut MouseState, style: &theme::ContextMenuItem| {
255 Flex::row()
256 .with_child(Label::new("Copilot Settings", style.label.clone()))
257 .with_child(theme::ui::icon(icon_style.style_for(state, false)))
258 .align_children_center()
259 .into_any()
260 },
261 OsOpen::new(COPILOT_SETTINGS_URL),
262 ));
263
264 menu_options.push(ContextMenuItem::action("Sign Out", SignOut));
265
266 self.popup_menu.update(cx, |menu, cx| {
267 menu.show(
268 Default::default(),
269 AnchorCorner::BottomRight,
270 menu_options,
271 cx,
272 );
273 });
274 }
275
276 pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
277 let editor = editor.read(cx);
278 let snapshot = editor.buffer().read(cx).snapshot(cx);
279 let suggestion_anchor = editor.selections.newest_anchor().start;
280 let language_name = snapshot
281 .language_at(suggestion_anchor)
282 .map(|language| language.name());
283 let path = snapshot.file_at(suggestion_anchor).map(|file| file.path());
284
285 self.editor_enabled = Some(
286 all_language_settings(None, cx)
287 .copilot_enabled(language_name.as_deref(), path.map(|p| p.as_ref())),
288 );
289 self.language = language_name;
290 self.path = path.cloned();
291
292 cx.notify()
293 }
294}
295
296impl StatusItemView for CopilotButton {
297 fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
298 if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
299 self.editor_subscription =
300 Some((cx.observe(&editor, Self::update_enabled), editor.id()));
301 self.update_enabled(editor, cx);
302 } else {
303 self.language = None;
304 self.editor_subscription = None;
305 self.editor_enabled = None;
306 }
307 cx.notify();
308 }
309}
310
311async fn configure_disabled_globs(
312 workspace: WeakViewHandle<Workspace>,
313 path_to_disable: Option<Arc<Path>>,
314 mut cx: AsyncAppContext,
315) -> Result<()> {
316 let settings_editor = workspace
317 .update(&mut cx, |_, cx| {
318 create_and_open_local_file(&paths::SETTINGS, cx, || {
319 Settings::initial_user_settings_content(&assets::Assets)
320 .as_ref()
321 .into()
322 })
323 })?
324 .await?
325 .downcast::<Editor>()
326 .unwrap();
327
328 settings_editor.downgrade().update(&mut cx, |item, cx| {
329 let text = item.buffer().read(cx).snapshot(cx).text();
330
331 let settings = cx.global::<SettingsStore>();
332 let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
333 let copilot = file.copilot.get_or_insert_with(Default::default);
334 let globs = copilot.disabled_globs.get_or_insert_with(|| {
335 settings
336 .get::<AllLanguageSettings>(None)
337 .copilot
338 .disabled_globs
339 .clone()
340 .iter()
341 .map(|glob| glob.as_str().to_string())
342 .collect::<Vec<_>>()
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<str>, 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)
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}