wip: connect to github

This commit is contained in:
2026-04-24 19:22:25 +01:00
parent b327648d31
commit a9f4d1d923
11 changed files with 587 additions and 146 deletions

View File

@@ -5,10 +5,12 @@ edition = "2024"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
futures = "0.3.32"
futures-lite = "2.6.1"
gpui = { version = "*" } gpui = { version = "*" }
paste = "1.0" paste = "1.0"
rand = "0.10.1" rand = "0.10.1"
reqwest = "0.13.2" reqwest = { version = "0.13.2", features = ["form"] }
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.149" serde_json = "1.0.149"
tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] } tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] }

View File

@@ -1,3 +1,8 @@
use std::fmt::format;
use reqwest::{Response, dns::Resolving};
use serde::Deserialize;
use crate::query; use crate::query;
pub(crate) mod auth; pub(crate) mod auth;
@@ -7,27 +12,56 @@ pub(crate) mod user;
#[derive(Clone)] #[derive(Clone)]
pub struct QueryContext { pub struct QueryContext {
pub(crate) http: reqwest::Client, pub(crate) http: reqwest::Client,
pub(crate) auth: Option<Auth>, pub(crate) auth: Option<AuthTokens>,
pub(crate) github: GithubCredentials, pub(crate) github: GithubCredentials,
} }
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct Auth { pub(crate) struct AuthTokens {
pub(crate) access_token: &'static str, pub(crate) access_token: String,
pub(crate) refresh_token: &'static str,
} }
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct GithubCredentials { pub(crate) struct GithubCredentials {
pub(crate) base_url: &'static str,
pub(crate) client_id: &'static str, pub(crate) client_id: &'static str,
} }
pub enum Error { #[derive(Debug)]
pub(crate) enum Error {
Unauthenticated, Unauthenticated,
Github(GithubError),
MalformedResponse(serde_json::Error), MalformedResponse(serde_json::Error),
HttpError(reqwest::Error), HttpError(reqwest::Error),
} }
#[derive(Debug, Deserialize)]
pub(crate) struct GithubError {
pub error: String,
pub error_description: Option<String>,
pub error_uri: Option<String>,
}
impl QueryContext {
fn auth(&self) -> Result<&AuthTokens, Error> {
self.auth.as_ref().ok_or(Error::Unauthenticated)
}
fn github_request(
&self,
method: reqwest::Method,
url: &str,
) -> Result<reqwest::RequestBuilder, Error> {
let auth = self.auth()?;
Ok(self
.http
.request(method, format!("{}{}", self.github.base_url, url))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2026-03-10")
.bearer_auth(&auth.access_token))
}
}
impl query::Context for QueryContext {} impl query::Context for QueryContext {}
impl From<reqwest::Error> for Error { impl From<reqwest::Error> for Error {
@@ -41,3 +75,19 @@ impl From<serde_json::Error> for Error {
Self::MalformedResponse(value) Self::MalformedResponse(value)
} }
} }
async fn parse_response<T>(res: reqwest::Response) -> Result<T, Error>
where
T: serde::de::DeserializeOwned,
{
let status = res.status().clone();
let data = res.bytes().await?;
if status.is_success() {
serde_json::from_slice::<T>(&data).map_err(|e| e.into())
} else {
serde_json::from_slice::<GithubError>(&data)
.map_err(|e| e.into())
.and_then(|e| Err(Error::Github(e)))
}
}

View File

@@ -1,17 +1,24 @@
use std::collections::HashMap;
use serde::Deserialize; use serde::Deserialize;
use crate::{api, query}; use crate::{
api,
query::{self, use_query},
};
pub(crate) const DEVICE_LOGIN_FLOW_URL: &str = "https://github.com/login/device";
#[derive(Clone)] #[derive(Clone)]
pub struct CreateDeviceCode; pub struct CreateDeviceCode;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DeviceCodeResponse { pub(crate) struct DeviceCodeResponse {
device_code: String, pub device_code: String,
user_code: String, pub user_code: String,
vertification_uri: String, pub vertification_uri: Option<String>,
expires_in: u16, pub expires_in: u16,
interval: u16, pub interval: u16,
} }
impl query::QueryFn for CreateDeviceCode { impl query::QueryFn for CreateDeviceCode {
@@ -24,17 +31,57 @@ impl query::QueryFn for CreateDeviceCode {
} }
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> { async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
let data = c let res = c
.http .http
.post(format!( .post(format!(
"https://github.com/login/device/code?client_id={}", "https://github.com/login/device/code?client_id={}",
c.github.client_id c.github.client_id
)) ))
.header("Accept", "application/json")
.send() .send()
.await?
.bytes()
.await?; .await?;
serde_json::from_slice::<DeviceCodeResponse>(&data).map_err(|e| e.into()) api::parse_response(res).await
}
}
#[derive(Clone)]
pub struct RequestAccessToken {
pub device_code: String,
}
#[derive(Deserialize)]
pub struct RequestAccessTokenResponse {
pub access_token: String,
pub token_type: String,
pub scope: String,
}
impl query::QueryFn for RequestAccessToken {
type Data = RequestAccessTokenResponse;
type Error = api::Error;
type Context = api::QueryContext;
fn key(&self) -> &'static str {
"auth.access_token"
}
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
let mut params = HashMap::new();
params.insert("client_id", c.github.client_id);
params.insert("device_code", &self.device_code);
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
let res = c
.http
.post(format!(
"https://github.com/login/oauth/access_token?client_id={}&device_code={}",
c.github.client_id, self.device_code
))
.form(&params)
.send()
.await?;
api::parse_response(res).await
} }
} }

View File

@@ -1,10 +1,23 @@
use reqwest::Method;
use serde::Deserialize;
use crate::{api, query}; use crate::{api, query};
#[derive(Debug, Deserialize)]
pub struct User {
pub login: String,
pub id: u64,
pub avatar_url: String,
pub html_url: String,
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct Fetch; pub struct Fetch;
impl query::QueryFn for Fetch { impl query::QueryFn for Fetch {
type Data = api::Error; type Data = User;
type Error = api::Error; type Error = api::Error;
type Context = api::QueryContext; type Context = api::QueryContext;
@@ -13,6 +26,7 @@ impl query::QueryFn for Fetch {
} }
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> { async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
Err(api::Error::Unauthenticated) let res = c.github_request(Method::GET, "/user")?.send().await?;
api::parse_response(res).await
} }
} }

View File

@@ -59,6 +59,6 @@ pub fn rng(cx: &mut gpui::App) -> &mut rand::prelude::ThreadRng {
&mut cx.global_mut::<Global>().rng &mut cx.global_mut::<Global>().rng
} }
pub fn query_store<'a, E>(cx: &'a gpui::Context<E>) -> &'a query::Store<api::QueryContext> { pub fn query_store(cx: &gpui::App) -> &query::Store<api::QueryContext> {
cx.global::<query::Store<api::QueryContext>>() cx.global::<query::Store<api::QueryContext>>()
} }

View File

@@ -3,7 +3,7 @@ use gpui::{
StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, StatefulInteractiveElement, Styled, div, prelude::FluentBuilder,
}; };
use crate::{app, component::text::TextContent, theme}; use crate::{app, component::text::TextContent};
#[derive(gpui::IntoElement)] #[derive(gpui::IntoElement)]
pub struct Button { pub struct Button {
@@ -12,6 +12,7 @@ pub struct Button {
leading: Option<gpui::Svg>, leading: Option<gpui::Svg>,
trailing: Option<gpui::Svg>, trailing: Option<gpui::Svg>,
on_click: Option<Box<dyn Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App)>>, on_click: Option<Box<dyn Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App)>>,
enabled: bool,
} }
pub fn button(id: impl Into<gpui::ElementId>) -> Button { pub fn button(id: impl Into<gpui::ElementId>) -> Button {
@@ -21,6 +22,7 @@ pub fn button(id: impl Into<gpui::ElementId>) -> Button {
leading: None, leading: None,
trailing: None, trailing: None,
on_click: None, on_click: None,
enabled: true,
} }
} }
@@ -47,6 +49,11 @@ impl Button {
self.on_click = Some(Box::new(f)); self.on_click = Some(Box::new(f));
self self
} }
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
} }
impl gpui::RenderOnce for Button { impl gpui::RenderOnce for Button {
@@ -86,8 +93,9 @@ impl gpui::RenderOnce for Button {
.px_2p5() .px_2p5()
.py_0p5() .py_0p5()
.children(children) .children(children)
.when(self.on_click.is_some(), |div| { .when(self.on_click.is_some() && self.enabled, |div| {
div.on_click(self.on_click.unwrap()) div.on_click(self.on_click.unwrap())
}) })
.when(!self.enabled, |div| div.opacity(0.5))
} }
} }

View File

@@ -10,6 +10,7 @@ mod component;
mod dashboard; mod dashboard;
mod query; mod query;
mod screen; mod screen;
mod storage;
mod theme; mod theme;
mod titlebar; mod titlebar;
@@ -29,16 +30,15 @@ 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 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,
github: api::GithubCredentials { github: api::GithubCredentials {
client_id: "Iv23liZD4bMQpGJICsR7", base_url: "https://api.github.com",
}, client_id: "Iv23liZD4bMQpGJICsR7",
}, },
cx, });
);
let global = app::Global { let global = app::Global {
safe_area: bounds(point(px(0.), px(0.)), size(px(72.), px(12.))), safe_area: bounds(point(px(0.), px(0.)), size(px(72.), px(12.))),

View File

@@ -1,9 +1,9 @@
use gpui::{AppContext, BorrowAppContext}; use gpui::{AppContext, BorrowAppContext};
use std::{any::Any, collections::HashMap, marker::PhantomData}; use std::{any::Any, collections::HashMap, marker::PhantomData, ops::Deref};
pub trait QueryFn: Clone + 'static { pub(crate) trait QueryFn: Clone + 'static {
type Data: 'static; type Data: 'static;
type Error: 'static; type Error: std::fmt::Debug + 'static;
type Context: Context; type Context: Context;
fn key(&self) -> &'static str; fn key(&self) -> &'static str;
@@ -11,7 +11,13 @@ pub trait QueryFn: Clone + 'static {
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error>; async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error>;
} }
struct Query { pub(crate) trait QueryAppContext: gpui::AppContext {
fn ready<T>(value: T) -> Self::Result<T>;
fn map_result<T, U>(result: Self::Result<T>, f: impl FnOnce(T) -> U) -> Self::Result<U>;
}
pub(crate) struct Query {
key: &'static str, key: &'static str,
data: QueryData, data: QueryData,
} }
@@ -51,7 +57,7 @@ where
ent ent
}); });
cx.observe(&ent.raw, move |_, ent, cx| { cx.observe(&ent, move |_, ent, cx| {
let query = ent.read(cx); let query = ent.read(cx);
if matches!(query.data, QueryData::Stale) { if matches!(query.data, QueryData::Stale) {
cx.update_global::<Store<F::Context>, _>(|store, cx| { cx.update_global::<Store<F::Context>, _>(|store, cx| {
@@ -62,15 +68,6 @@ where
}) })
.detach(); .detach();
let cloned_ent = ent.clone();
let query_context = cx.global::<Store<F::Context>>().query_context.clone();
cx.observe(&query_context, move |_, _, cx| {
cx.update_global::<Store<F::Context>, _>(|store, cx| {
store.invalidate_query(&cloned_ent, cx);
});
})
.detach();
ent ent
} }
@@ -82,7 +79,7 @@ where
{ {
let ent = cx.update_global::<Store<F::Context>, _>(|store, cx| store.entity_for(&query_fn, cx)); let ent = cx.update_global::<Store<F::Context>, _>(|store, cx| store.entity_for(&query_fn, cx));
cx.observe(&ent.raw, move |_, ent, cx| { cx.observe(&ent, move |_, ent, cx| {
let query = ent.read(cx); let query = ent.read(cx);
if matches!(query.data, QueryData::Stale) { if matches!(query.data, QueryData::Stale) {
cx.update_global::<Store<F::Context>, _>(|store, cx| { cx.update_global::<Store<F::Context>, _>(|store, cx| {
@@ -93,37 +90,91 @@ where
}) })
.detach(); .detach();
let cloned_ent = ent.clone();
let query_context = cx.global::<Store<F::Context>>().query_context.clone();
cx.observe(&query_context, move |_, _, cx| {
cx.update_global::<Store<F::Context>, _>(|store, cx| {
store.invalidate_query(&cloned_ent, cx);
});
})
.detach();
ent ent
} }
pub async fn fetch_query<F>(query_fn: F, cx: &mut gpui::AsyncApp) -> anyhow::Result<Entity<F>>
where
F: QueryFn,
{
let ent = cx.update(|cx| {
cx.update_global::<Store<F::Context>, _>(|store, cx| store.ensure_query_data(&query_fn, cx))
})?;
enum WaitState {
Cached,
Waiting {
rx: futures::channel::oneshot::Receiver<()>,
sub: gpui::Subscription,
},
}
loop {
let wait_state = cx.update(|cx| {
let is_done = ent.read_with(cx, |query, _| {
matches!(query.data, QueryData::Some(_) | QueryData::Err(_))
});
if is_done {
WaitState::Cached
} else {
let (tx, rx) = futures::channel::oneshot::channel();
let ent = ent.clone();
let mut tx = Some(tx);
let sub = cx.observe(&ent, move |ent, cx| {
let is_done = ent.read_with(cx, |query, _| {
matches!(query.data, QueryData::Some(_) | QueryData::Err(_))
});
if is_done && let Some(tx) = tx.take() {
tx.send(());
}
});
WaitState::Waiting { rx, sub }
}
})?;
match wait_state {
WaitState::Cached => {
return Ok(ent);
}
WaitState::Waiting { rx, sub } => {
let _sub = sub;
let _ = rx.await;
}
}
}
}
impl<F> Entity<F> impl<F> Entity<F>
where where
F: QueryFn, F: QueryFn,
Store<F::Context>: gpui::Global, Store<F::Context>: gpui::Global,
{ {
pub fn refetch(&self, cx: &mut gpui::Context<F::Context>) { pub fn refetch<E>(&self, cx: &mut gpui::Context<E>)
where
E: 'static,
{
cx.update_global::<Store<F::Context>, _>(|store, cx| { cx.update_global::<Store<F::Context>, _>(|store, cx| {
store.invalidate_query(self, cx); store.invalidate_query(self, cx);
}); });
} }
} }
pub fn read_query<'a, F, T>( impl<F> Deref for Entity<F>
query: &Entity<F>, where
cx: &'a gpui::Context<T>, F: QueryFn,
) -> QueryStatus<'a, F::Data, F::Error> {
type Target = gpui::Entity<Query>;
fn deref(&self) -> &Self::Target {
&self.raw
}
}
pub fn read_query<'a, F>(query: &Entity<F>, cx: &'a gpui::App) -> QueryStatus<'a, F::Data, F::Error>
where where
F: QueryFn, F: QueryFn,
T: 'static,
{ {
let state = query.raw.read(cx); let state = query.raw.read(cx);
@@ -143,45 +194,55 @@ where
C: Context, C: Context,
{ {
query_data: HashMap<String, gpui::Entity<Query>>, query_data: HashMap<String, gpui::Entity<Query>>,
query_context: gpui::Entity<C>, query_context: C,
} }
impl<C> Store<C> impl<C> Store<C>
where where
C: Context + 'static, C: Context + 'static,
{ {
pub fn new(ctx: C, cx: &mut gpui::App) -> Self { pub fn new(ctx: C) -> Self {
Self { Self {
query_context: cx.new(|_| ctx), query_context: ctx,
query_data: std::collections::HashMap::new(), query_data: std::collections::HashMap::new(),
} }
} }
fn entity_for<Q, T>(&mut self, query: &Q, cx: &mut gpui::Context<T>) -> Entity<Q> pub(crate) fn update_query_context(&mut self, f: impl FnOnce(&mut C)) {
where f(&mut self.query_context);
Q: QueryFn<Context = C>,
T: 'static,
{
let raw = self
.query_data
.entry(query.key().into())
.or_insert_with(|| {
cx.new(|_| Query {
key: query.key(),
data: QueryData::Pending,
})
})
.clone();
Entity {
raw,
_marker: PhantomData,
}
} }
fn ensure_query_data<Q, T>(&mut self, query: &Q, cx: &mut gpui::Context<T>) -> Entity<Q> fn entity_for<Q, CX>(&mut self, query: &Q, cx: &mut CX) -> CX::Result<Entity<Q>>
where
Q: QueryFn<Context = C>,
CX: QueryAppContext,
{
if let Some(raw) = self.query_data.get(query.key()) {
return CX::ready(Entity {
raw: raw.clone(),
_marker: PhantomData,
});
}
let key = query.key();
CX::map_result(
cx.new(|_| Query {
key: query.key(),
data: QueryData::Pending,
}),
|raw| {
self.query_data.insert(key.into(), raw.clone());
Entity {
raw,
_marker: PhantomData,
}
},
)
}
fn ensure_query_data<Q>(&mut self, query: &Q, cx: &mut gpui::App) -> Entity<Q>
where where
T: 'static,
Q: QueryFn<Context = C>, Q: QueryFn<Context = C>,
{ {
let entity = self.entity_for(query, cx); let entity = self.entity_for(query, cx);
@@ -191,20 +252,19 @@ where
}); });
if should_execute { if should_execute {
self.execute_query(query, cx).detach(); self.execute_query_detached(query, cx).detach();
} }
entity entity
} }
fn execute_query<Q, T>( fn execute_query_detached<Q>(
&mut self, &mut self,
query: &Q, query: &Q,
cx: &mut gpui::Context<T>, cx: &mut gpui::App,
) -> gpui::Task<anyhow::Result<()>> ) -> gpui::Task<anyhow::Result<()>>
where where
Q: QueryFn<Context = C>, Q: QueryFn<Context = C>,
T: 'static,
{ {
let entity = self.entity_for(query, cx); let entity = self.entity_for(query, cx);
@@ -216,14 +276,21 @@ where
let q = query.clone(); let q = query.clone();
let query_context = self.query_context.clone(); let query_context = self.query_context.clone();
cx.spawn(async move |_, cx| { cx.spawn(async move |cx| {
let c = query_context.read_with(cx, |c, _| c.clone())?; println!("[query] {}", q.key());
let result = q.run(&c).await;
let result = q.run(&query_context).await;
entity.raw.update(cx, |state, cx| { entity.raw.update(cx, |state, cx| {
state.data = match result { state.data = match result {
Ok(data) => QueryData::Some(Box::new(data)), Ok(data) => {
Err(err) => QueryData::Err(Box::new(err)), println!("[query] OK {}", q.key());
QueryData::Some(Box::new(data))
}
Err(err) => {
println!("[query] ERR {:?}: {:?}", q.key(), err);
QueryData::Err(Box::new(err))
}
}; };
cx.notify(); cx.notify();
})?; })?;
@@ -232,6 +299,34 @@ where
}) })
} }
async fn execute_query<'a, F>(&mut self, query_fn: &F, cx: &'a mut gpui::AsyncApp) -> Entity<F>
where
F: QueryFn<Context = C>,
{
let entity = self.entity_for(query_fn, cx).unwrap();
entity.update(cx, |query, cx| {
query.data = QueryData::Loading;
cx.notify();
});
let result = query_fn.run(&self.query_context).await;
entity
.raw
.update(cx, |query, cx| {
query.data = match result {
Ok(data) => QueryData::Some(Box::new(data)),
Err(err) => QueryData::Err(Box::new(err)),
};
cx.notify();
true
})
.unwrap();
entity
}
fn invalidate_query<E, F>(&self, entity: &Entity<F>, cx: &mut gpui::Context<E>) fn invalidate_query<E, F>(&self, entity: &Entity<F>, cx: &mut gpui::Context<E>)
where where
E: 'static, E: 'static,
@@ -249,4 +344,34 @@ where
} }
} }
impl QueryAppContext for gpui::App {
fn ready<T>(value: T) -> Self::Result<T> {
value
}
fn map_result<T, U>(result: Self::Result<T>, f: impl FnOnce(T) -> U) -> Self::Result<U> {
f(result)
}
}
impl QueryAppContext for gpui::AsyncApp {
fn ready<T>(value: T) -> Self::Result<T> {
Ok(value)
}
fn map_result<T, U>(result: Self::Result<T>, f: impl FnOnce(T) -> U) -> Self::Result<U> {
result.map(f)
}
}
impl<'a, E> QueryAppContext for gpui::Context<'a, E> {
fn ready<T>(value: T) -> Self::Result<T> {
value
}
fn map_result<T, U>(result: Self::Result<T>, f: impl FnOnce(T) -> U) -> Self::Result<U> {
f(result)
}
}
impl<C> gpui::Global for Store<C> where C: Context + 'static {} impl<C> gpui::Global for Store<C> where C: Context + 'static {}

View File

@@ -1,31 +1,95 @@
use std::time::{Duration, Instant}; use std::time::Duration;
use gpui::{AppContext, FontWeight, ParentElement, Styled, div, prelude::FluentBuilder}; use futures_lite::StreamExt;
use gpui::{
BorrowAppContext, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, Timer,
div, prelude::FluentBuilder,
};
use rand::RngExt; use rand::RngExt;
use crate::{ use crate::{
api, app, api, app,
component::text::text, component::{button::button, text::text},
query::{self, QueryStatus, read_query, use_lazy_query}, query::{self, QueryStatus, fetch_query, read_query, use_query},
storage, theme,
}; };
pub(crate) struct GithubStepView { pub(crate) struct GithubStepView {
last_tick: Instant, has_opened_link: bool,
placeholder_code: String, placeholder_code: String,
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>>,
user_query: Option<query::Entity<api::user::Fetch>>,
} }
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView { pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
GithubStepView { let mut view = GithubStepView {
last_tick: Instant::now(), has_opened_link: false,
placeholder_code: "ABCDEFGH".to_owned(), placeholder_code: "ABCDEFGH".to_owned(),
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,
};
view.on_create(cx);
view
} }
impl GithubStepView { impl GithubStepView {
const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
cx.observe(&self.create_device_code_query, |this, _, cx| {
let code = {
let data = read_query(&this.create_device_code_query, cx);
if let QueryStatus::Loaded(data) = data {
Some(data.device_code.clone())
} else {
None
}
};
if let Some(ref code) = code
&& !this.has_opened_link
{
this.has_opened_link = true;
this.begin_auth_flow(code, cx);
cx.notify();
}
})
.detach();
cx.spawn(async |this, cx| {
let mut timer = Timer::interval(Duration::from_millis(50));
loop {
let is_code_loaded = this.read_with(cx, |this, cx| {
matches!(
read_query(&this.create_device_code_query, cx),
QueryStatus::Loaded(_)
)
});
if matches!(is_code_loaded, Ok(true) | Err(_)) {
timer.clear();
} else {
this.update(cx, |this, cx| {
this.placeholder_code = this.generate_random_code(cx);
cx.notify();
});
}
if let None = timer.next().await {
break;
};
}
})
.detach();
}
fn open_github_auth_page(cx: &mut gpui::Context<Self>) {
cx.open_url(api::auth::DEVICE_LOGIN_FLOW_URL);
}
fn generate_random_code(&mut self, cx: &mut gpui::Context<Self>) -> String { fn generate_random_code(&mut self, cx: &mut gpui::Context<Self>) -> String {
let rng = app::rng(cx); let rng = app::rng(cx);
(0..8) (0..8)
@@ -35,75 +99,195 @@ impl GithubStepView {
}) })
.collect() .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 begin_auth_flow(&mut self, device_code: &str, cx: &mut gpui::Context<Self>) {
GithubStepView::open_github_auth_page(cx);
let query = use_query(
api::auth::RequestAccessToken {
device_code: device_code.to_owned(),
},
cx,
);
cx.observe(&query, |this, _, cx| {
this.handle_access_token_query_response(cx);
})
.detach();
self.has_opened_link = true;
self.request_access_token_query = Some(query);
cx.notify();
}
fn handle_access_token_query_response(&mut self, cx: &mut gpui::Context<Self>) {
let Some(query) = &self.request_access_token_query else {
return;
};
match read_query(query, cx) {
QueryStatus::Loaded(data) => {
let auth_tokens = api::AuthTokens {
access_token: data.access_token.clone(),
};
cx.update_global::<query::Store<api::QueryContext>, _>(|store, _| {
store.update_query_context(|c| {
c.auth = Some(auth_tokens.clone());
});
});
cx.spawn(async move |weak, cx| {
let ent = fetch_query(api::user::Fetch, cx).await;
let fut = weak
.update(cx, move |_this, cx| {
let Ok(query) = ent else {
return None;
};
let QueryStatus::Loaded(user) = read_query(&query, cx) else {
return None;
};
Some(storage::store_auth_tokens(&auth_tokens, user, cx))
})
.unwrap_or_default();
let r = if let Some(task) = fut {
task.await
} else {
Err(anyhow::Error::msg(""))
};
})
.detach();
}
QueryStatus::Err(api::Error::Github(api::GithubError { error, .. })) => {
if error == "authorization_pending" {
cx.spawn(async |weak, cx| {
Timer::after(Duration::from_secs(1)).await;
if let Ok(Some(query)) =
weak.read_with(cx, |this, _cx| this.request_access_token_query.clone())
{
weak.update(cx, |_this, cx| {
query.refetch(cx);
});
}
})
.detach();
}
}
_ => {}
}
}
} }
impl gpui::Render for GithubStepView { impl gpui::Render for GithubStepView {
fn render( fn render(
&mut self, &mut self,
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 theme = app::current_theme(cx);
let border_color = theme.colors.surface_elevated.clone();
let bg_color = theme.colors.surface.clone();
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);
let now = Instant::now(); let theme = app::current_theme(cx);
let should_tick = now.duration_since(self.last_tick) >= Duration::from_millis(50);
if is_loading_code { let displayed_code = match create_device_code_query {
cx.on_next_frame(window, move |this, _, cx| { QueryStatus::Loaded(data) => &data.user_code,
if should_tick { _ => &self.placeholder_code,
this.placeholder_code = this.generate_random_code(cx); };
this.last_tick = Instant::now();
}
cx.notify();
});
}
let letter_boxes = self
.placeholder_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() div()
.flex() .flex()
.flex_col() .flex_col()
.size_full() .size_full()
.px_4() .px_4()
.py_12() .pt_12()
.child(header()) .child(header())
.child( .child(
div() div()
.flex() .flex()
.flex_row() .flex_col()
.flex_1() .flex_1()
.items_center() .items_center()
.justify_center() .justify_center()
.gap_1p5() .gap_1p5()
.children(letter_boxes), .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(
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()),
),
) )
} }
} }
fn device_code_area(
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 { fn header() -> impl gpui::IntoElement {
div() div()
.flex() .flex()

View File

@@ -59,8 +59,6 @@ impl gpui::Render for Screen {
let theme = app::current_theme(cx); let theme = app::current_theme(cx);
div() div()
.id("awd")
.on_click(cx.listener(|a, b, c, d| {}))
.flex() .flex()
.flex_row() .flex_row()
.items_center() .items_center()

13
src/storage.rs Normal file
View File

@@ -0,0 +1,13 @@
use crate::api;
pub(crate) fn store_auth_tokens(
tokens: &api::AuthTokens,
user: &api::user::User,
cx: &gpui::App,
) -> gpui::Task<anyhow::Result<()>> {
cx.write_credentials(
&format!("novem://github/github.com/user/{}", user.id),
&format!("{}", user.id),
tokens.access_token.as_bytes(),
)
}