feat: impl setup restoration
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user