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