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 reqwest::Method;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::{api, query}; 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)] #[derive(Debug, Deserialize)]
pub struct User { pub struct User {
pub login: String, pub login: String,
pub id: u64, pub id: Id,
pub avatar_url: String, pub avatar_url: String,
pub html_url: String, pub html_url: String,
pub name: Option<String>, pub name: Option<String>,
pub email: 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)] #[derive(Clone)]
pub struct Fetch; 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 { 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;

View File

@@ -14,6 +14,13 @@ mod screen;
mod storage; mod storage;
mod theme; mod theme;
mod titlebar; mod titlebar;
mod util;
enum Start {
FromScratch,
FromSetup(setup_wizard::StoredSetupState),
FromSaved,
}
fn main() { fn main() {
// GPUI polls our async query futures, but reqwest relies on Tokio's // 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) { 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 { let query_store = query::Store::new(api::QueryContext {
http: reqwest::Client::new(), http: reqwest::Client::new(),
auth: None, auth: None,
@@ -50,23 +55,55 @@ fn setup_application(cx: &mut gpui::App) {
rng: rand::rng(), rng: rand::rng(),
}; };
let top_left = global.safe_area.origin;
cx.set_global(global); cx.set_global(global);
cx.set_global(query_store); cx.set_global(query_store);
cx.open_window( // TODO: handle failure
gpui::WindowOptions { _ = storage::ensure_data_dir();
window_bounds: Some(gpui::WindowBounds::Windowed(window_bounds)),
titlebar: Some(gpui::TitlebarOptions { let start = resume_application_state(cx);
appears_transparent: true,
traffic_light_position: Some(top_left + point(px(12.), px(12.))), match start {
..Default::default() Start::FromScratch => {
}), let screen = setup_wizard::new();
is_resizable: false, _ = setup_wizard::open_window(screen, cx);
..Default::default() }
},
|window, cx| cx.new(|cx| setup_wizard::new(window, cx)), Start::FromSetup(state) => {
) let screen = setup_wizard::from_saved(state);
.unwrap(); _ = 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 futures_lite::StreamExt;
use gpui::{ use gpui::{
BorrowAppContext, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, Timer, BorrowAppContext, InteractiveElement, ParentElement, Styled, Timer, div, img,
div, img, prelude::FluentBuilder, prelude::FluentBuilder,
}; };
use rand::RngExt; use rand::RngExt;
@@ -14,27 +14,32 @@ use crate::{
font_icon::{FontIcon, font_icon}, font_icon::{FontIcon, font_icon},
text::text, text::text,
}, },
query::{self, QueryStatus, fetch_query, read_query, use_lazy_query, use_query}, query::{self, QueryStatus, fetch_query, read_query, use_query},
storage, storage,
util::timeout::set_timeout,
}; };
pub(crate) struct GithubStepView { pub(crate) struct GithubStepView {
is_opening_link: bool,
has_opened_link: bool, has_opened_link: bool,
placeholder_code: String, placeholder_code: String,
has_copied_code: bool,
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>>, on_success: Option<Box<dyn Fn(api::user::Id, &mut gpui::App) + 'static>>,
} }
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView { pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
let mut view = GithubStepView { let mut view = GithubStepView {
is_opening_link: false,
has_opened_link: false, has_opened_link: false,
placeholder_code: "ABCDEFGH".to_owned(), 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, request_access_token_query: None,
user_query: None, user_query: None,
@@ -47,7 +52,10 @@ 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 { 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.on_success = Some(Box::new(f));
self self
} }
@@ -64,9 +72,25 @@ impl GithubStepView {
}; };
if let Some(ref code) = code if let Some(ref code) = code
&& !this.has_opened_link && !this.has_opened_link
&& !this.is_opening_link
{ {
this.has_opened_link = true; this.is_opening_link = true;
this.begin_auth_flow(code, cx); 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(); cx.notify();
} }
}) })
@@ -113,14 +137,22 @@ impl GithubStepView {
.collect() .collect()
} }
fn copy_device_code(&self, cx: &gpui::App) { fn copy_device_code(&mut self, code: &str, cx: &mut gpui::Context<Self>) {
let query = read_query(&self.create_device_code_query, cx); cx.write_to_clipboard(gpui::ClipboardItem::new_string(code.to_owned()));
match query {
QueryStatus::Loaded(data) => { self.has_copied_code = true;
cx.write_to_clipboard(gpui::ClipboardItem::new_string(data.user_code.clone())); 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>) { fn begin_auth_flow(&mut self, device_code: &str, cx: &mut gpui::Context<Self>) {
@@ -185,20 +217,11 @@ impl GithubStepView {
}) })
.unwrap_or_default(); .unwrap_or_default();
let r = if let Some(task) = fut { _ = if let Some(task) = fut {
task.await task.await
} 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();
} }
@@ -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 { 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 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);
@@ -276,13 +309,35 @@ impl GithubStepView {
.child( .child(
text(if is_loading_code { text(if is_loading_code {
"Loading..." "Loading..."
} else if self.is_opening_link {
"Copied to clipboard! Opening the browser…"
} else if self.has_copied_code {
"Copied to clipboard!"
} else { } else {
"Click to copy" "Click to copy"
}) })
.text_sm() .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 { impl gpui::Render for GithubStepView {
@@ -291,17 +346,15 @@ impl gpui::Render for GithubStepView {
_window: &mut gpui::Window, _window: &mut gpui::Window,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement { ) -> impl gpui::IntoElement {
let create_device_code_query = read_query(&self.create_device_code_query, cx); let (can_go_next, header, body) = match self.user_query {
None => (false, self.header(), self.device_code_area(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) => { Some(ref q) => {
let user_query = read_query(q, cx); let user_query = read_query(q, cx);
match user_query { match user_query {
QueryStatus::Loaded(user) => (connected_header(), connected_body(user, cx)), QueryStatus::Loaded(user) => {
_ => (header(), self.device_code_area(cx)), (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( .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")
.label("Next") .label(if can_go_next {
.when(is_loading_code, |it| it.disabled()), "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 { fn connected_header() -> gpui::Div {
div() div()
.flex() .flex()

View File

@@ -3,14 +3,67 @@ mod screen;
mod storage; mod storage;
mod welcome_step; 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}; use serde::{Deserialize, Serialize};
#[derive(PartialEq, Serialize, Deserialize)] pub(crate) use crate::screen::setup_wizard::storage::{SetupStatus, StoredSetupState};
enum Step { use crate::{app, screen::setup_wizard::screen::Screen};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) enum Step {
Welcome, Welcome,
ConnectToGithub, ConnectToGithub,
Customization, Customization,
} }
const ALL_SETUP_STEPS: [Step; 3] = [Step::Welcome, Step::ConnectToGithub, Step::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::{ use gpui::{AppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder};
AppContext, BorrowAppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder,
};
use crate::{ use crate::{
app, api, app,
component::text::{Text, text}, component::{
font_icon::{FontIcon, font_icon},
text::text,
},
screen::setup_wizard::{ screen::setup_wizard::{
ALL_SETUP_STEPS, Step, github_step, ALL_SETUP_STEPS, Step, github_step,
storage::{StoredSetupState, store_setup_state}, storage::{StoredSetupState, store_setup_state},
welcome_step::welcome_step, welcome_step::welcome_step,
}, },
storage,
}; };
pub(crate) struct Screen { pub(crate) struct Screen {
@@ -17,50 +19,50 @@ pub(crate) struct Screen {
github_step_view: Option<gpui::Entity<github_step::GithubStepView>>, github_step_view: Option<gpui::Entity<github_step::GithubStepView>>,
} }
pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) -> Screen { pub(crate) fn new() -> Screen {
cx.observe_window_appearance(window, |_, window, cx| {
cx.update_global::<app::Global, ()>(|global, cx| {
global.current_theme = window.appearance().into();
cx.notify();
});
})
.detach();
Screen { Screen {
current_step: Step::Welcome, current_step: Step::Welcome,
github_step_view: None, 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 { impl Screen {
fn advance_to_next_step(&mut self, cx: &mut gpui::Context<Self>) { fn advance_to_next_step(&mut self, cx: &mut gpui::Context<Self>) {
match self.current_step { let next_step = match self.current_step {
Step::Welcome => { Step::Welcome => Step::ConnectToGithub,
self.init_github_step_view(cx); Step::ConnectToGithub => Step::Customization,
self.current_step = Step::ConnectToGithub; _ => panic!(),
} };
self.current_step = next_step;
cx.notify();
}
Step::ConnectToGithub => { fn save_setup_state(&mut self, state: StoredSetupState, cx: &mut gpui::Context<Self>) {
let state = StoredSetupState { _ = cx.background_executor().block(store_setup_state(state));
step: Step::Customization, }
};
cx.spawn(async move |weak, cx| { fn on_github_connected(&mut self, user_id: api::user::Id, cx: &mut gpui::Context<Self>) {
// best effort in persisting setup state let state = StoredSetupState {
// if it fails, we still proceed to the next step step: Step::Customization,
// if the user quits the wizard, the state will be lost, which is not the end of the world connected_user_id: Some(user_id),
let _ = store_setup_state(&state).await; };
self.save_setup_state(state, cx);
let _ = weak.update(cx, |this, cx| { // TODO: handle state write error
this.current_step = Step::Customization; _ = storage::update_persisted_state(|state| {
cx.notify(); state.selected_account = user_id;
}); });
})
.detach(); self.advance_to_next_step(cx);
}
_ => {}
}
cx.notify(); cx.notify();
} }
@@ -74,10 +76,9 @@ impl Screen {
let weak = cx.weak_entity(); let weak = cx.weak_entity();
self.github_step_view = Some(cx.new(|cx| { self.github_step_view = Some(cx.new(|cx| {
let mut v = github_step::new(cx); let mut v = github_step::new(cx);
v.on_success(move |app| { v.on_success(move |user_id, app| {
let _ = weak.update(app, |this, cx| { _ = weak.update(app, |this, cx| {
this.advance_to_next_step(cx); this.on_github_connected(user_id, cx);
cx.notify();
}); });
}); });
v v
@@ -87,16 +88,37 @@ impl Screen {
} }
} }
fn step_list(&self) -> impl gpui::IntoElement { fn step_list(&self, cx: &gpui::Context<Self>) -> impl gpui::IntoElement {
let children: Vec<Text> = ALL_SETUP_STEPS let children: Vec<gpui::Div> = ALL_SETUP_STEPS
.iter() .iter()
.map(|step| { .enumerate()
.map(|(i, step)| {
let label = match step { let label = match step {
Step::Welcome => "Welcome!", Step::Welcome => "Welcome!",
Step::ConnectToGithub => "Connect to GitHub", Step::ConnectToGithub => "Connect to GitHub",
Step::Customization => "Customize Novem", 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(); .collect();
@@ -129,7 +151,7 @@ impl gpui::Render for Screen {
None => self.init_github_step_view(cx).clone().into_any_element(), 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); let theme = app::current_theme(cx);
@@ -154,7 +176,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(self.step_list()), .child(self.step_list(cx)),
) )
.child( .child(
div() div()

View File

@@ -1,15 +1,39 @@
use serde::{Deserialize, Serialize}; 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) struct StoredSetupState {
pub(crate) step: Step, pub(crate) step: Step,
pub(crate) connected_user_id: Option<api::user::Id>,
} }
pub(crate) async fn store_setup_state(state: &StoredSetupState) -> anyhow::Result<()> { #[derive(Debug, Serialize, Deserialize)]
let path = storage::data_dir_path(); pub(crate) enum SetupStatus {
let content = serde_json::to_string(state)?; 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?; tokio::fs::write(path, content).await?;
Ok(()) 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; 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 { pub(crate) fn data_dir_path() -> std::path::PathBuf {
match std::env::consts::OS { match std::env::consts::OS {
"macos" => { "macos" => std::env::home_dir()
let home = std::env::home_dir().unwrap(); .unwrap()
home.join("Library/Application Support/novem") .join("Library")
} .join("Application Support")
.join("novem"),
_ => unimplemented!( _ => unimplemented!(
"data_dir_path is unimplemented for OS: {}", "data_dir_path is unimplemented for OS: {}",
std::env::consts::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( pub(crate) fn store_auth_tokens(
tokens: &api::AuthTokens, tokens: &api::AuthTokens,
user: &api::user::User, user: &api::user::User,
cx: &gpui::App, cx: &gpui::App,
) -> gpui::Task<anyhow::Result<()>> { ) -> gpui::Task<anyhow::Result<()>> {
cx.write_credentials( cx.write_credentials(
&format!("novem://github/github.com/user/{}", user.id), &format!("https://github.com/user/{}", user.id),
&format!("{}", user.id), &format!("{}", user.id),
tokens.access_token.as_bytes(), 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();
}