feat: connect to github step
This commit is contained in:
11
src/api.rs
11
src/api.rs
@@ -58,6 +58,7 @@ impl QueryContext {
|
|||||||
.request(method, format!("{}{}", self.github.base_url, url))
|
.request(method, format!("{}{}", self.github.base_url, url))
|
||||||
.header("Accept", "application/vnd.github+json")
|
.header("Accept", "application/vnd.github+json")
|
||||||
.header("X-GitHub-Api-Version", "2026-03-10")
|
.header("X-GitHub-Api-Version", "2026-03-10")
|
||||||
|
.header("User-Agent", "kennethnym")
|
||||||
.bearer_auth(&auth.access_token))
|
.bearer_auth(&auth.access_token))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,11 +84,19 @@ where
|
|||||||
let status = res.status().clone();
|
let status = res.status().clone();
|
||||||
let data = res.bytes().await?;
|
let data = res.bytes().await?;
|
||||||
|
|
||||||
|
println!("[query] RES {:?} {:?}", status, str::from_utf8(&data));
|
||||||
|
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
serde_json::from_slice::<T>(&data).map_err(|e| e.into())
|
serde_json::from_slice::<T>(&data).map_err(|e| e.into())
|
||||||
} else {
|
} else {
|
||||||
serde_json::from_slice::<GithubError>(&data)
|
serde_json::from_slice::<GithubError>(&data)
|
||||||
.map_err(|e| e.into())
|
.map_err(|e| e.into())
|
||||||
.and_then(|e| Err(Error::Github(e)))
|
.and_then(|e| {
|
||||||
|
println!(
|
||||||
|
"[api parse error] invalid json, received: {:?}",
|
||||||
|
str::from_utf8(&data),
|
||||||
|
);
|
||||||
|
Err(Error::Github(e))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub(crate) struct DeviceCodeResponse {
|
|||||||
pub user_code: String,
|
pub user_code: String,
|
||||||
pub vertification_uri: Option<String>,
|
pub vertification_uri: Option<String>,
|
||||||
pub expires_in: u16,
|
pub expires_in: u16,
|
||||||
|
|
||||||
|
// minimum number of seconds between polling for access token
|
||||||
pub interval: u16,
|
pub interval: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +80,32 @@ impl query::QueryFn for RequestAccessToken {
|
|||||||
"https://github.com/login/oauth/access_token?client_id={}&device_code={}",
|
"https://github.com/login/oauth/access_token?client_id={}&device_code={}",
|
||||||
c.github.client_id, self.device_code
|
c.github.client_id, self.device_code
|
||||||
))
|
))
|
||||||
|
.header("Accept", "application/json")
|
||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
let status = res.status();
|
||||||
|
|
||||||
api::parse_response(res).await
|
let data = res.bytes().await?;
|
||||||
|
|
||||||
|
println!("status: {:?}, data: {:?}", status, str::from_utf8(&data));
|
||||||
|
|
||||||
|
if status.is_success() {
|
||||||
|
// for device code flow, github returns error with 200 response code
|
||||||
|
// so the body is either a valid access token or an error
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&data)?;
|
||||||
|
let maybe_error = &json["error"];
|
||||||
|
if maybe_error.is_string() {
|
||||||
|
let error = serde_json::from_value::<api::GithubError>(json)?;
|
||||||
|
Err(api::Error::Github(error))
|
||||||
|
} else {
|
||||||
|
let res = serde_json::from_value::<RequestAccessTokenResponse>(json)?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice::<api::GithubError>(&data)
|
||||||
|
.map_err(|e| e.into())
|
||||||
|
.and_then(|e| Err(api::Error::Github(e)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/asset/font_icon/check.svg
Normal file
1
src/asset/font_icon/check.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"/></svg>
|
||||||
|
After Width: | Height: | Size: 261 B |
@@ -26,10 +26,9 @@ macro_rules! define_font_icons {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
define_font_icons!(ChevronDown, FolderGit, Github);
|
define_font_icons!(Check, ChevronDown, FolderGit, Github);
|
||||||
|
|
||||||
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;
|
||||||
println!("{}", icon_path(icon));
|
|
||||||
svg().path(icon_path(icon)).text_color(theme.colors.text)
|
svg().path(icon_path(icon)).text_color(theme.colors.text)
|
||||||
}
|
}
|
||||||
|
|||||||
94
src/http.rs
Normal file
94
src/http.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
use std::any::type_name;
|
||||||
|
|
||||||
|
use futures::{AsyncReadExt, FutureExt, future::BoxFuture};
|
||||||
|
use gpui::http_client::{AsyncBody, HttpClient, RedirectPolicy, Response, Url, http::HeaderValue};
|
||||||
|
|
||||||
|
pub(crate) struct Client {
|
||||||
|
client: reqwest::Client,
|
||||||
|
no_redirect_client: reqwest::Client,
|
||||||
|
runtime: tokio::runtime::Handle,
|
||||||
|
user_agent: Option<HeaderValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub(crate) fn new(client: reqwest::Client) -> Self {
|
||||||
|
Self::with_runtime(client, tokio::runtime::Handle::current())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_runtime(client: reqwest::Client, runtime: tokio::runtime::Handle) -> Self {
|
||||||
|
let no_redirect_client = reqwest::Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.expect("failed to build reqwest no-redirect client");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
no_redirect_client,
|
||||||
|
runtime,
|
||||||
|
user_agent: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Client> for Client {
|
||||||
|
fn from(client: reqwest::Client) -> Self {
|
||||||
|
Self::new(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClient for Client {
|
||||||
|
fn type_name(&self) -> &'static str {
|
||||||
|
type_name::<Self>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_agent(&self) -> Option<&HeaderValue> {
|
||||||
|
self.user_agent.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(
|
||||||
|
&self,
|
||||||
|
req: gpui::http_client::Request<AsyncBody>,
|
||||||
|
) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
|
||||||
|
let client = if matches!(
|
||||||
|
req.extensions().get::<RedirectPolicy>(),
|
||||||
|
Some(RedirectPolicy::NoFollow)
|
||||||
|
) {
|
||||||
|
self.no_redirect_client.clone()
|
||||||
|
} else {
|
||||||
|
self.client.clone()
|
||||||
|
};
|
||||||
|
let runtime = self.runtime.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let (parts, mut body) = req.into_parts();
|
||||||
|
let mut body_bytes = Vec::new();
|
||||||
|
body.read_to_end(&mut body_bytes).await?;
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.spawn(async move {
|
||||||
|
let method = reqwest::Method::from_bytes(parts.method.as_str().as_bytes())?;
|
||||||
|
let request = client
|
||||||
|
.request(method, parts.uri.to_string())
|
||||||
|
.headers(parts.headers)
|
||||||
|
.body(body_bytes);
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let version = response.version();
|
||||||
|
let headers = response.headers().clone();
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
let mut builder = Response::builder().status(status).version(version);
|
||||||
|
*builder.headers_mut().expect("missing response headers") = headers;
|
||||||
|
|
||||||
|
Ok(builder.body(AsyncBody::from(bytes.to_vec()))?)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy(&self) -> Option<&Url> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ mod asset;
|
|||||||
mod colors;
|
mod colors;
|
||||||
mod component;
|
mod component;
|
||||||
mod dashboard;
|
mod dashboard;
|
||||||
|
mod http;
|
||||||
mod query;
|
mod query;
|
||||||
mod screen;
|
mod screen;
|
||||||
mod storage;
|
mod storage;
|
||||||
@@ -25,6 +26,9 @@ fn main() {
|
|||||||
|
|
||||||
gpui::Application::new()
|
gpui::Application::new()
|
||||||
.with_assets(asset::Asset)
|
.with_assets(asset::Asset)
|
||||||
|
.with_http_client(std::sync::Arc::new(http::Client::new(
|
||||||
|
reqwest::Client::new(),
|
||||||
|
)))
|
||||||
.run(setup_application);
|
.run(setup_application);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ where
|
|||||||
matches!(query.data, QueryData::Some(_) | QueryData::Err(_))
|
matches!(query.data, QueryData::Some(_) | QueryData::Err(_))
|
||||||
});
|
});
|
||||||
if is_done && let Some(tx) = tx.take() {
|
if is_done && let Some(tx) = tx.take() {
|
||||||
tx.send(());
|
_ = tx.send(());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,8 +139,8 @@ where
|
|||||||
return Ok(ent);
|
return Ok(ent);
|
||||||
}
|
}
|
||||||
WaitState::Waiting { rx, sub } => {
|
WaitState::Waiting { rx, sub } => {
|
||||||
let _sub = sub;
|
_ = sub;
|
||||||
let _ = rx.await;
|
_ = rx.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ 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, StatefulInteractiveElement, Styled, Timer,
|
||||||
div, prelude::FluentBuilder,
|
div, img, prelude::FluentBuilder,
|
||||||
};
|
};
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api, app,
|
api, app,
|
||||||
component::{button::button, text::text},
|
component::{
|
||||||
query::{self, QueryStatus, fetch_query, read_query, use_query},
|
button::button,
|
||||||
storage, theme,
|
font_icon::{FontIcon, font_icon},
|
||||||
|
text::text,
|
||||||
|
},
|
||||||
|
query::{self, QueryStatus, fetch_query, read_query, use_lazy_query, use_query},
|
||||||
|
storage,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) struct GithubStepView {
|
pub(crate) struct GithubStepView {
|
||||||
@@ -21,6 +25,8 @@ pub(crate) struct GithubStepView {
|
|||||||
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>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
|
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
|
||||||
@@ -28,9 +34,11 @@ pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
|
|||||||
has_opened_link: false,
|
has_opened_link: false,
|
||||||
placeholder_code: "ABCDEFGH".to_owned(),
|
placeholder_code: "ABCDEFGH".to_owned(),
|
||||||
|
|
||||||
create_device_code_query: use_query(api::auth::CreateDeviceCode, cx),
|
create_device_code_query: use_lazy_query(api::auth::CreateDeviceCode, cx),
|
||||||
request_access_token_query: None,
|
request_access_token_query: None,
|
||||||
user_query: None,
|
user_query: None,
|
||||||
|
|
||||||
|
on_success: None,
|
||||||
};
|
};
|
||||||
view.on_create(cx);
|
view.on_create(cx);
|
||||||
view
|
view
|
||||||
@@ -39,6 +47,11 @@ 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 {
|
||||||
|
self.on_success = Some(Box::new(f));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
|
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
cx.observe(&self.create_device_code_query, |this, _, cx| {
|
cx.observe(&self.create_device_code_query, |this, _, cx| {
|
||||||
let code = {
|
let code = {
|
||||||
@@ -72,7 +85,7 @@ impl GithubStepView {
|
|||||||
if matches!(is_code_loaded, Ok(true) | Err(_)) {
|
if matches!(is_code_loaded, Ok(true) | Err(_)) {
|
||||||
timer.clear();
|
timer.clear();
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |this, cx| {
|
let _ = this.update(cx, |this, cx| {
|
||||||
this.placeholder_code = this.generate_random_code(cx);
|
this.placeholder_code = this.generate_random_code(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
@@ -135,6 +148,13 @@ impl GithubStepView {
|
|||||||
let Some(query) = &self.request_access_token_query else {
|
let Some(query) = &self.request_access_token_query else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let QueryStatus::Loaded(api::auth::DeviceCodeResponse { interval, .. }) =
|
||||||
|
read_query(&self.create_device_code_query, cx)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let poll_interval = u64::from(*interval);
|
||||||
|
|
||||||
match read_query(query, cx) {
|
match read_query(query, cx) {
|
||||||
QueryStatus::Loaded(data) => {
|
QueryStatus::Loaded(data) => {
|
||||||
@@ -148,6 +168,8 @@ impl GithubStepView {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.user_query = Some(use_query(api::user::Fetch, cx));
|
||||||
|
|
||||||
cx.spawn(async move |weak, cx| {
|
cx.spawn(async move |weak, cx| {
|
||||||
let ent = fetch_query(api::user::Fetch, cx).await;
|
let ent = fetch_query(api::user::Fetch, cx).await;
|
||||||
|
|
||||||
@@ -168,18 +190,27 @@ impl GithubStepView {
|
|||||||
} 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryStatus::Err(api::Error::Github(api::GithubError { error, .. })) => {
|
QueryStatus::Err(api::Error::Github(api::GithubError { error, .. })) => {
|
||||||
if error == "authorization_pending" {
|
if error == "authorization_pending" {
|
||||||
cx.spawn(async |weak, cx| {
|
cx.spawn(async move |weak, cx| {
|
||||||
Timer::after(Duration::from_secs(1)).await;
|
Timer::after(Duration::from_secs(poll_interval)).await;
|
||||||
if let Ok(Some(query)) =
|
if let Ok(Some(query)) =
|
||||||
weak.read_with(cx, |this, _cx| this.request_access_token_query.clone())
|
weak.read_with(cx, |this, _cx| this.request_access_token_query.clone())
|
||||||
{
|
{
|
||||||
weak.update(cx, |_this, cx| {
|
let _ = weak.update(cx, |_this, cx| {
|
||||||
query.refetch(cx);
|
query.refetch(cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -191,14 +222,8 @@ impl GithubStepView {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl gpui::Render for GithubStepView {
|
fn device_code_area(&self, cx: &mut gpui::Context<Self>) -> gpui::Div {
|
||||||
fn render(
|
|
||||||
&mut self,
|
|
||||||
_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 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);
|
||||||
|
|
||||||
@@ -209,38 +234,86 @@ impl gpui::Render for GithubStepView {
|
|||||||
_ => &self.placeholder_code,
|
_ => &self.placeholder_code,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let border_color = theme.colors.border.clone();
|
||||||
|
let bg_color = theme.colors.background.clone();
|
||||||
|
|
||||||
|
let letter_boxes = displayed_code
|
||||||
|
.split("")
|
||||||
|
.filter(|c| !c.is_empty())
|
||||||
|
.map(|c| {
|
||||||
|
text(String::from(c))
|
||||||
|
.bold()
|
||||||
|
.text_2xl()
|
||||||
|
.styled(move |it| {
|
||||||
|
it.p_3()
|
||||||
|
.font_family("CommitMono")
|
||||||
|
.border_1()
|
||||||
|
.border_color(border_color)
|
||||||
|
.rounded_lg()
|
||||||
|
.bg(bg_color)
|
||||||
|
})
|
||||||
|
.when(is_loading_code, |it| it.opacity(0.5))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("github-device-code-area")
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_1p5()
|
||||||
|
.children(letter_boxes),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
text(if is_loading_code {
|
||||||
|
"Loading..."
|
||||||
|
} else {
|
||||||
|
"Click to copy"
|
||||||
|
})
|
||||||
|
.text_sm()
|
||||||
|
.opacity(0.5),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl gpui::Render for GithubStepView {
|
||||||
|
fn render(
|
||||||
|
&mut self,
|
||||||
|
_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)),
|
||||||
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.size_full()
|
.size_full()
|
||||||
.px_4()
|
.px_4()
|
||||||
.pt_12()
|
.pt_12()
|
||||||
.child(header())
|
.child(header)
|
||||||
.child(
|
.child(body)
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.flex_1()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.gap_1p5()
|
|
||||||
.child(
|
|
||||||
device_code_area(displayed_code, is_loading_code, theme).on_click(
|
|
||||||
cx.listener(|this, _, _, cx| {
|
|
||||||
this.copy_device_code(cx);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
text(if is_loading_code {
|
|
||||||
"Loading..."
|
|
||||||
} else {
|
|
||||||
"Click to copy"
|
|
||||||
})
|
|
||||||
.text_sm()
|
|
||||||
.opacity(0.5),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.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")
|
||||||
@@ -251,44 +324,7 @@ impl gpui::Render for GithubStepView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn device_code_area(
|
fn header() -> gpui::Div {
|
||||||
code: &String,
|
|
||||||
is_loading: bool,
|
|
||||||
theme: &theme::Theme,
|
|
||||||
) -> gpui::Stateful<gpui::Div> {
|
|
||||||
let border_color = theme.colors.border.clone();
|
|
||||||
let bg_color = theme.colors.background.clone();
|
|
||||||
|
|
||||||
let letter_boxes = code
|
|
||||||
.split("")
|
|
||||||
.filter(|c| !c.is_empty())
|
|
||||||
.map(|c| {
|
|
||||||
text(String::from(c))
|
|
||||||
.bold()
|
|
||||||
.text_2xl()
|
|
||||||
.styled(move |it| {
|
|
||||||
it.p_3()
|
|
||||||
.font_family("CommitMono")
|
|
||||||
.border_1()
|
|
||||||
.border_color(border_color)
|
|
||||||
.rounded_lg()
|
|
||||||
.bg(bg_color)
|
|
||||||
})
|
|
||||||
.when(is_loading, |it| it.opacity(0.5))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
div()
|
|
||||||
.id("github-device-code-area")
|
|
||||||
.flex()
|
|
||||||
.flex_row()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.gap_1p5()
|
|
||||||
.children(letter_boxes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn header() -> impl gpui::IntoElement {
|
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
@@ -299,3 +335,67 @@ fn header() -> impl gpui::IntoElement {
|
|||||||
"You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub.",
|
"You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub.",
|
||||||
).leading_tight().centered().opacity(0.8))
|
).leading_tight().centered().opacity(0.8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connected_header() -> gpui::Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_center()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(text("Connected to GitHub!").text_xl().bold())
|
||||||
|
.child(
|
||||||
|
text("Novem is now connected to your GitHub account.")
|
||||||
|
.leading_tight()
|
||||||
|
.centered()
|
||||||
|
.opacity(0.8),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connected_body(user: &api::user::User, cx: &gpui::Context<GithubStepView>) -> gpui::Div {
|
||||||
|
let theme = app::current_theme(cx);
|
||||||
|
|
||||||
|
let display_name = user.name.as_deref().unwrap_or(&user.login).to_owned();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.justify_center()
|
||||||
|
.items_center()
|
||||||
|
.px_8()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.justify_between()
|
||||||
|
.items_center()
|
||||||
|
.rounded_2xl()
|
||||||
|
.border_1()
|
||||||
|
.w_full()
|
||||||
|
.border_color(theme.colors.surface_elevated)
|
||||||
|
.p_4()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap_4()
|
||||||
|
.items_center()
|
||||||
|
.child(img(user.avatar_url.clone()).size_12().rounded_full())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.child(text(display_name).medium().text_xl().leading_tight())
|
||||||
|
.child(text(user.login.clone()).text_sm().opacity(0.5)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.rounded_full()
|
||||||
|
.bg(theme.colors.accent)
|
||||||
|
.p_1()
|
||||||
|
.child(font_icon(FontIcon::Check, cx).size_4()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
mod github_step;
|
mod github_step;
|
||||||
mod screen;
|
mod screen;
|
||||||
|
mod storage;
|
||||||
mod welcome_step;
|
mod welcome_step;
|
||||||
|
|
||||||
pub(crate) use screen::new;
|
pub(crate) use screen::new;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(PartialEq, Serialize, Deserialize)]
|
||||||
|
enum Step {
|
||||||
|
Welcome,
|
||||||
|
ConnectToGithub,
|
||||||
|
Customization,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_SETUP_STEPS: [Step; 3] = [Step::Welcome, Step::ConnectToGithub, Step::Customization];
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext, BorrowAppContext, InteractiveElement, IntoElement, ParentElement,
|
AppContext, BorrowAppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder,
|
||||||
StatefulInteractiveElement, Styled, div,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app,
|
app,
|
||||||
component::text::text,
|
component::text::{Text, text},
|
||||||
screen::setup_wizard::{github_step, welcome_step::welcome_step},
|
screen::setup_wizard::{
|
||||||
|
ALL_SETUP_STEPS, Step, github_step,
|
||||||
|
storage::{StoredSetupState, store_setup_state},
|
||||||
|
welcome_step::welcome_step,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) struct Screen {
|
pub(crate) struct Screen {
|
||||||
current_step: Step,
|
current_step: Step,
|
||||||
github_step_view: gpui::Entity<github_step::GithubStepView>,
|
github_step_view: Option<gpui::Entity<github_step::GithubStepView>>,
|
||||||
}
|
|
||||||
|
|
||||||
enum Step {
|
|
||||||
Welcome,
|
|
||||||
ConnectToGithub,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) -> Screen {
|
pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) -> Screen {
|
||||||
@@ -30,16 +28,88 @@ pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) ->
|
|||||||
|
|
||||||
Screen {
|
Screen {
|
||||||
current_step: Step::Welcome,
|
current_step: Step::Welcome,
|
||||||
github_step_view: cx.new(|cx| github_step::new(cx)),
|
github_step_view: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screen {
|
impl Screen {
|
||||||
fn advance_to_next_step(&mut self) {
|
fn advance_to_next_step(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
match self.current_step {
|
match self.current_step {
|
||||||
Step::Welcome => self.current_step = Step::ConnectToGithub,
|
Step::Welcome => {
|
||||||
Step::ConnectToGithub => {}
|
self.init_github_step_view(cx);
|
||||||
|
self.current_step = Step::ConnectToGithub;
|
||||||
|
}
|
||||||
|
|
||||||
|
Step::ConnectToGithub => {
|
||||||
|
let state = StoredSetupState {
|
||||||
|
step: Step::Customization,
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
let _ = weak.update(cx, |this, cx| {
|
||||||
|
this.current_step = Step::Customization;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_github_step_view(
|
||||||
|
&mut self,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) -> &gpui::Entity<github_step::GithubStepView> {
|
||||||
|
match self.github_step_view {
|
||||||
|
Some(ref v) => v,
|
||||||
|
None => {
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
self.github_step_view.as_ref().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step_list(&self) -> impl gpui::IntoElement {
|
||||||
|
let children: Vec<Text> = ALL_SETUP_STEPS
|
||||||
|
.iter()
|
||||||
|
.map(|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())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_start()
|
||||||
|
.w_full()
|
||||||
|
.px_8()
|
||||||
|
.justify_center()
|
||||||
|
.gap_3()
|
||||||
|
.text_sm()
|
||||||
|
.children(children)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +121,15 @@ impl gpui::Render for Screen {
|
|||||||
) -> impl gpui::IntoElement {
|
) -> impl gpui::IntoElement {
|
||||||
let step_view = match self.current_step {
|
let step_view = match self.current_step {
|
||||||
Step::Welcome => welcome_step()
|
Step::Welcome => welcome_step()
|
||||||
.on_next(cx.listener(|this, _, _window, _cx| this.advance_to_next_step()))
|
.on_next(cx.listener(|this, _, _, cx| this.advance_to_next_step(cx)))
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
Step::ConnectToGithub => self.github_step_view.clone().into_any_element(),
|
|
||||||
|
Step::ConnectToGithub => match self.github_step_view {
|
||||||
|
Some(ref view) => view.clone().into_any_element(),
|
||||||
|
None => self.init_github_step_view(cx).clone().into_any_element(),
|
||||||
|
},
|
||||||
|
|
||||||
|
_ => panic!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let theme = app::current_theme(cx);
|
let theme = app::current_theme(cx);
|
||||||
@@ -78,7 +154,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(step_list(cx)),
|
.child(self.step_list()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -92,21 +168,3 @@ impl gpui::Render for Screen {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn step_list(cx: &gpui::Context<impl gpui::Render>) -> impl gpui::IntoElement {
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.items_start()
|
|
||||||
.w_full()
|
|
||||||
.px_8()
|
|
||||||
.justify_center()
|
|
||||||
.gap_3()
|
|
||||||
.text_sm()
|
|
||||||
.children(vec![
|
|
||||||
text("Welcome!"),
|
|
||||||
text("Connect to GitHub"),
|
|
||||||
text("Customize Novem"),
|
|
||||||
text("Complete!"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|||||||
15
src/screen/setup_wizard/storage.rs
Normal file
15
src/screen/setup_wizard/storage.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{screen::setup_wizard::Step, storage};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub(crate) struct StoredSetupState {
|
||||||
|
pub(crate) step: Step,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn store_setup_state(state: &StoredSetupState) -> anyhow::Result<()> {
|
||||||
|
let path = storage::data_dir_path();
|
||||||
|
let content = serde_json::to_string(state)?;
|
||||||
|
tokio::fs::write(path, content).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
use crate::api;
|
use crate::api;
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
_ => unimplemented!(
|
||||||
|
"data_dir_path is unimplemented for OS: {}",
|
||||||
|
std::env::consts::OS
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user