diff --git a/src/api/user.rs b/src/api/user.rs index bc6609f..5797e53 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -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, pub email: Option, } +impl Deref for Id { + type Target = u64; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From 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; diff --git a/src/asset/font_icon/arrow_right.svg b/src/asset/font_icon/arrow_right.svg new file mode 100644 index 0000000..0ecef1f --- /dev/null +++ b/src/asset/font_icon/arrow_right.svg @@ -0,0 +1 @@ + diff --git a/src/component/font_icon.rs b/src/component/font_icon.rs index c7c3d8e..550fd61 100644 --- a/src/component/font_icon.rs +++ b/src/component/font_icon.rs @@ -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(icon: FontIcon, cx: &gpui::Context) -> gpui::Svg { let theme = cx.global::().current_theme; diff --git a/src/main.rs b/src/main.rs index 6806574..dc61e7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::, _>(|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, + } } diff --git a/src/screen/setup_wizard/github_step.rs b/src/screen/setup_wizard/github_step.rs index 2ef9222..2fef575 100644 --- a/src/screen/setup_wizard/github_step.rs +++ b/src/screen/setup_wizard/github_step.rs @@ -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, request_access_token_query: Option>, user_query: Option>, - on_success: Option>, + on_success: Option>, } pub(crate) fn new(cx: &mut gpui::Context) -> 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 { 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) { + 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) { @@ -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) { + 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) -> 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, ) -> 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() diff --git a/src/screen/setup_wizard/mod.rs b/src/screen/setup_wizard/mod.rs index 3314314..42cd09d 100644 --- a/src/screen/setup_wizard/mod.rs +++ b/src/screen/setup_wizard/mod.rs @@ -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::(|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::(|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, + } + } +} diff --git a/src/screen/setup_wizard/screen.rs b/src/screen/setup_wizard/screen.rs index 1587f6e..98f493b 100644 --- a/src/screen/setup_wizard/screen.rs +++ b/src/screen/setup_wizard/screen.rs @@ -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>, } -pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context) -> Screen { - cx.observe_window_appearance(window, |_, window, cx| { - cx.update_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) { - 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) { + _ = 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) { + 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 = ALL_SETUP_STEPS + fn step_list(&self, cx: &gpui::Context) -> impl gpui::IntoElement { + let children: Vec = 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() diff --git a/src/screen/setup_wizard/storage.rs b/src/screen/setup_wizard/storage.rs index 6fdfb2f..f1577d0 100644 --- a/src/screen/setup_wizard/storage.rs +++ b/src/screen/setup_wizard/storage.rs @@ -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, } -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) +} diff --git a/src/storage.rs b/src/storage.rs index 6848190..d4d93f0 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -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 { + 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 { + 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> { 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(), ) diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..367793c --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1 @@ +pub(crate) mod timeout; diff --git a/src/util/timeout.rs b/src/util/timeout.rs new file mode 100644 index 0000000..4e4e289 --- /dev/null +++ b/src/util/timeout.rs @@ -0,0 +1,13 @@ +pub(crate) fn set_timeout( + f: impl FnOnce(gpui::WeakEntity, &mut gpui::AsyncApp) + Send + 'static, + duration: std::time::Duration, + cx: &mut gpui::Context, +) where + E: 'static, +{ + cx.spawn(async move |weak, cx| { + gpui::Timer::after(duration).await; + f(weak, cx); + }) + .detach(); +}