feat: connect to github step

This commit is contained in:
2026-04-25 00:49:50 +01:00
parent a9f4d1d923
commit a54cc84660
12 changed files with 450 additions and 122 deletions

View File

@@ -58,6 +58,7 @@ impl QueryContext {
.request(method, format!("{}{}", self.github.base_url, url)) .request(method, format!("{}{}", self.github.base_url, url))
.header("Accept", "application/vnd.github+json") .header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2026-03-10") .header("X-GitHub-Api-Version", "2026-03-10")
.header("User-Agent", "kennethnym")
.bearer_auth(&auth.access_token)) .bearer_auth(&auth.access_token))
} }
} }
@@ -83,11 +84,19 @@ where
let status = res.status().clone(); let status = res.status().clone();
let data = res.bytes().await?; let data = res.bytes().await?;
println!("[query] RES {:?} {:?}", status, str::from_utf8(&data));
if status.is_success() { if status.is_success() {
serde_json::from_slice::<T>(&data).map_err(|e| e.into()) serde_json::from_slice::<T>(&data).map_err(|e| e.into())
} else { } else {
serde_json::from_slice::<GithubError>(&data) serde_json::from_slice::<GithubError>(&data)
.map_err(|e| e.into()) .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))
})
} }
} }

View File

@@ -18,6 +18,8 @@ pub(crate) struct DeviceCodeResponse {
pub user_code: String, pub user_code: String,
pub vertification_uri: Option<String>, pub vertification_uri: Option<String>,
pub expires_in: u16, pub expires_in: u16,
// minimum number of seconds between polling for access token
pub interval: u16, pub interval: u16,
} }
@@ -78,10 +80,32 @@ impl query::QueryFn for RequestAccessToken {
"https://github.com/login/oauth/access_token?client_id={}&device_code={}", "https://github.com/login/oauth/access_token?client_id={}&device_code={}",
c.github.client_id, self.device_code c.github.client_id, self.device_code
)) ))
.header("Accept", "application/json")
.form(&params) .form(&params)
.send() .send()
.await?; .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::<api::GithubError>(json)?;
Err(api::Error::Github(error))
} else {
let res = serde_json::from_value::<RequestAccessTokenResponse>(json)?;
Ok(res)
}
} else {
serde_json::from_slice::<api::GithubError>(&data)
.map_err(|e| e.into())
.and_then(|e| Err(api::Error::Github(e)))
}
} }
} }

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"/></svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@@ -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<T>(icon: FontIcon, cx: &gpui::Context<T>) -> gpui::Svg { pub fn font_icon<T>(icon: FontIcon, cx: &gpui::Context<T>) -> gpui::Svg {
let theme = cx.global::<app::Global>().current_theme; let theme = cx.global::<app::Global>().current_theme;
println!("{}", icon_path(icon));
svg().path(icon_path(icon)).text_color(theme.colors.text) svg().path(icon_path(icon)).text_color(theme.colors.text)
} }

94
src/http.rs Normal file
View File

@@ -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<HeaderValue>,
}
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<reqwest::Client> for Client {
fn from(client: reqwest::Client) -> Self {
Self::new(client)
}
}
impl HttpClient for Client {
fn type_name(&self) -> &'static str {
type_name::<Self>()
}
fn user_agent(&self) -> Option<&HeaderValue> {
self.user_agent.as_ref()
}
fn send(
&self,
req: gpui::http_client::Request<AsyncBody>,
) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
let client = if matches!(
req.extensions().get::<RedirectPolicy>(),
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
}
}

View File

@@ -8,6 +8,7 @@ mod asset;
mod colors; mod colors;
mod component; mod component;
mod dashboard; mod dashboard;
mod http;
mod query; mod query;
mod screen; mod screen;
mod storage; mod storage;
@@ -25,6 +26,9 @@ fn main() {
gpui::Application::new() gpui::Application::new()
.with_assets(asset::Asset) .with_assets(asset::Asset)
.with_http_client(std::sync::Arc::new(http::Client::new(
reqwest::Client::new(),
)))
.run(setup_application); .run(setup_application);
} }

View File

@@ -126,7 +126,7 @@ where
matches!(query.data, QueryData::Some(_) | QueryData::Err(_)) matches!(query.data, QueryData::Some(_) | QueryData::Err(_))
}); });
if is_done && let Some(tx) = tx.take() { if is_done && let Some(tx) = tx.take() {
tx.send(()); _ = tx.send(());
} }
}); });
@@ -139,8 +139,8 @@ where
return Ok(ent); return Ok(ent);
} }
WaitState::Waiting { rx, sub } => { WaitState::Waiting { rx, sub } => {
let _sub = sub; _ = sub;
let _ = rx.await; _ = rx.await;
} }
} }
} }

View File

@@ -3,15 +3,19 @@ use std::time::Duration;
use futures_lite::StreamExt; use futures_lite::StreamExt;
use gpui::{ use gpui::{
BorrowAppContext, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, Timer, BorrowAppContext, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, Timer,
div, prelude::FluentBuilder, div, img, prelude::FluentBuilder,
}; };
use rand::RngExt; use rand::RngExt;
use crate::{ use crate::{
api, app, api, app,
component::{button::button, text::text}, component::{
query::{self, QueryStatus, fetch_query, read_query, use_query}, button::button,
storage, theme, font_icon::{FontIcon, font_icon},
text::text,
},
query::{self, QueryStatus, fetch_query, read_query, use_lazy_query, use_query},
storage,
}; };
pub(crate) struct GithubStepView { pub(crate) struct GithubStepView {
@@ -21,6 +25,8 @@ pub(crate) struct GithubStepView {
create_device_code_query: query::Entity<api::auth::CreateDeviceCode>, create_device_code_query: query::Entity<api::auth::CreateDeviceCode>,
request_access_token_query: Option<query::Entity<api::auth::RequestAccessToken>>, request_access_token_query: Option<query::Entity<api::auth::RequestAccessToken>>,
user_query: Option<query::Entity<api::user::Fetch>>, user_query: Option<query::Entity<api::user::Fetch>>,
on_success: Option<Box<dyn Fn(&mut gpui::App) + 'static>>,
} }
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView { pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
@@ -28,9 +34,11 @@ pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
has_opened_link: false, has_opened_link: false,
placeholder_code: "ABCDEFGH".to_owned(), 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, request_access_token_query: None,
user_query: None, user_query: None,
on_success: None,
}; };
view.on_create(cx); view.on_create(cx);
view view
@@ -39,6 +47,11 @@ pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
impl GithubStepView { impl GithubStepView {
const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 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<Self>) { fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
cx.observe(&self.create_device_code_query, |this, _, cx| { cx.observe(&self.create_device_code_query, |this, _, cx| {
let code = { let code = {
@@ -72,7 +85,7 @@ impl GithubStepView {
if matches!(is_code_loaded, Ok(true) | Err(_)) { if matches!(is_code_loaded, Ok(true) | Err(_)) {
timer.clear(); timer.clear();
} else { } else {
this.update(cx, |this, cx| { let _ = this.update(cx, |this, cx| {
this.placeholder_code = this.generate_random_code(cx); this.placeholder_code = this.generate_random_code(cx);
cx.notify(); cx.notify();
}); });
@@ -135,6 +148,13 @@ impl GithubStepView {
let Some(query) = &self.request_access_token_query else { let Some(query) = &self.request_access_token_query else {
return; 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) { match read_query(query, cx) {
QueryStatus::Loaded(data) => { 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| { cx.spawn(async move |weak, cx| {
let ent = fetch_query(api::user::Fetch, cx).await; let ent = fetch_query(api::user::Fetch, cx).await;
@@ -168,18 +190,27 @@ impl GithubStepView {
} else { } else {
Err(anyhow::Error::msg("")) 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(); .detach();
} }
QueryStatus::Err(api::Error::Github(api::GithubError { error, .. })) => { QueryStatus::Err(api::Error::Github(api::GithubError { error, .. })) => {
if error == "authorization_pending" { if error == "authorization_pending" {
cx.spawn(async |weak, cx| { cx.spawn(async move |weak, cx| {
Timer::after(Duration::from_secs(1)).await; Timer::after(Duration::from_secs(poll_interval)).await;
if let Ok(Some(query)) = if let Ok(Some(query)) =
weak.read_with(cx, |this, _cx| this.request_access_token_query.clone()) 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); query.refetch(cx);
}); });
} }
@@ -191,14 +222,8 @@ impl GithubStepView {
_ => {} _ => {}
} }
} }
}
impl gpui::Render for GithubStepView { fn device_code_area(&self, cx: &mut gpui::Context<Self>) -> gpui::Div {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let create_device_code_query = read_query(&self.create_device_code_query, cx); 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 is_loading_code = matches!(create_device_code_query, QueryStatus::Loading);
@@ -209,38 +234,86 @@ impl gpui::Render for GithubStepView {
_ => &self.placeholder_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::<Vec<_>>();
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<Self>,
) -> 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() div()
.flex() .flex()
.flex_col() .flex_col()
.size_full() .size_full()
.px_4() .px_4()
.pt_12() .pt_12()
.child(header()) .child(header)
.child( .child(body)
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( .child(
div().flex().flex_row().justify_end().w_full().pb_4().child( div().flex().flex_row().justify_end().w_full().pb_4().child(
button("connect-to-github-next") button("connect-to-github-next")
@@ -251,44 +324,7 @@ impl gpui::Render for GithubStepView {
} }
} }
fn device_code_area( fn header() -> gpui::Div {
code: &String,
is_loading: bool,
theme: &theme::Theme,
) -> gpui::Stateful<gpui::Div> {
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::<Vec<_>>();
div()
.id("github-device-code-area")
.flex()
.flex_row()
.items_center()
.justify_center()
.gap_1p5()
.children(letter_boxes)
}
fn header() -> impl gpui::IntoElement {
div() div()
.flex() .flex()
.flex_col() .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.", "You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub.",
).leading_tight().centered().opacity(0.8)) ).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<GithubStepView>) -> 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()),
),
)
}

View File

@@ -1,5 +1,16 @@
mod github_step; mod github_step;
mod screen; mod screen;
mod storage;
mod welcome_step; mod welcome_step;
pub(crate) use screen::new; 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];

View File

@@ -1,22 +1,20 @@
use gpui::{ use gpui::{
AppContext, BorrowAppContext, InteractiveElement, IntoElement, ParentElement, AppContext, BorrowAppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder,
StatefulInteractiveElement, Styled, div,
}; };
use crate::{ use crate::{
app, app,
component::text::text, component::text::{Text, text},
screen::setup_wizard::{github_step, welcome_step::welcome_step}, screen::setup_wizard::{
ALL_SETUP_STEPS, Step, github_step,
storage::{StoredSetupState, store_setup_state},
welcome_step::welcome_step,
},
}; };
pub(crate) struct Screen { pub(crate) struct Screen {
current_step: Step, current_step: Step,
github_step_view: gpui::Entity<github_step::GithubStepView>, github_step_view: Option<gpui::Entity<github_step::GithubStepView>>,
}
enum Step {
Welcome,
ConnectToGithub,
} }
pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) -> Screen { pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) -> Screen {
@@ -30,16 +28,88 @@ pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) ->
Screen { Screen {
current_step: Step::Welcome, current_step: Step::Welcome,
github_step_view: cx.new(|cx| github_step::new(cx)), github_step_view: None,
} }
} }
impl Screen { impl Screen {
fn advance_to_next_step(&mut self) { fn advance_to_next_step(&mut self, cx: &mut gpui::Context<Self>) {
match self.current_step { match self.current_step {
Step::Welcome => self.current_step = Step::ConnectToGithub, Step::Welcome => {
Step::ConnectToGithub => {} 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<Self>,
) -> &gpui::Entity<github_step::GithubStepView> {
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<Text> = 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 { ) -> impl gpui::IntoElement {
let step_view = match self.current_step { let step_view = match self.current_step {
Step::Welcome => welcome_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(), .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); let theme = app::current_theme(cx);
@@ -78,7 +154,7 @@ impl gpui::Render for Screen {
.bold() .bold()
.styled(|it| it.absolute().top_20().left_8()), .styled(|it| it.absolute().top_20().left_8()),
) )
.child(step_list(cx)), .child(self.step_list()),
) )
.child( .child(
div() div()
@@ -92,21 +168,3 @@ impl gpui::Render for Screen {
) )
} }
} }
fn step_list(cx: &gpui::Context<impl gpui::Render>) -> 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!"),
])
}

View File

@@ -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(())
}

View File

@@ -1,5 +1,18 @@
use crate::api; 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( pub(crate) fn store_auth_tokens(
tokens: &api::AuthTokens, tokens: &api::AuthTokens,
user: &api::user::User, user: &api::user::User,