diff --git a/src/api.rs b/src/api.rs index 0af220b..bad29f1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -58,6 +58,7 @@ impl QueryContext { .request(method, format!("{}{}", self.github.base_url, url)) .header("Accept", "application/vnd.github+json") .header("X-GitHub-Api-Version", "2026-03-10") + .header("User-Agent", "kennethnym") .bearer_auth(&auth.access_token)) } } @@ -83,11 +84,19 @@ where let status = res.status().clone(); let data = res.bytes().await?; + println!("[query] RES {:?} {:?}", status, str::from_utf8(&data)); + 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))) + .and_then(|e| { + println!( + "[api parse error] invalid json, received: {:?}", + str::from_utf8(&data), + ); + Err(Error::Github(e)) + }) } } diff --git a/src/api/auth.rs b/src/api/auth.rs index 2cd538b..90d9fcc 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -18,6 +18,8 @@ pub(crate) struct DeviceCodeResponse { pub user_code: String, pub vertification_uri: Option, pub expires_in: u16, + + // minimum number of seconds between polling for access token pub interval: u16, } @@ -78,10 +80,32 @@ impl query::QueryFn for RequestAccessToken { "https://github.com/login/oauth/access_token?client_id={}&device_code={}", c.github.client_id, self.device_code )) + .header("Accept", "application/json") .form(¶ms) .send() .await?; + let status = res.status(); - api::parse_response(res).await + let data = res.bytes().await?; + + println!("status: {:?}, data: {:?}", status, str::from_utf8(&data)); + + if status.is_success() { + // for device code flow, github returns error with 200 response code + // so the body is either a valid access token or an error + let json: serde_json::Value = serde_json::from_slice(&data)?; + let maybe_error = &json["error"]; + if maybe_error.is_string() { + let error = serde_json::from_value::(json)?; + Err(api::Error::Github(error)) + } else { + let res = serde_json::from_value::(json)?; + Ok(res) + } + } else { + serde_json::from_slice::(&data) + .map_err(|e| e.into()) + .and_then(|e| Err(api::Error::Github(e))) + } } } diff --git a/src/asset/font_icon/check.svg b/src/asset/font_icon/check.svg new file mode 100644 index 0000000..3bba323 --- /dev/null +++ b/src/asset/font_icon/check.svg @@ -0,0 +1 @@ + diff --git a/src/component/font_icon.rs b/src/component/font_icon.rs index 071c849..c7c3d8e 100644 --- a/src/component/font_icon.rs +++ b/src/component/font_icon.rs @@ -26,10 +26,9 @@ macro_rules! define_font_icons { }; } -define_font_icons!(ChevronDown, FolderGit, Github); +define_font_icons!(Check, ChevronDown, FolderGit, Github); pub fn font_icon(icon: FontIcon, cx: &gpui::Context) -> gpui::Svg { let theme = cx.global::().current_theme; - println!("{}", icon_path(icon)); svg().path(icon_path(icon)).text_color(theme.colors.text) } diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..4442765 --- /dev/null +++ b/src/http.rs @@ -0,0 +1,94 @@ +use std::any::type_name; + +use futures::{AsyncReadExt, FutureExt, future::BoxFuture}; +use gpui::http_client::{AsyncBody, HttpClient, RedirectPolicy, Response, Url, http::HeaderValue}; + +pub(crate) struct Client { + client: reqwest::Client, + no_redirect_client: reqwest::Client, + runtime: tokio::runtime::Handle, + user_agent: Option, +} + +impl Client { + pub(crate) fn new(client: reqwest::Client) -> Self { + Self::with_runtime(client, tokio::runtime::Handle::current()) + } + + pub(crate) fn with_runtime(client: reqwest::Client, runtime: tokio::runtime::Handle) -> Self { + let no_redirect_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build reqwest no-redirect client"); + + Self { + client, + no_redirect_client, + runtime, + user_agent: None, + } + } +} + +impl From for Client { + fn from(client: reqwest::Client) -> Self { + Self::new(client) + } +} + +impl HttpClient for Client { + fn type_name(&self) -> &'static str { + type_name::() + } + + fn user_agent(&self) -> Option<&HeaderValue> { + self.user_agent.as_ref() + } + + fn send( + &self, + req: gpui::http_client::Request, + ) -> BoxFuture<'static, anyhow::Result>> { + let client = if matches!( + req.extensions().get::(), + Some(RedirectPolicy::NoFollow) + ) { + self.no_redirect_client.clone() + } else { + self.client.clone() + }; + let runtime = self.runtime.clone(); + + async move { + let (parts, mut body) = req.into_parts(); + let mut body_bytes = Vec::new(); + body.read_to_end(&mut body_bytes).await?; + + runtime + .spawn(async move { + let method = reqwest::Method::from_bytes(parts.method.as_str().as_bytes())?; + let request = client + .request(method, parts.uri.to_string()) + .headers(parts.headers) + .body(body_bytes); + + let response = request.send().await?; + let status = response.status(); + let version = response.version(); + let headers = response.headers().clone(); + let bytes = response.bytes().await?; + + let mut builder = Response::builder().status(status).version(version); + *builder.headers_mut().expect("missing response headers") = headers; + + Ok(builder.body(AsyncBody::from(bytes.to_vec()))?) + }) + .await? + } + .boxed() + } + + fn proxy(&self) -> Option<&Url> { + None + } +} diff --git a/src/main.rs b/src/main.rs index 96e91f6..6806574 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod asset; mod colors; mod component; mod dashboard; +mod http; mod query; mod screen; mod storage; @@ -25,6 +26,9 @@ fn main() { gpui::Application::new() .with_assets(asset::Asset) + .with_http_client(std::sync::Arc::new(http::Client::new( + reqwest::Client::new(), + ))) .run(setup_application); } diff --git a/src/query.rs b/src/query.rs index b29c616..ca43a2a 100644 --- a/src/query.rs +++ b/src/query.rs @@ -126,7 +126,7 @@ where matches!(query.data, QueryData::Some(_) | QueryData::Err(_)) }); if is_done && let Some(tx) = tx.take() { - tx.send(()); + _ = tx.send(()); } }); @@ -139,8 +139,8 @@ where return Ok(ent); } WaitState::Waiting { rx, sub } => { - let _sub = sub; - let _ = rx.await; + _ = sub; + _ = rx.await; } } } diff --git a/src/screen/setup_wizard/github_step.rs b/src/screen/setup_wizard/github_step.rs index b9175c8..2ef9222 100644 --- a/src/screen/setup_wizard/github_step.rs +++ b/src/screen/setup_wizard/github_step.rs @@ -3,15 +3,19 @@ use std::time::Duration; use futures_lite::StreamExt; use gpui::{ BorrowAppContext, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, Timer, - div, prelude::FluentBuilder, + div, img, prelude::FluentBuilder, }; use rand::RngExt; use crate::{ api, app, - component::{button::button, text::text}, - query::{self, QueryStatus, fetch_query, read_query, use_query}, - storage, theme, + component::{ + button::button, + font_icon::{FontIcon, font_icon}, + text::text, + }, + query::{self, QueryStatus, fetch_query, read_query, use_lazy_query, use_query}, + storage, }; pub(crate) struct GithubStepView { @@ -21,6 +25,8 @@ pub(crate) struct GithubStepView { 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 { @@ -28,9 +34,11 @@ pub(crate) fn new(cx: &mut gpui::Context) -> GithubStepView { has_opened_link: false, placeholder_code: "ABCDEFGH".to_owned(), - create_device_code_query: use_query(api::auth::CreateDeviceCode, cx), + create_device_code_query: use_lazy_query(api::auth::CreateDeviceCode, cx), request_access_token_query: None, user_query: None, + + on_success: None, }; view.on_create(cx); view @@ -39,6 +47,11 @@ pub(crate) fn new(cx: &mut gpui::Context) -> GithubStepView { impl GithubStepView { const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + pub(crate) fn on_success(&mut self, f: impl Fn(&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 = { @@ -72,7 +85,7 @@ impl GithubStepView { if matches!(is_code_loaded, Ok(true) | Err(_)) { timer.clear(); } else { - this.update(cx, |this, cx| { + let _ = this.update(cx, |this, cx| { this.placeholder_code = this.generate_random_code(cx); cx.notify(); }); @@ -135,6 +148,13 @@ impl GithubStepView { 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) => { @@ -148,6 +168,8 @@ impl GithubStepView { }); }); + 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; @@ -168,18 +190,27 @@ impl GithubStepView { } else { Err(anyhow::Error::msg("")) }; + + let _ = weak.update(cx, |this, cx| match r { + Ok(_) => { + if let Some(f) = this.on_success.as_ref() { + f(cx); + } + } + Err(e) => {} + }); }) .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; + 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()) { - weak.update(cx, |_this, cx| { + let _ = weak.update(cx, |_this, cx| { query.refetch(cx); }); } @@ -191,14 +222,8 @@ impl GithubStepView { _ => {} } } -} -impl gpui::Render for GithubStepView { - fn render( - &mut self, - _window: &mut gpui::Window, - cx: &mut gpui::Context, - ) -> impl gpui::IntoElement { + 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); @@ -209,38 +234,86 @@ impl gpui::Render for GithubStepView { _ => &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 { + "Click to copy" + }) + .text_sm() + .opacity(0.5), + ) + } +} + +impl gpui::Render for GithubStepView { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + 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 (header, body) = match self.user_query { + None => (header(), self.device_code_area(cx)), + Some(ref q) => { + let user_query = read_query(q, cx); + match user_query { + QueryStatus::Loaded(user) => (connected_header(), connected_body(user, cx)), + _ => (header(), self.device_code_area(cx)), + } + } + }; + div() .flex() .flex_col() .size_full() .px_4() .pt_12() - .child(header()) - .child( - div() - .flex() - .flex_col() - .flex_1() - .items_center() - .justify_center() - .gap_1p5() - .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(header) + .child(body) .child( div().flex().flex_row().justify_end().w_full().pb_4().child( button("connect-to-github-next") @@ -251,44 +324,7 @@ impl gpui::Render for GithubStepView { } } -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 { +fn header() -> gpui::Div { div() .flex() .flex_col() @@ -299,3 +335,67 @@ fn header() -> impl gpui::IntoElement { "You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub.", ).leading_tight().centered().opacity(0.8)) } + +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()), + ), + ) +} diff --git a/src/screen/setup_wizard/mod.rs b/src/screen/setup_wizard/mod.rs index 3ea0382..3314314 100644 --- a/src/screen/setup_wizard/mod.rs +++ b/src/screen/setup_wizard/mod.rs @@ -1,5 +1,16 @@ mod github_step; mod screen; +mod storage; mod welcome_step; pub(crate) use screen::new; +use serde::{Deserialize, Serialize}; + +#[derive(PartialEq, Serialize, Deserialize)] +enum Step { + Welcome, + ConnectToGithub, + Customization, +} + +const ALL_SETUP_STEPS: [Step; 3] = [Step::Welcome, Step::ConnectToGithub, Step::Customization]; diff --git a/src/screen/setup_wizard/screen.rs b/src/screen/setup_wizard/screen.rs index 080e340..1587f6e 100644 --- a/src/screen/setup_wizard/screen.rs +++ b/src/screen/setup_wizard/screen.rs @@ -1,22 +1,20 @@ use gpui::{ - AppContext, BorrowAppContext, InteractiveElement, IntoElement, ParentElement, - StatefulInteractiveElement, Styled, div, + AppContext, BorrowAppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder, }; use crate::{ app, - component::text::text, - screen::setup_wizard::{github_step, welcome_step::welcome_step}, + component::text::{Text, text}, + screen::setup_wizard::{ + ALL_SETUP_STEPS, Step, github_step, + storage::{StoredSetupState, store_setup_state}, + welcome_step::welcome_step, + }, }; pub(crate) struct Screen { current_step: Step, - github_step_view: gpui::Entity, -} - -enum Step { - Welcome, - ConnectToGithub, + github_step_view: Option>, } pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context) -> Screen { @@ -30,16 +28,88 @@ pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context) -> Screen { current_step: Step::Welcome, - github_step_view: cx.new(|cx| github_step::new(cx)), + github_step_view: None, } } impl Screen { - fn advance_to_next_step(&mut self) { + fn advance_to_next_step(&mut self, cx: &mut gpui::Context) { match self.current_step { - Step::Welcome => self.current_step = Step::ConnectToGithub, - Step::ConnectToGithub => {} + Step::Welcome => { + self.init_github_step_view(cx); + self.current_step = Step::ConnectToGithub; + } + + Step::ConnectToGithub => { + let state = StoredSetupState { + step: Step::Customization, + }; + + cx.spawn(async move |weak, cx| { + // best effort in persisting setup state + // if it fails, we still proceed to the next step + // if the user quits the wizard, the state will be lost, which is not the end of the world + let _ = store_setup_state(&state).await; + + let _ = weak.update(cx, |this, cx| { + this.current_step = Step::Customization; + cx.notify(); + }); + }) + .detach(); + } + + _ => {} } + cx.notify(); + } + + fn init_github_step_view( + &mut self, + cx: &mut gpui::Context, + ) -> &gpui::Entity { + match self.github_step_view { + Some(ref v) => v, + None => { + let weak = cx.weak_entity(); + self.github_step_view = Some(cx.new(|cx| { + let mut v = github_step::new(cx); + v.on_success(move |app| { + let _ = weak.update(app, |this, cx| { + this.advance_to_next_step(cx); + cx.notify(); + }); + }); + v + })); + self.github_step_view.as_ref().unwrap() + } + } + } + + fn step_list(&self) -> impl gpui::IntoElement { + let children: Vec = ALL_SETUP_STEPS + .iter() + .map(|step| { + let label = match step { + Step::Welcome => "Welcome!", + Step::ConnectToGithub => "Connect to GitHub", + Step::Customization => "Customize Novem", + }; + text(label).when(self.current_step == *step, |it| it.bold()) + }) + .collect(); + + div() + .flex() + .flex_col() + .items_start() + .w_full() + .px_8() + .justify_center() + .gap_3() + .text_sm() + .children(children) } } @@ -51,9 +121,15 @@ impl gpui::Render for Screen { ) -> impl gpui::IntoElement { let step_view = match self.current_step { Step::Welcome => welcome_step() - .on_next(cx.listener(|this, _, _window, _cx| this.advance_to_next_step())) + .on_next(cx.listener(|this, _, _, cx| this.advance_to_next_step(cx))) .into_any_element(), - Step::ConnectToGithub => self.github_step_view.clone().into_any_element(), + + Step::ConnectToGithub => match self.github_step_view { + Some(ref view) => view.clone().into_any_element(), + None => self.init_github_step_view(cx).clone().into_any_element(), + }, + + _ => panic!(), }; let theme = app::current_theme(cx); @@ -78,7 +154,7 @@ impl gpui::Render for Screen { .bold() .styled(|it| it.absolute().top_20().left_8()), ) - .child(step_list(cx)), + .child(self.step_list()), ) .child( div() @@ -92,21 +168,3 @@ impl gpui::Render for Screen { ) } } - -fn step_list(cx: &gpui::Context) -> impl gpui::IntoElement { - div() - .flex() - .flex_col() - .items_start() - .w_full() - .px_8() - .justify_center() - .gap_3() - .text_sm() - .children(vec![ - text("Welcome!"), - text("Connect to GitHub"), - text("Customize Novem"), - text("Complete!"), - ]) -} diff --git a/src/screen/setup_wizard/storage.rs b/src/screen/setup_wizard/storage.rs new file mode 100644 index 0000000..6fdfb2f --- /dev/null +++ b/src/screen/setup_wizard/storage.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use crate::{screen::setup_wizard::Step, storage}; + +#[derive(Serialize, Deserialize)] +pub(crate) struct StoredSetupState { + pub(crate) step: Step, +} + +pub(crate) async fn store_setup_state(state: &StoredSetupState) -> anyhow::Result<()> { + let path = storage::data_dir_path(); + let content = serde_json::to_string(state)?; + tokio::fs::write(path, content).await?; + Ok(()) +} diff --git a/src/storage.rs b/src/storage.rs index 7c651e0..6848190 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,5 +1,18 @@ use crate::api; +pub(crate) fn data_dir_path() -> std::path::PathBuf { + match std::env::consts::OS { + "macos" => { + let home = std::env::home_dir().unwrap(); + home.join("Library/Application Support/novem") + } + _ => unimplemented!( + "data_dir_path is unimplemented for OS: {}", + std::env::consts::OS + ), + } +} + pub(crate) fn store_auth_tokens( tokens: &api::AuthTokens, user: &api::user::User,