use std::time::Duration; use futures_lite::StreamExt; use gpui::{ BorrowAppContext, InteractiveElement, ParentElement, Styled, Timer, div, img, prelude::FluentBuilder, }; use rand::RngExt; use crate::{ api, app, component::{ button::button, font_icon::{FontIcon, font_icon}, text::text, }, query::{self, QueryStatus, fetch_query, read_query, use_query}, storage, util::timeout::set_timeout, }; pub(crate) struct GithubStepView { is_opening_link: bool, has_opened_link: bool, placeholder_code: String, has_copied_code: bool, create_device_code_query: query::Entity, request_access_token_query: Option>, user_query: Option>, on_success: Option>, } pub(crate) fn new(cx: &mut gpui::Context) -> GithubStepView { let mut view = GithubStepView { is_opening_link: false, has_opened_link: false, placeholder_code: "ABCDEFGH".to_owned(), has_copied_code: false, create_device_code_query: use_query(api::auth::CreateDeviceCode, cx), request_access_token_query: None, user_query: None, on_success: None, }; view.on_create(cx); view } impl GithubStepView { const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; pub(crate) fn on_success( &mut self, f: impl Fn(api::user::Id, &mut gpui::App) + 'static, ) -> &mut Self { self.on_success = Some(Box::new(f)); self } fn on_create(&mut self, cx: &mut gpui::Context) { cx.observe(&self.create_device_code_query, |this, _, cx| { let code = { let data = read_query(&this.create_device_code_query, cx); if let QueryStatus::Loaded(data) = data { Some(data.device_code.clone()) } else { None } }; if let Some(ref code) = code && !this.has_opened_link && !this.is_opening_link { this.is_opening_link = true; this.copy_device_code(code, cx); let code = code.clone(); set_timeout( move |weak, cx| { _ = weak.update(cx, |this, cx| { this.has_opened_link = true; this.is_opening_link = false; this.begin_auth_flow(&code, cx); cx.notify(); }); }, Duration::from_secs(2), cx, ); cx.notify(); } }) .detach(); cx.spawn(async |this, cx| { let mut timer = Timer::interval(Duration::from_millis(50)); loop { let is_code_loaded = this.read_with(cx, |this, cx| { matches!( read_query(&this.create_device_code_query, cx), QueryStatus::Loaded(_) ) }); if matches!(is_code_loaded, Ok(true) | Err(_)) { timer.clear(); } else { let _ = this.update(cx, |this, cx| { this.placeholder_code = this.generate_random_code(cx); cx.notify(); }); } if let None = timer.next().await { break; }; } }) .detach(); } fn open_github_auth_page(cx: &mut gpui::Context) { cx.open_url(api::auth::DEVICE_LOGIN_FLOW_URL); } fn generate_random_code(&mut self, cx: &mut gpui::Context) -> String { let rng = app::rng(cx); (0..8) .map(|_| { let idx = rng.random_range(0..Self::CHAR_POOL.len()); Self::CHAR_POOL.chars().nth(idx).unwrap() }) .collect() } fn copy_device_code(&mut self, code: &str, cx: &mut gpui::Context) { cx.write_to_clipboard(gpui::ClipboardItem::new_string(code.to_owned())); self.has_copied_code = true; set_timeout( |weak, cx| { _ = weak.update(cx, |this, cx| { this.has_copied_code = false; cx.notify(); }); }, Duration::from_secs(3), cx, ); cx.notify(); } fn begin_auth_flow(&mut self, device_code: &str, cx: &mut gpui::Context) { GithubStepView::open_github_auth_page(cx); let query = use_query( api::auth::RequestAccessToken { device_code: device_code.to_owned(), }, cx, ); cx.observe(&query, |this, _, cx| { this.handle_access_token_query_response(cx); }) .detach(); self.has_opened_link = true; self.request_access_token_query = Some(query); cx.notify(); } fn handle_access_token_query_response(&mut self, cx: &mut gpui::Context) { let Some(query) = &self.request_access_token_query else { return; }; let QueryStatus::Loaded(api::auth::DeviceCodeResponse { interval, .. }) = read_query(&self.create_device_code_query, cx) else { return; }; let poll_interval = u64::from(*interval); match read_query(query, cx) { QueryStatus::Loaded(data) => { let auth_tokens = api::AuthTokens { access_token: data.access_token.clone(), }; cx.update_global::, _>(|store, _| { store.update_query_context(|c| { c.auth = Some(auth_tokens.clone()); }); }); self.user_query = Some(use_query(api::user::Fetch, cx)); cx.spawn(async move |weak, cx| { let ent = fetch_query(api::user::Fetch, cx).await; let fut = weak .update(cx, move |_this, cx| { let Ok(query) = ent else { return None; }; let QueryStatus::Loaded(user) = read_query(&query, cx) else { return None; }; Some(storage::store_auth_tokens(&auth_tokens, user, cx)) }) .unwrap_or_default(); _ = if let Some(task) = fut { task.await } else { Err(anyhow::Error::msg("")) }; }) .detach(); } QueryStatus::Err(api::Error::Github(api::GithubError { error, .. })) => { if error == "authorization_pending" { cx.spawn(async move |weak, cx| { Timer::after(Duration::from_secs(poll_interval)).await; if let Ok(Some(query)) = weak.read_with(cx, |this, _cx| this.request_access_token_query.clone()) { let _ = weak.update(cx, |_this, cx| { query.refetch(cx); }); } }) .detach(); } } _ => {} } } fn on_next_clicked(&mut self, cx: &mut gpui::Context) { let (Some(f), Some(query)) = (&self.on_success, &self.user_query) else { return; }; let QueryStatus::Loaded(user) = read_query(query, cx) else { return; }; f(user.id, cx); } fn device_code_area(&self, cx: &mut gpui::Context) -> gpui::Div { let create_device_code_query = read_query(&self.create_device_code_query, cx); let is_loading_code = matches!(create_device_code_query, QueryStatus::Loading); let theme = app::current_theme(cx); let displayed_code = match create_device_code_query { QueryStatus::Loaded(data) => &data.user_code, _ => &self.placeholder_code, }; let border_color = theme.colors.border.clone(); let bg_color = theme.colors.background.clone(); let letter_boxes = displayed_code .split("") .filter(|c| !c.is_empty()) .map(|c| { text(String::from(c)) .bold() .text_2xl() .styled(move |it| { it.p_3() .font_family("CommitMono") .border_1() .border_color(border_color) .rounded_lg() .bg(bg_color) }) .when(is_loading_code, |it| it.opacity(0.5)) }) .collect::>(); div() .flex() .flex_col() .flex_1() .items_center() .justify_center() .gap_1p5() .child( div() .id("github-device-code-area") .flex() .flex_row() .items_center() .justify_center() .gap_1p5() .children(letter_boxes), ) .child( text(if is_loading_code { "Loading..." } else if self.is_opening_link { "Copied to clipboard! Opening the browser…" } else if self.has_copied_code { "Copied to clipboard!" } else { "Click to copy" }) .text_sm() .when(!self.is_opening_link && !self.has_copied_code, |it| { it.opacity(0.5) }), ) } fn header(&self) -> gpui::Div { div() .flex() .flex_col() .items_center() .gap_1p5() .child(text("Connect to GitHub").text_xl().bold()) .child(text( if self.has_copied_code { "You will be redirected to GitHub to authorize access.\nPaste the device code below into GitHub." } else { "You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub." } ).leading_tight().centered().opacity(0.8)) } } impl gpui::Render for GithubStepView { fn render( &mut self, _window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { let (can_go_next, header, body) = match self.user_query { None => (false, self.header(), self.device_code_area(cx)), Some(ref q) => { let user_query = read_query(q, cx); match user_query { QueryStatus::Loaded(user) => { (true, connected_header(), connected_body(user, cx)) } _ => (false, self.header(), self.device_code_area(cx)), } } }; div() .flex() .flex_col() .size_full() .px_4() .pt_12() .child(header) .child(body) .child( div().flex().flex_row().justify_end().w_full().pb_4().child( button("connect-to-github-next") .label(if can_go_next { "Next" } else { "Waiting for authentication" }) .on_click(cx.listener(|this, _, _, cx| { this.on_next_clicked(cx); })) .when(!can_go_next, |it| it.disabled()), ), ) } } fn connected_header() -> gpui::Div { div() .flex() .flex_col() .items_center() .gap_1p5() .child(text("Connected to GitHub!").text_xl().bold()) .child( text("Novem is now connected to your GitHub account.") .leading_tight() .centered() .opacity(0.8), ) } fn connected_body(user: &api::user::User, cx: &gpui::Context) -> gpui::Div { let theme = app::current_theme(cx); let display_name = user.name.as_deref().unwrap_or(&user.login).to_owned(); div() .flex() .flex_row() .flex_1() .w_full() .justify_center() .items_center() .px_8() .child( div() .flex() .flex_row() .justify_between() .items_center() .rounded_2xl() .border_1() .w_full() .border_color(theme.colors.surface_elevated) .p_4() .child( div() .flex() .flex_row() .gap_4() .items_center() .child(img(user.avatar_url.clone()).size_12().rounded_full()) .child( div() .flex() .flex_col() .child(text(display_name).medium().text_xl().leading_tight()) .child(text(user.login.clone()).text_sm().opacity(0.5)), ), ) .child( div() .rounded_full() .bg(theme.colors.accent) .p_1() .child(font_icon(FontIcon::Check, cx).size_4()), ), ) }