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