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