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