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 button_view_id = cx.view_id();
146 let menu = cx.add_view(|cx| {
147 let mut menu = ContextMenu::new(button_view_id, cx);
148 menu.set_position_mode(OverlayPositionMode::Local);
149 menu
150 });
151
152 cx.observe(&menu, |_, _, cx| cx.notify()).detach();
153
154 Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
155
156 cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
157 .detach();
158
159 Self {
160 popup_menu: menu,
161 editor_subscription: None,
162 editor_enabled: None,
163 language: None,
164 }
165 }
166
167 pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
168 let mut menu_options = Vec::with_capacity(2);
169
170 menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
171 initiate_sign_in(cx)
172 }));
173 menu_options.push(ContextMenuItem::handler("Disable Copilot", |cx| {
174 hide_copilot(cx)
175 }));
176
177 self.popup_menu.update(cx, |menu, cx| {
178 menu.show(
179 Default::default(),
180 AnchorCorner::BottomRight,
181 menu_options,
182 cx,
183 );
184 });
185 }
186
187 pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
188 let settings = cx.global::<Settings>();
189
190 let mut menu_options = Vec::with_capacity(6);
191
192 if let Some(language) = self.language.clone() {
193 let language_enabled = settings.show_copilot_suggestions(Some(language.as_ref()));
194 menu_options.push(ContextMenuItem::handler(
195 format!(
196 "{} Suggestions for {}",
197 if language_enabled { "Hide" } else { "Show" },
198 language
199 ),
200 move |cx| toggle_copilot_for_language(language.clone(), cx),
201 ));
202 }
203
204 let globally_enabled = cx.global::<Settings>().show_copilot_suggestions(None);
205 menu_options.push(ContextMenuItem::handler(
206 if globally_enabled {
207 "Hide Suggestions for All Files"
208 } else {
209 "Show Suggestions for All Files"
210 },
211 |cx| toggle_copilot_globally(cx),
212 ));
213
214 menu_options.push(ContextMenuItem::Separator);
215
216 let icon_style = settings.theme.copilot.out_link_icon.clone();
217 menu_options.push(ContextMenuItem::action(
218 move |state: &mut MouseState, style: &theme::ContextMenuItem| {
219 Flex::row()
220 .with_child(Label::new("Copilot Settings", style.label.clone()))
221 .with_child(theme::ui::icon(icon_style.style_for(state, false)))
222 .align_children_center()
223 .into_any()
224 },
225 OsOpen::new(COPILOT_SETTINGS_URL),
226 ));
227
228 menu_options.push(ContextMenuItem::action("Sign Out", SignOut));
229
230 self.popup_menu.update(cx, |menu, cx| {
231 menu.show(
232 Default::default(),
233 AnchorCorner::BottomRight,
234 menu_options,
235 cx,
236 );
237 });
238 }
239
240 pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
241 let editor = editor.read(cx);
242
243 let snapshot = editor.buffer().read(cx).snapshot(cx);
244 let settings = cx.global::<Settings>();
245 let suggestion_anchor = editor.selections.newest_anchor().start;
246
247 let language_name = snapshot
248 .language_at(suggestion_anchor)
249 .map(|language| language.name());
250
251 self.language = language_name.clone();
252
253 self.editor_enabled = Some(settings.show_copilot_suggestions(language_name.as_deref()));
254
255 cx.notify()
256 }
257}
258
259impl StatusItemView for CopilotButton {
260 fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
261 if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
262 self.editor_subscription =
263 Some((cx.observe(&editor, Self::update_enabled), editor.id()));
264 self.update_enabled(editor, cx);
265 } else {
266 self.language = None;
267 self.editor_subscription = None;
268 self.editor_enabled = None;
269 }
270 cx.notify();
271 }
272}
273
274fn toggle_copilot_globally(cx: &mut AppContext) {
275 let show_copilot_suggestions = cx.global::<Settings>().show_copilot_suggestions(None);
276 SettingsFile::update(cx, move |file_contents| {
277 file_contents.editor.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
278 });
279}
280
281fn toggle_copilot_for_language(language: Arc<str>, cx: &mut AppContext) {
282 let show_copilot_suggestions = cx
283 .global::<Settings>()
284 .show_copilot_suggestions(Some(&language));
285
286 SettingsFile::update(cx, move |file_contents| {
287 file_contents.languages.insert(
288 language,
289 settings::EditorSettings {
290 show_copilot_suggestions: Some((!show_copilot_suggestions).into()),
291 ..Default::default()
292 },
293 );
294 })
295}
296
297fn hide_copilot(cx: &mut AppContext) {
298 SettingsFile::update(cx, move |file_contents| {
299 file_contents.features.copilot = Some(false)
300 })
301}
302
303fn initiate_sign_in(cx: &mut WindowContext) {
304 let Some(copilot) = Copilot::global(cx) else {
305 return;
306 };
307 let status = copilot.read(cx).status();
308
309 match status {
310 Status::Starting { task } => {
311 let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() else {
312 return;
313 };
314
315 workspace.update(cx, |workspace, cx| {
316 workspace.show_toast(
317 Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
318 cx,
319 )
320 });
321 let workspace = workspace.downgrade();
322 cx.spawn(|mut cx| async move {
323 task.await;
324 if let Some(copilot) = cx.read(Copilot::global) {
325 workspace
326 .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
327 Status::Authorized => workspace.show_toast(
328 Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
329 cx,
330 ),
331 _ => {
332 workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
333 copilot
334 .update(cx, |copilot, cx| copilot.sign_in(cx))
335 .detach_and_log_err(cx);
336 }
337 })
338 .log_err();
339 }
340 })
341 .detach();
342 }
343 _ => {
344 copilot
345 .update(cx, |copilot, cx| copilot.sign_in(cx))
346 .detach_and_log_err(cx);
347 }
348 }
349}