oauth_device_flow_modal.rs

  1use gpui::{
  2    Animation, AnimationExt, App, ClipboardItem, Context, DismissEvent, Element, Entity,
  3    EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent,
  4    ParentElement, Render, SharedString, Styled, Subscription, Transformation, Window, div,
  5    percentage, svg,
  6};
  7use menu;
  8use std::time::Duration;
  9use ui::{Button, Icon, IconName, Label, prelude::*};
 10
 11use crate::ModalView;
 12
 13/// Configuration for the OAuth device flow modal.
 14/// This allows extensions to specify the text and appearance of the modal.
 15#[derive(Clone)]
 16pub struct OAuthDeviceFlowModalConfig {
 17    /// The user code to display (e.g., "ABC-123").
 18    pub user_code: String,
 19    /// The URL the user needs to visit to authorize (for the "Connect" button).
 20    pub verification_url: String,
 21    /// The headline text for the modal (e.g., "Use GitHub Copilot in Zed.").
 22    pub headline: String,
 23    /// A description to show below the headline.
 24    pub description: String,
 25    /// Label for the connect button (e.g., "Connect to GitHub").
 26    pub connect_button_label: String,
 27    /// Success headline shown when authorization completes.
 28    pub success_headline: String,
 29    /// Success message shown when authorization completes.
 30    pub success_message: String,
 31    /// Optional path to an SVG icon file (absolute path on disk).
 32    pub icon_path: Option<SharedString>,
 33}
 34
 35/// The current status of the OAuth device flow.
 36#[derive(Clone, Debug)]
 37pub enum OAuthDeviceFlowStatus {
 38    /// Waiting for user to click connect and authorize.
 39    Prompting,
 40    /// User clicked connect, waiting for authorization.
 41    WaitingForAuthorization,
 42    /// Successfully authorized.
 43    Authorized,
 44    /// Authorization failed with an error message.
 45    Failed(String),
 46}
 47
 48/// Shared state for the OAuth device flow that can be observed by the modal.
 49pub struct OAuthDeviceFlowState {
 50    pub config: OAuthDeviceFlowModalConfig,
 51    pub status: OAuthDeviceFlowStatus,
 52}
 53
 54impl EventEmitter<()> for OAuthDeviceFlowState {}
 55
 56impl OAuthDeviceFlowState {
 57    pub fn new(config: OAuthDeviceFlowModalConfig) -> Self {
 58        Self {
 59            config,
 60            status: OAuthDeviceFlowStatus::Prompting,
 61        }
 62    }
 63
 64    /// Update the status of the OAuth flow.
 65    pub fn set_status(&mut self, status: OAuthDeviceFlowStatus, cx: &mut Context<Self>) {
 66        self.status = status;
 67        cx.emit(());
 68        cx.notify();
 69    }
 70}
 71
 72/// A generic OAuth device flow modal that can be used by extensions.
 73pub struct OAuthDeviceFlowModal {
 74    state: Entity<OAuthDeviceFlowState>,
 75    connect_clicked: bool,
 76    focus_handle: FocusHandle,
 77    _subscription: Subscription,
 78}
 79
 80impl Focusable for OAuthDeviceFlowModal {
 81    fn focus_handle(&self, _: &App) -> FocusHandle {
 82        self.focus_handle.clone()
 83    }
 84}
 85
 86impl EventEmitter<DismissEvent> for OAuthDeviceFlowModal {}
 87
 88impl ModalView for OAuthDeviceFlowModal {}
 89
 90impl OAuthDeviceFlowModal {
 91    pub fn new(state: Entity<OAuthDeviceFlowState>, cx: &mut Context<Self>) -> Self {
 92        let subscription = cx.observe(&state, |_, _, cx| {
 93            cx.notify();
 94        });
 95
 96        Self {
 97            state,
 98            connect_clicked: false,
 99            focus_handle: cx.focus_handle(),
100            _subscription: subscription,
101        }
102    }
103
104    fn render_icon(&self, cx: &mut Context<Self>) -> impl IntoElement {
105        let state = self.state.read(cx);
106        if let Some(icon_path) = &state.config.icon_path {
107            Icon::from_external_svg(icon_path.clone())
108                .size(ui::IconSize::XLarge)
109                .color(Color::Custom(cx.theme().colors().icon))
110                .into_any_element()
111        } else {
112            div().into_any_element()
113        }
114    }
115
116    fn render_device_code(&self, cx: &mut Context<Self>) -> impl IntoElement {
117        let state = self.state.read(cx);
118        let user_code = state.config.user_code.clone();
119        let copied = cx
120            .read_from_clipboard()
121            .map(|item| item.text().as_ref() == Some(&user_code))
122            .unwrap_or(false);
123        let user_code_for_click = user_code.clone();
124
125        h_flex()
126            .w_full()
127            .p_1()
128            .border_1()
129            .border_muted(cx)
130            .rounded_sm()
131            .cursor_pointer()
132            .justify_between()
133            .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
134                cx.write_to_clipboard(ClipboardItem::new_string(user_code_for_click.clone()));
135                window.refresh();
136            })
137            .child(div().flex_1().child(Label::new(user_code)))
138            .child(div().flex_none().px_1().child(Label::new(if copied {
139                "Copied!"
140            } else {
141                "Copy"
142            })))
143    }
144
145    fn render_prompting_modal(&self, cx: &mut Context<Self>) -> impl Element {
146        let (connect_button_label, verification_url, headline, description) = {
147            let state = self.state.read(cx);
148            let label = if self.connect_clicked {
149                "Waiting for connection...".to_string()
150            } else {
151                state.config.connect_button_label.clone()
152            };
153            (
154                label,
155                state.config.verification_url.clone(),
156                state.config.headline.clone(),
157                state.config.description.clone(),
158            )
159        };
160
161        v_flex()
162            .flex_1()
163            .gap_2()
164            .items_center()
165            .child(Headline::new(headline).size(HeadlineSize::Large))
166            .child(Label::new(description).color(Color::Muted))
167            .child(self.render_device_code(cx))
168            .child(
169                Label::new("Paste this code into GitHub after clicking the button below.")
170                    .size(ui::LabelSize::Small),
171            )
172            .child(
173                Button::new("connect-button", connect_button_label)
174                    .on_click(cx.listener(move |this, _, _window, cx| {
175                        cx.open_url(&verification_url);
176                        this.connect_clicked = true;
177                    }))
178                    .full_width()
179                    .style(ButtonStyle::Filled),
180            )
181            .child(
182                Button::new("cancel-button", "Cancel")
183                    .full_width()
184                    .on_click(cx.listener(|_, _, _, cx| {
185                        cx.emit(DismissEvent);
186                    })),
187            )
188    }
189
190    fn render_authorized_modal(&self, cx: &mut Context<Self>) -> impl Element {
191        let state = self.state.read(cx);
192        let success_headline = state.config.success_headline.clone();
193        let success_message = state.config.success_message.clone();
194
195        v_flex()
196            .gap_2()
197            .child(Headline::new(success_headline).size(HeadlineSize::Large))
198            .child(Label::new(success_message))
199            .child(
200                Button::new("done-button", "Done")
201                    .full_width()
202                    .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
203            )
204    }
205
206    fn render_failed_modal(&self, error: &str, cx: &mut Context<Self>) -> impl Element {
207        v_flex()
208            .gap_2()
209            .child(Headline::new("Authorization Failed").size(HeadlineSize::Large))
210            .child(Label::new(error.to_string()).color(Color::Error))
211            .child(
212                Button::new("close-button", "Close")
213                    .full_width()
214                    .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
215            )
216    }
217
218    fn render_loading(window: &mut Window, _cx: &mut Context<Self>) -> impl Element {
219        let loading_icon = svg()
220            .size_8()
221            .path(IconName::ArrowCircle.path())
222            .text_color(window.text_style().color)
223            .with_animation(
224                "icon_circle_arrow",
225                Animation::new(Duration::from_secs(2)).repeat(),
226                |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))),
227            );
228
229        h_flex().justify_center().child(loading_icon)
230    }
231}
232
233impl Render for OAuthDeviceFlowModal {
234    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
235        let status = self.state.read(cx).status.clone();
236
237        let prompt = match &status {
238            OAuthDeviceFlowStatus::Prompting => self.render_prompting_modal(cx).into_any_element(),
239            OAuthDeviceFlowStatus::WaitingForAuthorization => {
240                if self.connect_clicked {
241                    self.render_prompting_modal(cx).into_any_element()
242                } else {
243                    Self::render_loading(window, cx).into_any_element()
244                }
245            }
246            OAuthDeviceFlowStatus::Authorized => {
247                self.render_authorized_modal(cx).into_any_element()
248            }
249            OAuthDeviceFlowStatus::Failed(error) => {
250                self.render_failed_modal(error, cx).into_any_element()
251            }
252        };
253
254        v_flex()
255            .id("oauth-device-flow-modal")
256            .track_focus(&self.focus_handle(cx))
257            .elevation_3(cx)
258            .w_96()
259            .items_center()
260            .p_4()
261            .gap_2()
262            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
263                cx.emit(DismissEvent);
264            }))
265            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| {
266                window.focus(&this.focus_handle);
267            }))
268            .child(self.render_icon(cx))
269            .child(prompt)
270    }
271}