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, rems, svg,
  6};
  7use menu;
  8use std::time::Duration;
  9use ui::{Button, Icon, IconName, Label, Vector, VectorName, 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        let icon_color = Color::Custom(cx.theme().colors().icon);
107        // Match ZedXCopilot visual appearance
108        let icon_size = rems(2.5);
109        let plus_size = rems(0.875);
110        // The "+" in ZedXCopilot SVG has fill-opacity="0.5"
111        let plus_color = cx.theme().colors().icon.opacity(0.5);
112
113        if let Some(icon_path) = &state.config.icon_path {
114            // Show "[Provider Icon] + [Zed Logo]" format to match built-in Copilot modal
115            h_flex()
116                .gap_2()
117                .items_center()
118                .child(
119                    Icon::from_external_svg(icon_path.clone())
120                        .size(ui::IconSize::Custom(icon_size))
121                        .color(icon_color),
122                )
123                .child(
124                    svg()
125                        .size(plus_size)
126                        .path("icons/plus.svg")
127                        .text_color(plus_color),
128                )
129                .child(Vector::new(VectorName::ZedLogo, icon_size, icon_size).color(icon_color))
130                .into_any_element()
131        } else {
132            // Fallback to just Zed logo if no provider icon
133            Vector::new(VectorName::ZedLogo, icon_size, icon_size)
134                .color(icon_color)
135                .into_any_element()
136        }
137    }
138
139    fn render_device_code(&self, cx: &mut Context<Self>) -> impl IntoElement {
140        let state = self.state.read(cx);
141        let user_code = state.config.user_code.clone();
142        let copied = cx
143            .read_from_clipboard()
144            .map(|item| item.text().as_ref() == Some(&user_code))
145            .unwrap_or(false);
146        let user_code_for_click = user_code.clone();
147
148        h_flex()
149            .w_full()
150            .p_1()
151            .border_1()
152            .border_muted(cx)
153            .rounded_sm()
154            .cursor_pointer()
155            .justify_between()
156            .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
157                cx.write_to_clipboard(ClipboardItem::new_string(user_code_for_click.clone()));
158                window.refresh();
159            })
160            .child(div().flex_1().child(Label::new(user_code)))
161            .child(div().flex_none().px_1().child(Label::new(if copied {
162                "Copied!"
163            } else {
164                "Copy"
165            })))
166    }
167
168    fn render_prompting_modal(&self, cx: &mut Context<Self>) -> impl Element {
169        let (connect_button_label, verification_url, headline, description) = {
170            let state = self.state.read(cx);
171            let label = if self.connect_clicked {
172                "Waiting for connection...".to_string()
173            } else {
174                state.config.connect_button_label.clone()
175            };
176            (
177                label,
178                state.config.verification_url.clone(),
179                state.config.headline.clone(),
180                state.config.description.clone(),
181            )
182        };
183
184        v_flex()
185            .flex_1()
186            .gap_2()
187            .items_center()
188            .child(Headline::new(headline).size(HeadlineSize::Large))
189            .child(Label::new(description).color(Color::Muted))
190            .child(self.render_device_code(cx))
191            .child(
192                Label::new("Paste this code into GitHub after clicking the button below.")
193                    .size(ui::LabelSize::Small),
194            )
195            .child(
196                Button::new("connect-button", connect_button_label)
197                    .on_click(cx.listener(move |this, _, _window, cx| {
198                        cx.open_url(&verification_url);
199                        this.connect_clicked = true;
200                    }))
201                    .full_width()
202                    .style(ButtonStyle::Filled),
203            )
204            .child(
205                Button::new("cancel-button", "Cancel")
206                    .full_width()
207                    .on_click(cx.listener(|_, _, _, cx| {
208                        cx.emit(DismissEvent);
209                    })),
210            )
211    }
212
213    fn render_authorized_modal(&self, cx: &mut Context<Self>) -> impl Element {
214        let state = self.state.read(cx);
215        let success_headline = state.config.success_headline.clone();
216        let success_message = state.config.success_message.clone();
217
218        v_flex()
219            .gap_2()
220            .child(Headline::new(success_headline).size(HeadlineSize::Large))
221            .child(Label::new(success_message))
222            .child(
223                Button::new("done-button", "Done")
224                    .full_width()
225                    .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
226            )
227    }
228
229    fn render_failed_modal(&self, error: &str, cx: &mut Context<Self>) -> impl Element {
230        v_flex()
231            .gap_2()
232            .child(Headline::new("Authorization Failed").size(HeadlineSize::Large))
233            .child(Label::new(error.to_string()).color(Color::Error))
234            .child(
235                Button::new("close-button", "Close")
236                    .full_width()
237                    .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
238            )
239    }
240
241    fn render_loading(window: &mut Window, _cx: &mut Context<Self>) -> impl Element {
242        let loading_icon = svg()
243            .size_8()
244            .path(IconName::ArrowCircle.path())
245            .text_color(window.text_style().color)
246            .with_animation(
247                "icon_circle_arrow",
248                Animation::new(Duration::from_secs(2)).repeat(),
249                |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))),
250            );
251
252        h_flex().justify_center().child(loading_icon)
253    }
254}
255
256impl Render for OAuthDeviceFlowModal {
257    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
258        let status = self.state.read(cx).status.clone();
259
260        let prompt = match &status {
261            OAuthDeviceFlowStatus::Prompting => self.render_prompting_modal(cx).into_any_element(),
262            OAuthDeviceFlowStatus::WaitingForAuthorization => {
263                if self.connect_clicked {
264                    self.render_prompting_modal(cx).into_any_element()
265                } else {
266                    Self::render_loading(window, cx).into_any_element()
267                }
268            }
269            OAuthDeviceFlowStatus::Authorized => {
270                self.render_authorized_modal(cx).into_any_element()
271            }
272            OAuthDeviceFlowStatus::Failed(error) => {
273                self.render_failed_modal(error, cx).into_any_element()
274            }
275        };
276
277        v_flex()
278            .id("oauth-device-flow-modal")
279            .track_focus(&self.focus_handle(cx))
280            .elevation_3(cx)
281            .w_96()
282            .items_center()
283            .p_4()
284            .gap_2()
285            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
286                cx.emit(DismissEvent);
287            }))
288            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
289                window.focus(&this.focus_handle, cx);
290            }))
291            .child(self.render_icon(cx))
292            .child(prompt)
293    }
294}