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