copilot_button.rs

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