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