diff --git a/Cargo.toml b/Cargo.toml index 663b323..42c1ce5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,10 +5,12 @@ edition = "2024" [dependencies] anyhow = "1" +futures = "0.3.32" +futures-lite = "2.6.1" gpui = { version = "*" } paste = "1.0" rand = "0.10.1" -reqwest = "0.13.2" +reqwest = { version = "0.13.2", features = ["form"] } serde = "1.0.228" serde_json = "1.0.149" tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] } diff --git a/src/api.rs b/src/api.rs index d00004e..0af220b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,3 +1,8 @@ +use std::fmt::format; + +use reqwest::{Response, dns::Resolving}; +use serde::Deserialize; + use crate::query; pub(crate) mod auth; @@ -7,27 +12,56 @@ pub(crate) mod user; #[derive(Clone)] pub struct QueryContext { pub(crate) http: reqwest::Client, - pub(crate) auth: Option, + pub(crate) auth: Option, pub(crate) github: GithubCredentials, } #[derive(Clone)] -pub(crate) struct Auth { - pub(crate) access_token: &'static str, - pub(crate) refresh_token: &'static str, +pub(crate) struct AuthTokens { + pub(crate) access_token: String, } #[derive(Clone)] pub(crate) struct GithubCredentials { + pub(crate) base_url: &'static str, pub(crate) client_id: &'static str, } -pub enum Error { +#[derive(Debug)] +pub(crate) enum Error { Unauthenticated, + Github(GithubError), MalformedResponse(serde_json::Error), HttpError(reqwest::Error), } +#[derive(Debug, Deserialize)] +pub(crate) struct GithubError { + pub error: String, + pub error_description: Option, + pub error_uri: Option, +} + +impl QueryContext { + fn auth(&self) -> Result<&AuthTokens, Error> { + self.auth.as_ref().ok_or(Error::Unauthenticated) + } + + fn github_request( + &self, + method: reqwest::Method, + url: &str, + ) -> Result { + let auth = self.auth()?; + Ok(self + .http + .request(method, format!("{}{}", self.github.base_url, url)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2026-03-10") + .bearer_auth(&auth.access_token)) + } +} + impl query::Context for QueryContext {} impl From for Error { @@ -41,3 +75,19 @@ impl From for Error { Self::MalformedResponse(value) } } + +async fn parse_response(res: reqwest::Response) -> Result +where + T: serde::de::DeserializeOwned, +{ + let status = res.status().clone(); + let data = res.bytes().await?; + + if status.is_success() { + serde_json::from_slice::(&data).map_err(|e| e.into()) + } else { + serde_json::from_slice::(&data) + .map_err(|e| e.into()) + .and_then(|e| Err(Error::Github(e))) + } +} diff --git a/src/api/auth.rs b/src/api/auth.rs index 0423974..2cd538b 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,17 +1,24 @@ +use std::collections::HashMap; + use serde::Deserialize; -use crate::{api, query}; +use crate::{ + api, + query::{self, use_query}, +}; + +pub(crate) const DEVICE_LOGIN_FLOW_URL: &str = "https://github.com/login/device"; #[derive(Clone)] pub struct CreateDeviceCode; #[derive(Deserialize)] -pub struct DeviceCodeResponse { - device_code: String, - user_code: String, - vertification_uri: String, - expires_in: u16, - interval: u16, +pub(crate) struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub vertification_uri: Option, + pub expires_in: u16, + pub interval: u16, } impl query::QueryFn for CreateDeviceCode { @@ -24,17 +31,57 @@ impl query::QueryFn for CreateDeviceCode { } async fn run(&self, c: &Self::Context) -> Result { - let data = c + let res = c .http .post(format!( "https://github.com/login/device/code?client_id={}", c.github.client_id )) + .header("Accept", "application/json") .send() - .await? - .bytes() .await?; - serde_json::from_slice::(&data).map_err(|e| e.into()) + api::parse_response(res).await + } +} + +#[derive(Clone)] +pub struct RequestAccessToken { + pub device_code: String, +} + +#[derive(Deserialize)] +pub struct RequestAccessTokenResponse { + pub access_token: String, + pub token_type: String, + pub scope: String, +} + +impl query::QueryFn for RequestAccessToken { + type Data = RequestAccessTokenResponse; + type Error = api::Error; + type Context = api::QueryContext; + + fn key(&self) -> &'static str { + "auth.access_token" + } + + async fn run(&self, c: &Self::Context) -> Result { + let mut params = HashMap::new(); + params.insert("client_id", c.github.client_id); + params.insert("device_code", &self.device_code); + params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + + let res = c + .http + .post(format!( + "https://github.com/login/oauth/access_token?client_id={}&device_code={}", + c.github.client_id, self.device_code + )) + .form(¶ms) + .send() + .await?; + + api::parse_response(res).await } } diff --git a/src/api/user.rs b/src/api/user.rs index 82ee605..bc6609f 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -1,10 +1,23 @@ +use reqwest::Method; +use serde::Deserialize; + use crate::{api, query}; +#[derive(Debug, Deserialize)] +pub struct User { + pub login: String, + pub id: u64, + pub avatar_url: String, + pub html_url: String, + pub name: Option, + pub email: Option, +} + #[derive(Clone)] pub struct Fetch; impl query::QueryFn for Fetch { - type Data = api::Error; + type Data = User; type Error = api::Error; type Context = api::QueryContext; @@ -13,6 +26,7 @@ impl query::QueryFn for Fetch { } async fn run(&self, c: &Self::Context) -> Result { - Err(api::Error::Unauthenticated) + let res = c.github_request(Method::GET, "/user")?.send().await?; + api::parse_response(res).await } } diff --git a/src/app.rs b/src/app.rs index 5228aa6..4606c57 100644 --- a/src/app.rs +++ b/src/app.rs @@ -59,6 +59,6 @@ pub fn rng(cx: &mut gpui::App) -> &mut rand::prelude::ThreadRng { &mut cx.global_mut::().rng } -pub fn query_store<'a, E>(cx: &'a gpui::Context) -> &'a query::Store { +pub fn query_store(cx: &gpui::App) -> &query::Store { cx.global::>() } diff --git a/src/component/button.rs b/src/component/button.rs index 40c3b78..0680675 100644 --- a/src/component/button.rs +++ b/src/component/button.rs @@ -3,7 +3,7 @@ use gpui::{ StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, }; -use crate::{app, component::text::TextContent, theme}; +use crate::{app, component::text::TextContent}; #[derive(gpui::IntoElement)] pub struct Button { @@ -12,6 +12,7 @@ pub struct Button { leading: Option, trailing: Option, on_click: Option>, + enabled: bool, } pub fn button(id: impl Into) -> Button { @@ -21,6 +22,7 @@ pub fn button(id: impl Into) -> Button { leading: None, trailing: None, on_click: None, + enabled: true, } } @@ -47,6 +49,11 @@ impl Button { self.on_click = Some(Box::new(f)); self } + + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } } impl gpui::RenderOnce for Button { @@ -86,8 +93,9 @@ impl gpui::RenderOnce for Button { .px_2p5() .py_0p5() .children(children) - .when(self.on_click.is_some(), |div| { + .when(self.on_click.is_some() && self.enabled, |div| { div.on_click(self.on_click.unwrap()) }) + .when(!self.enabled, |div| div.opacity(0.5)) } } diff --git a/src/main.rs b/src/main.rs index 3484e23..96e91f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod component; mod dashboard; mod query; mod screen; +mod storage; mod theme; mod titlebar; @@ -29,16 +30,15 @@ fn main() { fn setup_application(cx: &mut gpui::App) { let window_bounds = gpui::Bounds::centered(None, size(px(800.), px(600.0)), cx); - let query_store = query::Store::new( - api::QueryContext { - http: reqwest::Client::new(), - auth: None, - github: api::GithubCredentials { - client_id: "Iv23liZD4bMQpGJICsR7", - }, + + let query_store = query::Store::new(api::QueryContext { + http: reqwest::Client::new(), + auth: None, + github: api::GithubCredentials { + base_url: "https://api.github.com", + client_id: "Iv23liZD4bMQpGJICsR7", }, - cx, - ); + }); let global = app::Global { safe_area: bounds(point(px(0.), px(0.)), size(px(72.), px(12.))), diff --git a/src/query.rs b/src/query.rs index 6581c0b..b29c616 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,9 +1,9 @@ use gpui::{AppContext, BorrowAppContext}; -use std::{any::Any, collections::HashMap, marker::PhantomData}; +use std::{any::Any, collections::HashMap, marker::PhantomData, ops::Deref}; -pub trait QueryFn: Clone + 'static { +pub(crate) trait QueryFn: Clone + 'static { type Data: 'static; - type Error: 'static; + type Error: std::fmt::Debug + 'static; type Context: Context; fn key(&self) -> &'static str; @@ -11,7 +11,13 @@ pub trait QueryFn: Clone + 'static { async fn run(&self, c: &Self::Context) -> Result; } -struct Query { +pub(crate) trait QueryAppContext: gpui::AppContext { + fn ready(value: T) -> Self::Result; + + fn map_result(result: Self::Result, f: impl FnOnce(T) -> U) -> Self::Result; +} + +pub(crate) struct Query { key: &'static str, data: QueryData, } @@ -51,7 +57,7 @@ where ent }); - cx.observe(&ent.raw, move |_, ent, cx| { + cx.observe(&ent, move |_, ent, cx| { let query = ent.read(cx); if matches!(query.data, QueryData::Stale) { cx.update_global::, _>(|store, cx| { @@ -62,15 +68,6 @@ where }) .detach(); - let cloned_ent = ent.clone(); - let query_context = cx.global::>().query_context.clone(); - cx.observe(&query_context, move |_, _, cx| { - cx.update_global::, _>(|store, cx| { - store.invalidate_query(&cloned_ent, cx); - }); - }) - .detach(); - ent } @@ -82,7 +79,7 @@ where { let ent = cx.update_global::, _>(|store, cx| store.entity_for(&query_fn, cx)); - cx.observe(&ent.raw, move |_, ent, cx| { + cx.observe(&ent, move |_, ent, cx| { let query = ent.read(cx); if matches!(query.data, QueryData::Stale) { cx.update_global::, _>(|store, cx| { @@ -93,37 +90,91 @@ where }) .detach(); - let cloned_ent = ent.clone(); - let query_context = cx.global::>().query_context.clone(); - cx.observe(&query_context, move |_, _, cx| { - cx.update_global::, _>(|store, cx| { - store.invalidate_query(&cloned_ent, cx); - }); - }) - .detach(); - ent } +pub async fn fetch_query(query_fn: F, cx: &mut gpui::AsyncApp) -> anyhow::Result> +where + F: QueryFn, +{ + let ent = cx.update(|cx| { + cx.update_global::, _>(|store, cx| store.ensure_query_data(&query_fn, cx)) + })?; + + enum WaitState { + Cached, + Waiting { + rx: futures::channel::oneshot::Receiver<()>, + sub: gpui::Subscription, + }, + } + + loop { + let wait_state = cx.update(|cx| { + let is_done = ent.read_with(cx, |query, _| { + matches!(query.data, QueryData::Some(_) | QueryData::Err(_)) + }); + if is_done { + WaitState::Cached + } else { + let (tx, rx) = futures::channel::oneshot::channel(); + let ent = ent.clone(); + let mut tx = Some(tx); + + let sub = cx.observe(&ent, move |ent, cx| { + let is_done = ent.read_with(cx, |query, _| { + matches!(query.data, QueryData::Some(_) | QueryData::Err(_)) + }); + if is_done && let Some(tx) = tx.take() { + tx.send(()); + } + }); + + WaitState::Waiting { rx, sub } + } + })?; + + match wait_state { + WaitState::Cached => { + return Ok(ent); + } + WaitState::Waiting { rx, sub } => { + let _sub = sub; + let _ = rx.await; + } + } + } +} + impl Entity where F: QueryFn, Store: gpui::Global, { - pub fn refetch(&self, cx: &mut gpui::Context) { + pub fn refetch(&self, cx: &mut gpui::Context) + where + E: 'static, + { cx.update_global::, _>(|store, cx| { store.invalidate_query(self, cx); }); } } -pub fn read_query<'a, F, T>( - query: &Entity, - cx: &'a gpui::Context, -) -> QueryStatus<'a, F::Data, F::Error> +impl Deref for Entity +where + F: QueryFn, +{ + type Target = gpui::Entity; + + fn deref(&self) -> &Self::Target { + &self.raw + } +} + +pub fn read_query<'a, F>(query: &Entity, cx: &'a gpui::App) -> QueryStatus<'a, F::Data, F::Error> where F: QueryFn, - T: 'static, { let state = query.raw.read(cx); @@ -143,45 +194,55 @@ where C: Context, { query_data: HashMap>, - query_context: gpui::Entity, + query_context: C, } impl Store where C: Context + 'static, { - pub fn new(ctx: C, cx: &mut gpui::App) -> Self { + pub fn new(ctx: C) -> Self { Self { - query_context: cx.new(|_| ctx), + query_context: ctx, query_data: std::collections::HashMap::new(), } } - fn entity_for(&mut self, query: &Q, cx: &mut gpui::Context) -> Entity - where - Q: QueryFn, - T: 'static, - { - let raw = self - .query_data - .entry(query.key().into()) - .or_insert_with(|| { - cx.new(|_| Query { - key: query.key(), - data: QueryData::Pending, - }) - }) - .clone(); - - Entity { - raw, - _marker: PhantomData, - } + pub(crate) fn update_query_context(&mut self, f: impl FnOnce(&mut C)) { + f(&mut self.query_context); } - fn ensure_query_data(&mut self, query: &Q, cx: &mut gpui::Context) -> Entity + fn entity_for(&mut self, query: &Q, cx: &mut CX) -> CX::Result> + where + Q: QueryFn, + CX: QueryAppContext, + { + if let Some(raw) = self.query_data.get(query.key()) { + return CX::ready(Entity { + raw: raw.clone(), + _marker: PhantomData, + }); + } + + let key = query.key(); + + CX::map_result( + cx.new(|_| Query { + key: query.key(), + data: QueryData::Pending, + }), + |raw| { + self.query_data.insert(key.into(), raw.clone()); + Entity { + raw, + _marker: PhantomData, + } + }, + ) + } + + fn ensure_query_data(&mut self, query: &Q, cx: &mut gpui::App) -> Entity where - T: 'static, Q: QueryFn, { let entity = self.entity_for(query, cx); @@ -191,20 +252,19 @@ where }); if should_execute { - self.execute_query(query, cx).detach(); + self.execute_query_detached(query, cx).detach(); } entity } - fn execute_query( + fn execute_query_detached( &mut self, query: &Q, - cx: &mut gpui::Context, + cx: &mut gpui::App, ) -> gpui::Task> where Q: QueryFn, - T: 'static, { let entity = self.entity_for(query, cx); @@ -216,14 +276,21 @@ where let q = query.clone(); let query_context = self.query_context.clone(); - cx.spawn(async move |_, cx| { - let c = query_context.read_with(cx, |c, _| c.clone())?; - let result = q.run(&c).await; + cx.spawn(async move |cx| { + println!("[query] {}", q.key()); + + let result = q.run(&query_context).await; entity.raw.update(cx, |state, cx| { state.data = match result { - Ok(data) => QueryData::Some(Box::new(data)), - Err(err) => QueryData::Err(Box::new(err)), + Ok(data) => { + println!("[query] OK {}", q.key()); + QueryData::Some(Box::new(data)) + } + Err(err) => { + println!("[query] ERR {:?}: {:?}", q.key(), err); + QueryData::Err(Box::new(err)) + } }; cx.notify(); })?; @@ -232,6 +299,34 @@ where }) } + async fn execute_query<'a, F>(&mut self, query_fn: &F, cx: &'a mut gpui::AsyncApp) -> Entity + where + F: QueryFn, + { + let entity = self.entity_for(query_fn, cx).unwrap(); + + entity.update(cx, |query, cx| { + query.data = QueryData::Loading; + cx.notify(); + }); + + let result = query_fn.run(&self.query_context).await; + + entity + .raw + .update(cx, |query, cx| { + query.data = match result { + Ok(data) => QueryData::Some(Box::new(data)), + Err(err) => QueryData::Err(Box::new(err)), + }; + cx.notify(); + true + }) + .unwrap(); + + entity + } + fn invalidate_query(&self, entity: &Entity, cx: &mut gpui::Context) where E: 'static, @@ -249,4 +344,34 @@ where } } +impl QueryAppContext for gpui::App { + fn ready(value: T) -> Self::Result { + value + } + + fn map_result(result: Self::Result, f: impl FnOnce(T) -> U) -> Self::Result { + f(result) + } +} + +impl QueryAppContext for gpui::AsyncApp { + fn ready(value: T) -> Self::Result { + Ok(value) + } + + fn map_result(result: Self::Result, f: impl FnOnce(T) -> U) -> Self::Result { + result.map(f) + } +} + +impl<'a, E> QueryAppContext for gpui::Context<'a, E> { + fn ready(value: T) -> Self::Result { + value + } + + fn map_result(result: Self::Result, f: impl FnOnce(T) -> U) -> Self::Result { + f(result) + } +} + impl gpui::Global for Store where C: Context + 'static {} diff --git a/src/screen/setup_wizard/github_step.rs b/src/screen/setup_wizard/github_step.rs index 8f3cbda..b9175c8 100644 --- a/src/screen/setup_wizard/github_step.rs +++ b/src/screen/setup_wizard/github_step.rs @@ -1,31 +1,95 @@ -use std::time::{Duration, Instant}; +use std::time::Duration; -use gpui::{AppContext, FontWeight, ParentElement, Styled, div, prelude::FluentBuilder}; +use futures_lite::StreamExt; +use gpui::{ + BorrowAppContext, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, Timer, + div, prelude::FluentBuilder, +}; use rand::RngExt; use crate::{ api, app, - component::text::text, - query::{self, QueryStatus, read_query, use_lazy_query}, + component::{button::button, text::text}, + query::{self, QueryStatus, fetch_query, read_query, use_query}, + storage, theme, }; pub(crate) struct GithubStepView { - last_tick: Instant, + has_opened_link: bool, placeholder_code: String, + create_device_code_query: query::Entity, + request_access_token_query: Option>, + user_query: Option>, } pub(crate) fn new(cx: &mut gpui::Context) -> GithubStepView { - GithubStepView { - last_tick: Instant::now(), + let mut view = GithubStepView { + has_opened_link: false, placeholder_code: "ABCDEFGH".to_owned(), - create_device_code_query: use_lazy_query(api::auth::CreateDeviceCode, cx), - } + + create_device_code_query: use_query(api::auth::CreateDeviceCode, cx), + request_access_token_query: None, + user_query: None, + }; + view.on_create(cx); + view } impl GithubStepView { const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + 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.has_opened_link = true; + this.begin_auth_flow(code, 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 { + 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) @@ -35,75 +99,195 @@ impl GithubStepView { }) .collect() } + + fn copy_device_code(&self, cx: &gpui::App) { + let query = read_query(&self.create_device_code_query, cx); + match query { + QueryStatus::Loaded(data) => { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(data.user_code.clone())); + } + _ => {} + } + } + + 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; + }; + + 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()); + }); + }); + + 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(); + + let r = 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 |weak, cx| { + Timer::after(Duration::from_secs(1)).await; + if let Ok(Some(query)) = + weak.read_with(cx, |this, _cx| this.request_access_token_query.clone()) + { + weak.update(cx, |_this, cx| { + query.refetch(cx); + }); + } + }) + .detach(); + } + } + + _ => {} + } + } } impl gpui::Render for GithubStepView { fn render( &mut self, - window: &mut gpui::Window, + _window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - let theme = app::current_theme(cx); - - let border_color = theme.colors.surface_elevated.clone(); - let bg_color = theme.colors.surface.clone(); - 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 now = Instant::now(); - let should_tick = now.duration_since(self.last_tick) >= Duration::from_millis(50); + let theme = app::current_theme(cx); - if is_loading_code { - cx.on_next_frame(window, move |this, _, cx| { - if should_tick { - this.placeholder_code = this.generate_random_code(cx); - this.last_tick = Instant::now(); - } - cx.notify(); - }); - } - - let letter_boxes = self - .placeholder_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::>(); + let displayed_code = match create_device_code_query { + QueryStatus::Loaded(data) => &data.user_code, + _ => &self.placeholder_code, + }; div() .flex() .flex_col() .size_full() .px_4() - .py_12() + .pt_12() .child(header()) .child( div() .flex() - .flex_row() + .flex_col() .flex_1() .items_center() .justify_center() .gap_1p5() - .children(letter_boxes), + .child( + device_code_area(displayed_code, is_loading_code, theme).on_click( + cx.listener(|this, _, _, cx| { + this.copy_device_code(cx); + }), + ), + ) + .child( + text(if is_loading_code { + "Loading..." + } else { + "Click to copy" + }) + .text_sm() + .opacity(0.5), + ), + ) + .child( + div().flex().flex_row().justify_end().w_full().pb_4().child( + button("connect-to-github-next") + .label("Next") + .when(is_loading_code, |it| it.disabled()), + ), ) } } +fn device_code_area( + code: &String, + is_loading: bool, + theme: &theme::Theme, +) -> gpui::Stateful { + let border_color = theme.colors.border.clone(); + let bg_color = theme.colors.background.clone(); + + let letter_boxes = 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, |it| it.opacity(0.5)) + }) + .collect::>(); + + div() + .id("github-device-code-area") + .flex() + .flex_row() + .items_center() + .justify_center() + .gap_1p5() + .children(letter_boxes) +} + fn header() -> impl gpui::IntoElement { div() .flex() diff --git a/src/screen/setup_wizard/screen.rs b/src/screen/setup_wizard/screen.rs index 55c630a..080e340 100644 --- a/src/screen/setup_wizard/screen.rs +++ b/src/screen/setup_wizard/screen.rs @@ -59,8 +59,6 @@ impl gpui::Render for Screen { let theme = app::current_theme(cx); div() - .id("awd") - .on_click(cx.listener(|a, b, c, d| {})) .flex() .flex_row() .items_center() diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..7c651e0 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,13 @@ +use crate::api; + +pub(crate) fn store_auth_tokens( + tokens: &api::AuthTokens, + user: &api::user::User, + cx: &gpui::App, +) -> gpui::Task> { + cx.write_credentials( + &format!("novem://github/github.com/user/{}", user.id), + &format!("{}", user.id), + tokens.access_token.as_bytes(), + ) +}