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