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