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