copilot_button.rs

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