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