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 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 ViewContext<Self>) -> AnyElement<Self> {
160 let settings = cx.global::<Settings>();
161
162 if !settings.features.copilot {
163 return Empty::new().into_any();
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().into_any();
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 .into_any_named("copilot-icon"),
210 )
211 .constrained()
212 .with_height(style.icon_size)
213 .contained()
214 .with_style(style.container)
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>(
232 0,
233 "GitHub Copilot".into(),
234 None,
235 theme.tooltip.clone(),
236 cx,
237 ),
238 )
239 .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
240 .into_any()
241 }
242}
243
244impl CopilotButton {
245 pub fn new(cx: &mut ViewContext<Self>) -> Self {
246 let menu = cx.add_view(|cx| {
247 let mut menu = ContextMenu::new(cx);
248 menu.set_position_mode(OverlayPositionMode::Local);
249 menu
250 });
251
252 cx.observe(&menu, |_, _, cx| cx.notify()).detach();
253
254 Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
255
256 cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
257 .detach();
258
259 Self {
260 popup_menu: menu,
261 editor_subscription: None,
262 editor_enabled: None,
263 language: None,
264 }
265 }
266
267 pub fn deploy_copilot_start_menu(
268 &mut self,
269 _: &DeployCopilotStartMenu,
270 cx: &mut ViewContext<Self>,
271 ) {
272 let mut menu_options = Vec::with_capacity(2);
273
274 menu_options.push(ContextMenuItem::item("Sign In", InitiateSignIn));
275 menu_options.push(ContextMenuItem::item("Disable Copilot", HideCopilot));
276
277 self.popup_menu.update(cx, |menu, cx| {
278 menu.show(
279 Default::default(),
280 AnchorCorner::BottomRight,
281 menu_options,
282 cx,
283 );
284 });
285 }
286
287 pub fn deploy_copilot_menu(&mut self, _: &DeployCopilotMenu, cx: &mut ViewContext<Self>) {
288 let settings = cx.global::<Settings>();
289
290 let mut menu_options = Vec::with_capacity(6);
291
292 if let Some(language) = &self.language {
293 let language_enabled = settings.show_copilot_suggestions(Some(language.as_ref()));
294
295 menu_options.push(ContextMenuItem::item(
296 format!(
297 "{} Suggestions for {}",
298 if language_enabled { "Hide" } else { "Show" },
299 language
300 ),
301 ToggleCopilotForLanguage {
302 language: language.to_owned(),
303 },
304 ));
305 }
306
307 let globally_enabled = cx.global::<Settings>().show_copilot_suggestions(None);
308 menu_options.push(ContextMenuItem::item(
309 if globally_enabled {
310 "Hide Suggestions for All Files"
311 } else {
312 "Show Suggestions for All Files"
313 },
314 ToggleCopilotGlobally,
315 ));
316
317 menu_options.push(ContextMenuItem::Separator);
318
319 let icon_style = settings.theme.copilot.out_link_icon.clone();
320 menu_options.push(ContextMenuItem::item(
321 move |state: &mut MouseState, style: &theme::ContextMenuItem| {
322 Flex::row()
323 .with_child(Label::new("Copilot Settings", style.label.clone()))
324 .with_child(theme::ui::icon(icon_style.style_for(state, false)))
325 .align_children_center()
326 .into_any()
327 },
328 OsOpen::new(COPILOT_SETTINGS_URL),
329 ));
330
331 menu_options.push(ContextMenuItem::item("Sign Out", SignOut));
332
333 self.popup_menu.update(cx, |menu, cx| {
334 menu.show(
335 Default::default(),
336 AnchorCorner::BottomRight,
337 menu_options,
338 cx,
339 );
340 });
341 }
342
343 pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
344 let editor = editor.read(cx);
345
346 let snapshot = editor.buffer().read(cx).snapshot(cx);
347 let settings = cx.global::<Settings>();
348 let suggestion_anchor = editor.selections.newest_anchor().start;
349
350 let language_name = snapshot
351 .language_at(suggestion_anchor)
352 .map(|language| language.name());
353
354 self.language = language_name.clone();
355
356 self.editor_enabled = Some(settings.show_copilot_suggestions(language_name.as_deref()));
357
358 cx.notify()
359 }
360}
361
362impl StatusItemView for CopilotButton {
363 fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
364 if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
365 self.editor_subscription =
366 Some((cx.observe(&editor, Self::update_enabled), editor.id()));
367 self.update_enabled(editor, cx);
368 } else {
369 self.language = None;
370 self.editor_subscription = None;
371 self.editor_enabled = None;
372 }
373 cx.notify();
374 }
375}