copilot_button.rs

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