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