feat: impl setup restoration

This commit is contained in:
2026-04-26 00:01:57 +01:00
parent a54cc84660
commit 8b28f3d67f
11 changed files with 397 additions and 130 deletions

View File

@@ -1,18 +1,45 @@
use std::ops::Deref;
use reqwest::Method;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use crate::{api, query};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[repr(transparent)]
pub struct Id(u64);
#[derive(Debug, Deserialize)]
pub struct User {
pub login: String,
pub id: u64,
pub id: Id,
pub avatar_url: String,
pub html_url: String,
pub name: Option<String>,
pub email: Option<String>,
}
impl Deref for Id {
type Target = u64;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<u64> for Id {
fn from(id: u64) -> Self {
Self(id)
}
}
impl std::fmt::Display for Id {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone)]
pub struct Fetch;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-icon lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -26,7 +26,7 @@ macro_rules! define_font_icons {
};
}
define_font_icons!(Check, ChevronDown, FolderGit, Github);
define_font_icons!(Check, ChevronDown, FolderGit, Github, ArrowRight);
pub fn font_icon<T>(icon: FontIcon, cx: &gpui::Context<T>) -> gpui::Svg {
let theme = cx.global::<app::Global>().current_theme;

View File

@@ -14,6 +14,13 @@ mod screen;
mod storage;
mod theme;
mod titlebar;
mod util;
enum Start {
FromScratch,
FromSetup(setup_wizard::StoredSetupState),
FromSaved,
}
fn main() {
// GPUI polls our async query futures, but reqwest relies on Tokio's
@@ -33,8 +40,6 @@ 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,
@@ -50,23 +55,55 @@ fn setup_application(cx: &mut gpui::App) {
rng: rand::rng(),
};
let top_left = global.safe_area.origin;
cx.set_global(global);
cx.set_global(query_store);
cx.open_window(
gpui::WindowOptions {
window_bounds: Some(gpui::WindowBounds::Windowed(window_bounds)),
titlebar: Some(gpui::TitlebarOptions {
appears_transparent: true,
traffic_light_position: Some(top_left + point(px(12.), px(12.))),
..Default::default()
}),
is_resizable: false,
..Default::default()
},
|window, cx| cx.new(|cx| setup_wizard::new(window, cx)),
)
.unwrap();
// TODO: handle failure
_ = storage::ensure_data_dir();
let start = resume_application_state(cx);
match start {
Start::FromScratch => {
let screen = setup_wizard::new();
_ = setup_wizard::open_window(screen, cx);
}
Start::FromSetup(state) => {
let screen = setup_wizard::from_saved(state);
_ = setup_wizard::open_window(screen, cx);
}
_ => {}
};
}
fn resume_application_state(cx: &mut gpui::App) -> Start {
let state = storage::load_persisted_state();
let Some(state) = state else {
return Start::FromScratch;
};
let auth_tokens = cx
.background_executor()
.block(storage::load_auth_tokens(cx, state.selected_account));
let Some(auth_tokens) = auth_tokens else {
return Start::FromScratch;
};
_ = cx.update_global::<query::Store<api::QueryContext>, _>(|store, _| {
store.update_query_context(|cx| {
cx.auth = Some(auth_tokens);
});
});
let setup_status = setup_wizard::read_setup_status();
println!("[main] setup status: {:?}", setup_status);
match setup_status {
setup_wizard::SetupStatus::NotStarted => Start::FromScratch,
setup_wizard::SetupStatus::InProgress(state) => Start::FromSetup(state),
setup_wizard::SetupStatus::Completed => Start::FromSaved,
}
}

View File

@@ -2,8 +2,8 @@ use std::time::Duration;
use futures_lite::StreamExt;
use gpui::{
BorrowAppContext, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, Timer,
div, img, prelude::FluentBuilder,
BorrowAppContext, InteractiveElement, ParentElement, Styled, Timer, div, img,
prelude::FluentBuilder,
};
use rand::RngExt;
@@ -14,27 +14,32 @@ use crate::{
font_icon::{FontIcon, font_icon},
text::text,
},
query::{self, QueryStatus, fetch_query, read_query, use_lazy_query, use_query},
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<api::auth::CreateDeviceCode>,
request_access_token_query: Option<query::Entity<api::auth::RequestAccessToken>>,
user_query: Option<query::Entity<api::user::Fetch>>,
on_success: Option<Box<dyn Fn(&mut gpui::App) + 'static>>,
on_success: Option<Box<dyn Fn(api::user::Id, &mut gpui::App) + 'static>>,
}
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> 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_lazy_query(api::auth::CreateDeviceCode, cx),
create_device_code_query: use_query(api::auth::CreateDeviceCode, cx),
request_access_token_query: None,
user_query: None,
@@ -47,7 +52,10 @@ pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> 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 {
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
}
@@ -64,9 +72,25 @@ impl GithubStepView {
};
if let Some(ref code) = code
&& !this.has_opened_link
&& !this.is_opening_link
{
this.has_opened_link = true;
this.begin_auth_flow(code, cx);
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();
}
})
@@ -113,14 +137,22 @@ 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 copy_device_code(&mut self, code: &str, cx: &mut gpui::Context<Self>) {
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<Self>) {
@@ -185,20 +217,11 @@ impl GithubStepView {
})
.unwrap_or_default();
let r = if let Some(task) = fut {
_ = if let Some(task) = fut {
task.await
} 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();
}
@@ -223,6 +246,16 @@ impl GithubStepView {
}
}
fn on_next_clicked(&mut self, cx: &mut gpui::Context<Self>) {
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<Self>) -> 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);
@@ -276,13 +309,35 @@ impl GithubStepView {
.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()
.opacity(0.5),
.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 {
@@ -291,17 +346,15 @@ impl gpui::Render for GithubStepView {
_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)),
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) => (connected_header(), connected_body(user, cx)),
_ => (header(), self.device_code_area(cx)),
QueryStatus::Loaded(user) => {
(true, connected_header(), connected_body(user, cx))
}
_ => (false, self.header(), self.device_code_area(cx)),
}
}
};
@@ -317,25 +370,20 @@ impl gpui::Render for GithubStepView {
.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()),
.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 header() -> gpui::Div {
div()
.flex()
.flex_col()
.items_center()
.gap_1p5()
.child(text("Connect to GitHub").text_xl().bold())
.child(text(
"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()

View File

@@ -3,14 +3,67 @@ mod screen;
mod storage;
mod welcome_step;
pub(crate) use screen::new;
use gpui::{AppContext, BorrowAppContext, point, px, size};
pub(crate) use screen::{from_saved, new};
use serde::{Deserialize, Serialize};
#[derive(PartialEq, Serialize, Deserialize)]
enum Step {
pub(crate) use crate::screen::setup_wizard::storage::{SetupStatus, StoredSetupState};
use crate::{app, screen::setup_wizard::screen::Screen};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) enum Step {
Welcome,
ConnectToGithub,
Customization,
}
const ALL_SETUP_STEPS: [Step; 3] = [Step::Welcome, Step::ConnectToGithub, Step::Customization];
pub fn read_setup_status() -> SetupStatus {
storage::read_setup_state()
}
pub fn open_window(screen: Screen, cx: &mut gpui::App) -> anyhow::Result<()> {
let (top_left, window_bounds) = cx.read_global::<app::Global, _>(|global, cx| {
(
global.safe_area.origin,
gpui::Bounds::centered(None, size(px(800.), px(600.0)), cx),
)
});
cx.open_window(
gpui::WindowOptions {
window_bounds: Some(gpui::WindowBounds::Windowed(window_bounds)),
titlebar: Some(gpui::TitlebarOptions {
appears_transparent: true,
traffic_light_position: Some(top_left + point(px(12.), px(12.))),
..Default::default()
}),
is_resizable: false,
..Default::default()
},
|window, cx| {
cx.new(|cx| {
cx.observe_window_appearance(window, |_, window, cx| {
cx.update_global::<app::Global, ()>(|global, cx| {
global.current_theme = window.appearance().into();
cx.notify();
});
})
.detach();
screen
})
},
)
.map(|_| ())
}
impl Step {
pub const fn order(&self) -> usize {
match self {
Step::Welcome => 0,
Step::ConnectToGithub => 1,
Step::Customization => 2,
}
}
}

View File

@@ -1,15 +1,17 @@
use gpui::{
AppContext, BorrowAppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder,
};
use gpui::{AppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder};
use crate::{
app,
component::text::{Text, text},
api, app,
component::{
font_icon::{FontIcon, font_icon},
text::text,
},
screen::setup_wizard::{
ALL_SETUP_STEPS, Step, github_step,
storage::{StoredSetupState, store_setup_state},
welcome_step::welcome_step,
},
storage,
};
pub(crate) struct Screen {
@@ -17,50 +19,50 @@ pub(crate) struct Screen {
github_step_view: Option<gpui::Entity<github_step::GithubStepView>>,
}
pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) -> Screen {
cx.observe_window_appearance(window, |_, window, cx| {
cx.update_global::<app::Global, ()>(|global, cx| {
global.current_theme = window.appearance().into();
cx.notify();
});
})
.detach();
pub(crate) fn new() -> Screen {
Screen {
current_step: Step::Welcome,
github_step_view: None,
}
}
pub(crate) fn from_saved(state: StoredSetupState) -> Screen {
println!("[setup] initializing setup from saved state: {:?}", state);
Screen {
current_step: state.step,
github_step_view: None,
}
}
impl Screen {
fn advance_to_next_step(&mut self, cx: &mut gpui::Context<Self>) {
match self.current_step {
Step::Welcome => {
self.init_github_step_view(cx);
self.current_step = Step::ConnectToGithub;
}
let next_step = match self.current_step {
Step::Welcome => Step::ConnectToGithub,
Step::ConnectToGithub => Step::Customization,
_ => panic!(),
};
self.current_step = next_step;
cx.notify();
}
Step::ConnectToGithub => {
let state = StoredSetupState {
step: Step::Customization,
};
fn save_setup_state(&mut self, state: StoredSetupState, cx: &mut gpui::Context<Self>) {
_ = cx.background_executor().block(store_setup_state(state));
}
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;
fn on_github_connected(&mut self, user_id: api::user::Id, cx: &mut gpui::Context<Self>) {
let state = StoredSetupState {
step: Step::Customization,
connected_user_id: Some(user_id),
};
self.save_setup_state(state, cx);
let _ = weak.update(cx, |this, cx| {
this.current_step = Step::Customization;
cx.notify();
});
})
.detach();
}
// TODO: handle state write error
_ = storage::update_persisted_state(|state| {
state.selected_account = user_id;
});
self.advance_to_next_step(cx);
_ => {}
}
cx.notify();
}
@@ -74,10 +76,9 @@ impl Screen {
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.on_success(move |user_id, app| {
_ = weak.update(app, |this, cx| {
this.on_github_connected(user_id, cx);
});
});
v
@@ -87,16 +88,37 @@ impl Screen {
}
}
fn step_list(&self) -> impl gpui::IntoElement {
let children: Vec<Text> = ALL_SETUP_STEPS
fn step_list(&self, cx: &gpui::Context<Self>) -> impl gpui::IntoElement {
let children: Vec<gpui::Div> = ALL_SETUP_STEPS
.iter()
.map(|step| {
.enumerate()
.map(|(i, 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())
let is_completed = i < self.current_step.order();
let is_current = self.current_step == *step;
div()
.flex()
.flex_row()
.items_center()
.gap_2p5()
.child(if is_completed {
font_icon(FontIcon::Check, cx)
.size_4()
.into_any_element()
.into_any_element()
} else {
div().size_4().into_any_element()
})
.child(
text(label)
.leading_tight()
.when(self.current_step == *step, |it| it.bold()),
)
.when(!is_current, |it| it.opacity(0.5))
})
.collect();
@@ -129,7 +151,7 @@ impl gpui::Render for Screen {
None => self.init_github_step_view(cx).clone().into_any_element(),
},
_ => panic!(),
Step::Customization => text("customization").into_any_element(),
};
let theme = app::current_theme(cx);
@@ -154,7 +176,7 @@ impl gpui::Render for Screen {
.bold()
.styled(|it| it.absolute().top_20().left_8()),
)
.child(self.step_list()),
.child(self.step_list(cx)),
)
.child(
div()

View File

@@ -1,15 +1,39 @@
use serde::{Deserialize, Serialize};
use crate::{screen::setup_wizard::Step, storage};
use crate::{api, screen::setup_wizard::Step, storage};
#[derive(Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct StoredSetupState {
pub(crate) step: Step,
pub(crate) connected_user_id: Option<api::user::Id>,
}
pub(crate) async fn store_setup_state(state: &StoredSetupState) -> anyhow::Result<()> {
let path = storage::data_dir_path();
let content = serde_json::to_string(state)?;
#[derive(Debug, Serialize, Deserialize)]
pub(crate) enum SetupStatus {
NotStarted,
InProgress(StoredSetupState),
Completed,
}
pub(crate) async fn store_setup_state(state: StoredSetupState) -> anyhow::Result<()> {
let path = storage::data_dir_path().join("setup.json");
let content = serde_json::to_string(&SetupStatus::InProgress(state))?;
tokio::fs::write(path, content).await?;
Ok(())
}
pub(crate) fn read_setup_state() -> SetupStatus {
let path = storage::data_dir_path().join("setup.json");
let Some(f) = std::fs::File::open(path)
.inspect_err(|e| println!("[setup] failed to open setup.json {}", e))
.ok()
else {
return SetupStatus::NotStarted;
};
serde_json::from_reader(f)
.inspect_err(|e| println!("[setup] failed to parse setup.json {}", e))
.ok()
.unwrap_or(SetupStatus::NotStarted)
}

View File

@@ -1,11 +1,20 @@
use serde::{Deserialize, Serialize};
use tokio::io;
use crate::api;
#[derive(Default, Serialize, Deserialize)]
pub(crate) struct PersistedState {
pub selected_account: api::user::Id,
}
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")
}
"macos" => std::env::home_dir()
.unwrap()
.join("Library")
.join("Application Support")
.join("novem"),
_ => unimplemented!(
"data_dir_path is unimplemented for OS: {}",
std::env::consts::OS
@@ -13,13 +22,45 @@ pub(crate) fn data_dir_path() -> std::path::PathBuf {
}
}
pub(crate) fn ensure_data_dir() -> std::io::Result<()> {
std::fs::create_dir_all(data_dir_path())
}
pub(crate) fn load_persisted_state() -> Option<PersistedState> {
let data_dir = data_dir_path();
let path = data_dir.join("state.json");
let f = std::fs::File::open(path).ok()?;
serde_json::from_reader::<_, PersistedState>(f).ok()
}
pub(crate) fn update_persisted_state(
patch: impl FnOnce(&mut PersistedState),
) -> anyhow::Result<()> {
let data_dir = data_dir_path().join("state.json");
let mut state = load_persisted_state().unwrap_or_default();
patch(&mut state);
let f = std::fs::File::create(data_dir)?;
serde_json::to_writer(f, &state).map_err(|e| e.into())
}
pub(crate) async fn load_auth_tokens(
cx: &gpui::App,
user_id: api::user::Id,
) -> Option<api::AuthTokens> {
cx.read_credentials(&format!("https://github.com/user/{}", user_id))
.await
.ok()?
.and_then(|(_, password)| String::from_utf8(password).ok())
.map(|access_token| api::AuthTokens { access_token })
}
pub(crate) fn store_auth_tokens(
tokens: &api::AuthTokens,
user: &api::user::User,
cx: &gpui::App,
) -> gpui::Task<anyhow::Result<()>> {
cx.write_credentials(
&format!("novem://github/github.com/user/{}", user.id),
&format!("https://github.com/user/{}", user.id),
&format!("{}", user.id),
tokens.access_token.as_bytes(),
)

1
src/util/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub(crate) mod timeout;

13
src/util/timeout.rs Normal file
View File

@@ -0,0 +1,13 @@
pub(crate) fn set_timeout<E>(
f: impl FnOnce(gpui::WeakEntity<E>, &mut gpui::AsyncApp) + Send + 'static,
duration: std::time::Duration,
cx: &mut gpui::Context<E>,
) where
E: 'static,
{
cx.spawn(async move |weak, cx| {
gpui::Timer::after(duration).await;
f(weak, cx);
})
.detach();
}