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