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    // _settings_subscription: Subscription,
 81}
 82
 83impl Entity for CopilotButton {
 84    type Event = ();
 85}
 86
 87impl View for CopilotButton {
 88    fn ui_name() -> &'static str {
 89        "CopilotButton"
 90    }
 91
 92    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
 93        let settings = cx.global::<Settings>();
 94
 95        if !settings.enable_copilot_integration {
 96            return Empty::new().boxed();
 97        }
 98
 99        let theme = settings.theme.clone();
100        let active = self.popup_menu.read(cx).visible();
101        let Some(copilot) = Copilot::global(cx) else {
102            return Empty::new().boxed();
103        };
104        let status = copilot.read(cx).status();
105
106        let enabled = self.editor_enabled.unwrap_or(settings.copilot_on(None));
107
108        let view_id = cx.view_id();
109
110        Stack::new()
111            .with_child(
112                MouseEventHandler::<Self>::new(0, cx, {
113                    let theme = theme.clone();
114                    let status = status.clone();
115                    move |state, _cx| {
116                        let style = theme
117                            .workspace
118                            .status_bar
119                            .sidebar_buttons
120                            .item
121                            .style_for(state, active);
122
123                        Flex::row()
124                            .with_child(
125                                Svg::new({
126                                    match status {
127                                        Status::Error(_) => "icons/copilot_error_16.svg",
128                                        Status::Authorized => {
129                                            if enabled {
130                                                "icons/copilot_16.svg"
131                                            } else {
132                                                "icons/copilot_disabled_16.svg"
133                                            }
134                                        }
135                                        _ => "icons/copilot_init_16.svg",
136                                    }
137                                })
138                                .with_color(style.icon_color)
139                                .constrained()
140                                .with_width(style.icon_size)
141                                .aligned()
142                                .named("copilot-icon"),
143                            )
144                            .constrained()
145                            .with_height(style.icon_size)
146                            .contained()
147                            .with_style(style.container)
148                            .boxed()
149                    }
150                })
151                .with_cursor_style(CursorStyle::PointingHand)
152                .on_click(MouseButton::Left, {
153                    let status = status.clone();
154                    move |_, cx| match status {
155                        Status::Authorized => cx.dispatch_action(DeployCopilotMenu),
156                        Status::Starting { ref task } => {
157                            cx.dispatch_action(workspace::Toast::new(
158                                COPILOT_STARTING_TOAST_ID,
159                                "Copilot is starting...",
160                            ));
161                            let window_id = cx.window_id();
162                            let task = task.to_owned();
163                            cx.spawn(|mut cx| async move {
164                                task.await;
165                                cx.update(|cx| {
166                                    if let Some(copilot) = Copilot::global(cx) {
167                                        let status = copilot.read(cx).status();
168                                        match status {
169                                            Status::Authorized => cx.dispatch_action_at(
170                                                window_id,
171                                                view_id,
172                                                workspace::Toast::new(
173                                                    COPILOT_STARTING_TOAST_ID,
174                                                    "Copilot has started!",
175                                                ),
176                                            ),
177                                            _ => {
178                                                cx.dispatch_action_at(
179                                                    window_id,
180                                                    view_id,
181                                                    DismissToast::new(COPILOT_STARTING_TOAST_ID),
182                                                );
183                                                cx.dispatch_global_action(SignIn)
184                                            }
185                                        }
186                                    }
187                                })
188                            })
189                            .detach();
190                        }
191                        Status::Error(ref e) => cx.dispatch_action(workspace::Toast::new_action(
192                            COPILOT_ERROR_TOAST_ID,
193                            format!("Copilot can't be started: {}", e),
194                            "Reinstall Copilot",
195                            Reinstall,
196                        )),
197                        _ => cx.dispatch_action(SignIn),
198                    }
199                })
200                .with_tooltip::<Self, _>(
201                    0,
202                    "GitHub Copilot".into(),
203                    None,
204                    theme.tooltip.clone(),
205                    cx,
206                )
207                .boxed(),
208            )
209            .with_child(
210                ChildView::new(&self.popup_menu, cx)
211                    .aligned()
212                    .top()
213                    .right()
214                    .boxed(),
215            )
216            .boxed()
217    }
218}
219
220impl CopilotButton {
221    pub fn new(cx: &mut ViewContext<Self>) -> Self {
222        let menu = cx.add_view(|cx| {
223            let mut menu = ContextMenu::new(cx);
224            menu.set_position_mode(OverlayPositionMode::Local);
225            menu
226        });
227
228        cx.observe(&menu, |_, _, cx| cx.notify()).detach();
229
230        Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
231
232        // TODO: Determine why this leaked.
233        // let this_handle = cx.handle();
234        // let sub =
235        //     cx.observe_global::<Settings, _>(move |cx| this_handle.update(cx, |_, cx| cx.notify()));
236
237        Self {
238            popup_menu: menu,
239            editor_subscription: None,
240            editor_enabled: None,
241            language: None,
242            // _settings_subscription: sub,
243        }
244    }
245
246    pub fn deploy_copilot_menu(&mut self, _: &DeployCopilotMenu, cx: &mut ViewContext<Self>) {
247        let settings = cx.global::<Settings>();
248
249        let mut menu_options = Vec::with_capacity(6);
250
251        if let Some((_, view_id)) = self.editor_subscription.as_ref() {
252            let locally_enabled = self.editor_enabled.unwrap_or(settings.copilot_on(None));
253            menu_options.push(ContextMenuItem::item_for_view(
254                if locally_enabled {
255                    "Pause Copilot for this file"
256                } else {
257                    "Resume Copilot for this file"
258                },
259                *view_id,
260                copilot::Toggle,
261            ));
262        }
263
264        if let Some(language) = &self.language {
265            let language_enabled = settings.copilot_on(Some(language.as_ref()));
266
267            menu_options.push(ContextMenuItem::item(
268                format!(
269                    "{} Copilot for {}",
270                    if language_enabled {
271                        "Disable"
272                    } else {
273                        "Enable"
274                    },
275                    language
276                ),
277                ToggleCopilotForLanguage {
278                    language: language.to_owned(),
279                },
280            ));
281        }
282
283        let globally_enabled = cx.global::<Settings>().copilot_on(None);
284        menu_options.push(ContextMenuItem::item(
285            if globally_enabled {
286                "Disable Copilot Globally"
287            } else {
288                "Enable Copilot Locally"
289            },
290            ToggleCopilotGlobally,
291        ));
292
293        menu_options.push(ContextMenuItem::Separator);
294
295        let icon_style = settings.theme.copilot.out_link_icon.clone();
296        menu_options.push(ContextMenuItem::element_item(
297            Box::new(
298                move |state: &mut MouseState, style: &theme::ContextMenuItem| {
299                    Flex::row()
300                        .with_children([
301                            Label::new("Copilot Settings", style.label.clone()).boxed(),
302                            theme::ui::icon(icon_style.style_for(state, false)).boxed(),
303                        ])
304                        .align_children_center()
305                        .boxed()
306                },
307            ),
308            OsOpen::new(COPILOT_SETTINGS_URL),
309        ));
310
311        menu_options.push(ContextMenuItem::item("Sign Out", SignOut));
312
313        self.popup_menu.update(cx, |menu, cx| {
314            menu.show(
315                Default::default(),
316                AnchorCorner::BottomRight,
317                menu_options,
318                cx,
319            );
320        });
321    }
322
323    pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
324        let editor = editor.read(cx);
325
326        if let Some(enabled) = editor.copilot_state.user_enabled {
327            self.editor_enabled = Some(enabled);
328            cx.notify();
329            return;
330        }
331
332        let snapshot = editor.buffer().read(cx).snapshot(cx);
333        let settings = cx.global::<Settings>();
334        let suggestion_anchor = editor.selections.newest_anchor().start;
335
336        let language_name = snapshot
337            .language_at(suggestion_anchor)
338            .map(|language| language.name());
339
340        self.language = language_name.clone();
341        self.editor_enabled = Some(settings.copilot_on(language_name.as_deref()));
342        cx.notify()
343    }
344}
345
346impl StatusItemView for CopilotButton {
347    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
348        if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
349            self.editor_subscription =
350                Some((cx.observe(&editor, Self::update_enabled), editor.id()));
351            self.update_enabled(editor, cx);
352        } else {
353            self.language = None;
354            self.editor_subscription = None;
355            self.editor_enabled = None;
356        }
357        cx.notify();
358    }
359}