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}