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