feat: impl setup restoration
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
1
src/asset/font_icon/arrow_right.svg
Normal file
1
src/asset/font_icon/arrow_right.svg
Normal 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 |
@@ -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;
|
||||||
|
|||||||
73
src/main.rs
73
src/main.rs
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
1
src/util/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub(crate) mod timeout;
|
||||||
13
src/util/timeout.rs
Normal file
13
src/util/timeout.rs
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user