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