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, 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(cx);
45 if !all_language_settings.copilot.feature_enabled {
46 return Empty::new().into_any();
47 }
48
49 let theme = theme::current(cx).clone();
50 let active = self.popup_menu.read(cx).visible();
51 let Some(copilot) = Copilot::global(cx) else {
52 return Empty::new().into_any();
53 };
54 let status = copilot.read(cx).status();
55
56 let enabled = self
57 .editor_enabled
58 .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None));
59
60 Stack::new()
61 .with_child(
62 MouseEventHandler::<Self, _>::new(0, cx, {
63 let theme = theme.clone();
64 let status = status.clone();
65 move |state, _cx| {
66 let style = theme
67 .workspace
68 .status_bar
69 .sidebar_buttons
70 .item
71 .style_for(state, active);
72
73 Flex::row()
74 .with_child(
75 Svg::new({
76 match status {
77 Status::Error(_) => "icons/copilot_error_16.svg",
78 Status::Authorized => {
79 if enabled {
80 "icons/copilot_16.svg"
81 } else {
82 "icons/copilot_disabled_16.svg"
83 }
84 }
85 _ => "icons/copilot_init_16.svg",
86 }
87 })
88 .with_color(style.icon_color)
89 .constrained()
90 .with_width(style.icon_size)
91 .aligned()
92 .into_any_named("copilot-icon"),
93 )
94 .constrained()
95 .with_height(style.icon_size)
96 .contained()
97 .with_style(style.container)
98 }
99 })
100 .with_cursor_style(CursorStyle::PointingHand)
101 .on_click(MouseButton::Left, {
102 let status = status.clone();
103 move |_, this, cx| match status {
104 Status::Authorized => this.deploy_copilot_menu(cx),
105 Status::Error(ref e) => {
106 if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>()
107 {
108 workspace.update(cx, |workspace, cx| {
109 workspace.show_toast(
110 Toast::new(
111 COPILOT_ERROR_TOAST_ID,
112 format!("Copilot can't be started: {}", e),
113 )
114 .on_click(
115 "Reinstall Copilot",
116 |cx| {
117 if let Some(copilot) = Copilot::global(cx) {
118 copilot
119 .update(cx, |copilot, cx| {
120 copilot.reinstall(cx)
121 })
122 .detach();
123 }
124 },
125 ),
126 cx,
127 );
128 });
129 }
130 }
131 _ => this.deploy_copilot_start_menu(cx),
132 }
133 })
134 .with_tooltip::<Self>(
135 0,
136 "GitHub Copilot".into(),
137 None,
138 theme.tooltip.clone(),
139 cx,
140 ),
141 )
142 .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
143 .into_any()
144 }
145}
146
147impl CopilotButton {
148 pub fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<Self>) -> Self {
149 let button_view_id = cx.view_id();
150 let menu = cx.add_view(|cx| {
151 let mut menu = ContextMenu::new(button_view_id, cx);
152 menu.set_position_mode(OverlayPositionMode::Local);
153 menu
154 });
155
156 cx.observe(&menu, |_, _, cx| cx.notify()).detach();
157
158 Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
159
160 cx.observe_global::<SettingsStore, _>(move |_, cx| cx.notify())
161 .detach();
162
163 Self {
164 popup_menu: menu,
165 editor_subscription: None,
166 editor_enabled: None,
167 language: None,
168 path: None,
169 fs,
170 }
171 }
172
173 pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
174 let mut menu_options = Vec::with_capacity(2);
175 let fs = self.fs.clone();
176
177 menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
178 initiate_sign_in(cx)
179 }));
180 menu_options.push(ContextMenuItem::handler("Disable Copilot", move |cx| {
181 hide_copilot(fs.clone(), cx)
182 }));
183
184 self.popup_menu.update(cx, |menu, cx| {
185 menu.show(
186 Default::default(),
187 AnchorCorner::BottomRight,
188 menu_options,
189 cx,
190 );
191 });
192 }
193
194 pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
195 let fs = self.fs.clone();
196 let mut menu_options = Vec::with_capacity(8);
197
198 if let Some(language) = self.language.clone() {
199 let fs = fs.clone();
200 let language_enabled =
201 language_settings::language_settings(Some(language.as_ref()), cx)
202 .show_copilot_suggestions;
203 menu_options.push(ContextMenuItem::handler(
204 format!(
205 "{} Suggestions for {}",
206 if language_enabled { "Hide" } else { "Show" },
207 language
208 ),
209 move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
210 ));
211 }
212
213 let settings = settings::get::<AllLanguageSettings>(cx);
214
215 if let Some(path) = self.path.as_ref() {
216 let path_enabled = settings.copilot_enabled_for_path(path);
217 let path = path.clone();
218 menu_options.push(ContextMenuItem::handler(
219 format!(
220 "{} Suggestions for This Path",
221 if path_enabled { "Hide" } else { "Show" }
222 ),
223 move |cx| {
224 if let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() {
225 let workspace = workspace.downgrade();
226 cx.spawn(|_, cx| {
227 configure_disabled_globs(
228 workspace,
229 path_enabled.then_some(path.clone()),
230 cx,
231 )
232 })
233 .detach_and_log_err(cx);
234 }
235 },
236 ));
237 }
238
239 let globally_enabled = settings.copilot_enabled(None, None);
240 menu_options.push(ContextMenuItem::handler(
241 if globally_enabled {
242 "Hide Suggestions for All Files"
243 } else {
244 "Show Suggestions for All Files"
245 },
246 move |cx| toggle_copilot_globally(fs.clone(), cx),
247 ));
248
249 menu_options.push(ContextMenuItem::Separator);
250
251 let icon_style = theme::current(cx).copilot.out_link_icon.clone();
252 menu_options.push(ContextMenuItem::action(
253 move |state: &mut MouseState, style: &theme::ContextMenuItem| {
254 Flex::row()
255 .with_child(Label::new("Copilot Settings", style.label.clone()))
256 .with_child(theme::ui::icon(icon_style.style_for(state, false)))
257 .align_children_center()
258 .into_any()
259 },
260 OsOpen::new(COPILOT_SETTINGS_URL),
261 ));
262
263 menu_options.push(ContextMenuItem::action("Sign Out", SignOut));
264
265 self.popup_menu.update(cx, |menu, cx| {
266 menu.show(
267 Default::default(),
268 AnchorCorner::BottomRight,
269 menu_options,
270 cx,
271 );
272 });
273 }
274
275 pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
276 let editor = editor.read(cx);
277 let snapshot = editor.buffer().read(cx).snapshot(cx);
278 let suggestion_anchor = editor.selections.newest_anchor().start;
279 let language_name = snapshot
280 .language_at(suggestion_anchor)
281 .map(|language| language.name());
282 let path = snapshot.file_at(suggestion_anchor).map(|file| file.path());
283
284 self.editor_enabled = Some(
285 all_language_settings(cx)
286 .copilot_enabled(language_name.as_deref(), path.map(|p| p.as_ref())),
287 );
288 self.language = language_name;
289 self.path = path.cloned();
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(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<str>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
373 let show_copilot_suggestions = all_language_settings(cx).copilot_enabled(Some(&language), None);
374 update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
375 file.languages
376 .entry(language)
377 .or_default()
378 .show_copilot_suggestions = Some(!show_copilot_suggestions);
379 });
380}
381
382fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
383 update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
384 file.features.get_or_insert(Default::default()).copilot = Some(false);
385 });
386}
387
388fn initiate_sign_in(cx: &mut WindowContext) {
389 let Some(copilot) = Copilot::global(cx) else {
390 return;
391 };
392 let status = copilot.read(cx).status();
393
394 match status {
395 Status::Starting { task } => {
396 let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() else {
397 return;
398 };
399
400 workspace.update(cx, |workspace, cx| {
401 workspace.show_toast(
402 Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
403 cx,
404 )
405 });
406 let workspace = workspace.downgrade();
407 cx.spawn(|mut cx| async move {
408 task.await;
409 if let Some(copilot) = cx.read(Copilot::global) {
410 workspace
411 .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
412 Status::Authorized => workspace.show_toast(
413 Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
414 cx,
415 ),
416 _ => {
417 workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
418 copilot
419 .update(cx, |copilot, cx| copilot.sign_in(cx))
420 .detach_and_log_err(cx);
421 }
422 })
423 .log_err();
424 }
425 })
426 .detach();
427 }
428 _ => {
429 copilot
430 .update(cx, |copilot, cx| copilot.sign_in(cx))
431 .detach_and_log_err(cx);
432 }
433 }
434}