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