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}