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