copilot_button.rs

  1use context_menu::{ContextMenu, ContextMenuItem};
  2use copilot::{Copilot, Reinstall, 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_action(
107                                            COPILOT_ERROR_TOAST_ID,
108                                            format!("Copilot can't be started: {}", e),
109                                            "Reinstall Copilot",
110                                            Reinstall,
111                                        ),
112                                        cx,
113                                    );
114                                });
115                            }
116                        }
117                        _ => this.deploy_copilot_start_menu(cx),
118                    }
119                })
120                .with_tooltip::<Self>(
121                    0,
122                    "GitHub Copilot".into(),
123                    None,
124                    theme.tooltip.clone(),
125                    cx,
126                ),
127            )
128            .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
129            .into_any()
130    }
131}
132
133impl CopilotButton {
134    pub fn new(cx: &mut ViewContext<Self>) -> Self {
135        let menu = cx.add_view(|cx| {
136            let mut menu = ContextMenu::new(cx);
137            menu.set_position_mode(OverlayPositionMode::Local);
138            menu
139        });
140
141        cx.observe(&menu, |_, _, cx| cx.notify()).detach();
142
143        Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
144
145        cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
146            .detach();
147
148        Self {
149            popup_menu: menu,
150            editor_subscription: None,
151            editor_enabled: None,
152            language: None,
153        }
154    }
155
156    pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
157        let mut menu_options = Vec::with_capacity(2);
158
159        menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
160            initiate_sign_in(cx)
161        }));
162        menu_options.push(ContextMenuItem::handler("Disable Copilot", |cx| {
163            hide_copilot(cx)
164        }));
165
166        self.popup_menu.update(cx, |menu, cx| {
167            menu.show(
168                Default::default(),
169                AnchorCorner::BottomRight,
170                menu_options,
171                cx,
172            );
173        });
174    }
175
176    pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
177        let settings = cx.global::<Settings>();
178
179        let mut menu_options = Vec::with_capacity(6);
180
181        if let Some(language) = self.language.clone() {
182            let language_enabled = settings.show_copilot_suggestions(Some(language.as_ref()));
183            menu_options.push(ContextMenuItem::handler(
184                format!(
185                    "{} Suggestions for {}",
186                    if language_enabled { "Hide" } else { "Show" },
187                    language
188                ),
189                move |cx| toggle_copilot_for_language(language.clone(), cx),
190            ));
191        }
192
193        let globally_enabled = cx.global::<Settings>().show_copilot_suggestions(None);
194        menu_options.push(ContextMenuItem::handler(
195            if globally_enabled {
196                "Hide Suggestions for All Files"
197            } else {
198                "Show Suggestions for All Files"
199            },
200            |cx| toggle_copilot_globally(cx),
201        ));
202
203        menu_options.push(ContextMenuItem::Separator);
204
205        let icon_style = settings.theme.copilot.out_link_icon.clone();
206        menu_options.push(ContextMenuItem::action(
207            move |state: &mut MouseState, style: &theme::ContextMenuItem| {
208                Flex::row()
209                    .with_child(Label::new("Copilot Settings", style.label.clone()))
210                    .with_child(theme::ui::icon(icon_style.style_for(state, false)))
211                    .align_children_center()
212                    .into_any()
213            },
214            OsOpen::new(COPILOT_SETTINGS_URL),
215        ));
216
217        menu_options.push(ContextMenuItem::action("Sign Out", SignOut));
218
219        self.popup_menu.update(cx, |menu, cx| {
220            menu.show(
221                Default::default(),
222                AnchorCorner::BottomRight,
223                menu_options,
224                cx,
225            );
226        });
227    }
228
229    pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
230        let editor = editor.read(cx);
231
232        let snapshot = editor.buffer().read(cx).snapshot(cx);
233        let settings = cx.global::<Settings>();
234        let suggestion_anchor = editor.selections.newest_anchor().start;
235
236        let language_name = snapshot
237            .language_at(suggestion_anchor)
238            .map(|language| language.name());
239
240        self.language = language_name.clone();
241
242        self.editor_enabled = Some(settings.show_copilot_suggestions(language_name.as_deref()));
243
244        cx.notify()
245    }
246}
247
248impl StatusItemView for CopilotButton {
249    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
250        if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
251            self.editor_subscription =
252                Some((cx.observe(&editor, Self::update_enabled), editor.id()));
253            self.update_enabled(editor, cx);
254        } else {
255            self.language = None;
256            self.editor_subscription = None;
257            self.editor_enabled = None;
258        }
259        cx.notify();
260    }
261}
262
263fn toggle_copilot_globally(cx: &mut AppContext) {
264    let show_copilot_suggestions = cx.global::<Settings>().show_copilot_suggestions(None);
265    SettingsFile::update(cx, move |file_contents| {
266        file_contents.editor.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
267    });
268}
269
270fn toggle_copilot_for_language(language: Arc<str>, cx: &mut AppContext) {
271    let show_copilot_suggestions = cx
272        .global::<Settings>()
273        .show_copilot_suggestions(Some(&language));
274
275    SettingsFile::update(cx, move |file_contents| {
276        file_contents.languages.insert(
277            language,
278            settings::EditorSettings {
279                show_copilot_suggestions: Some((!show_copilot_suggestions).into()),
280                ..Default::default()
281            },
282        );
283    })
284}
285
286fn hide_copilot(cx: &mut AppContext) {
287    SettingsFile::update(cx, move |file_contents| {
288        file_contents.features.copilot = Some(false)
289    })
290}
291
292fn initiate_sign_in(cx: &mut WindowContext) {
293    let Some(copilot) = Copilot::global(cx) else {
294        return;
295    };
296    let status = copilot.read(cx).status();
297
298    match status {
299        Status::Starting { task } => {
300            let Some(workspace) = cx.root_view().clone().downcast::<Workspace>() else {
301                return;
302            };
303
304            workspace.update(cx, |workspace, cx| {
305                workspace.show_toast(
306                    Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
307                    cx,
308                )
309            });
310            let workspace = workspace.downgrade();
311            cx.spawn(|mut cx| async move {
312                task.await;
313                if let Some(copilot) = cx.read(Copilot::global) {
314                    workspace
315                        .update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
316                            Status::Authorized => workspace.show_toast(
317                                Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
318                                cx,
319                            ),
320                            _ => {
321                                workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
322                                copilot
323                                    .update(cx, |copilot, cx| copilot.sign_in(cx))
324                                    .detach_and_log_err(cx);
325                            }
326                        })
327                        .log_err();
328                }
329            })
330            .detach();
331        }
332        _ => {
333            copilot
334                .update(cx, |copilot, cx| copilot.sign_in(cx))
335                .detach_and_log_err(cx);
336        }
337    }
338}