wip: connect to github
This commit is contained in:
@@ -5,10 +5,12 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
futures = "0.3.32"
|
||||
futures-lite = "2.6.1"
|
||||
gpui = { version = "*" }
|
||||
paste = "1.0"
|
||||
rand = "0.10.1"
|
||||
reqwest = "0.13.2"
|
||||
reqwest = { version = "0.13.2", features = ["form"] }
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] }
|
||||
|
||||
60
src/api.rs
60
src/api.rs
@@ -1,3 +1,8 @@
|
||||
use std::fmt::format;
|
||||
|
||||
use reqwest::{Response, dns::Resolving};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::query;
|
||||
|
||||
pub(crate) mod auth;
|
||||
@@ -7,27 +12,56 @@ pub(crate) mod user;
|
||||
#[derive(Clone)]
|
||||
pub struct QueryContext {
|
||||
pub(crate) http: reqwest::Client,
|
||||
pub(crate) auth: Option<Auth>,
|
||||
pub(crate) auth: Option<AuthTokens>,
|
||||
pub(crate) github: GithubCredentials,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Auth {
|
||||
pub(crate) access_token: &'static str,
|
||||
pub(crate) refresh_token: &'static str,
|
||||
pub(crate) struct AuthTokens {
|
||||
pub(crate) access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct GithubCredentials {
|
||||
pub(crate) base_url: &'static str,
|
||||
pub(crate) client_id: &'static str,
|
||||
}
|
||||
|
||||
pub enum Error {
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Error {
|
||||
Unauthenticated,
|
||||
Github(GithubError),
|
||||
MalformedResponse(serde_json::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 From<reqwest::Error> for Error {
|
||||
@@ -41,3 +75,19 @@ impl From<serde_json::Error> for Error {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
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)]
|
||||
pub struct CreateDeviceCode;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
vertification_uri: String,
|
||||
expires_in: u16,
|
||||
interval: u16,
|
||||
pub(crate) struct DeviceCodeResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub vertification_uri: Option<String>,
|
||||
pub expires_in: u16,
|
||||
pub interval: u16,
|
||||
}
|
||||
|
||||
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> {
|
||||
let data = c
|
||||
let res = c
|
||||
.http
|
||||
.post(format!(
|
||||
"https://github.com/login/device/code?client_id={}",
|
||||
c.github.client_id
|
||||
))
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.bytes()
|
||||
.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(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
api::parse_response(res).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
|
||||
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)]
|
||||
pub struct Fetch;
|
||||
|
||||
impl query::QueryFn for Fetch {
|
||||
type Data = api::Error;
|
||||
type Data = User;
|
||||
type Error = api::Error;
|
||||
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> {
|
||||
Err(api::Error::Unauthenticated)
|
||||
let res = c.github_request(Method::GET, "/user")?.send().await?;
|
||||
api::parse_response(res).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,6 @@ pub fn rng(cx: &mut gpui::App) -> &mut rand::prelude::ThreadRng {
|
||||
&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>>()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use gpui::{
|
||||
StatefulInteractiveElement, Styled, div, prelude::FluentBuilder,
|
||||
};
|
||||
|
||||
use crate::{app, component::text::TextContent, theme};
|
||||
use crate::{app, component::text::TextContent};
|
||||
|
||||
#[derive(gpui::IntoElement)]
|
||||
pub struct Button {
|
||||
@@ -12,6 +12,7 @@ pub struct Button {
|
||||
leading: Option<gpui::Svg>,
|
||||
trailing: Option<gpui::Svg>,
|
||||
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 {
|
||||
@@ -21,6 +22,7 @@ pub fn button(id: impl Into<gpui::ElementId>) -> Button {
|
||||
leading: None,
|
||||
trailing: None,
|
||||
on_click: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +49,11 @@ impl Button {
|
||||
self.on_click = Some(Box::new(f));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self) -> Self {
|
||||
self.enabled = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::RenderOnce for Button {
|
||||
@@ -86,8 +93,9 @@ impl gpui::RenderOnce for Button {
|
||||
.px_2p5()
|
||||
.py_0p5()
|
||||
.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())
|
||||
})
|
||||
.when(!self.enabled, |div| div.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
18
src/main.rs
18
src/main.rs
@@ -10,6 +10,7 @@ mod component;
|
||||
mod dashboard;
|
||||
mod query;
|
||||
mod screen;
|
||||
mod storage;
|
||||
mod theme;
|
||||
mod titlebar;
|
||||
|
||||
@@ -29,16 +30,15 @@ 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,
|
||||
github: api::GithubCredentials {
|
||||
client_id: "Iv23liZD4bMQpGJICsR7",
|
||||
},
|
||||
|
||||
let query_store = query::Store::new(api::QueryContext {
|
||||
http: reqwest::Client::new(),
|
||||
auth: None,
|
||||
github: api::GithubCredentials {
|
||||
base_url: "https://api.github.com",
|
||||
client_id: "Iv23liZD4bMQpGJICsR7",
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
let global = app::Global {
|
||||
safe_area: bounds(point(px(0.), px(0.)), size(px(72.), px(12.))),
|
||||
|
||||
253
src/query.rs
253
src/query.rs
@@ -1,9 +1,9 @@
|
||||
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 Error: 'static;
|
||||
type Error: std::fmt::Debug + 'static;
|
||||
type Context: Context;
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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,
|
||||
data: QueryData,
|
||||
}
|
||||
@@ -51,7 +57,7 @@ where
|
||||
ent
|
||||
});
|
||||
|
||||
cx.observe(&ent.raw, move |_, ent, cx| {
|
||||
cx.observe(&ent, move |_, ent, cx| {
|
||||
let query = ent.read(cx);
|
||||
if matches!(query.data, QueryData::Stale) {
|
||||
cx.update_global::<Store<F::Context>, _>(|store, cx| {
|
||||
@@ -62,15 +68,6 @@ where
|
||||
})
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -82,7 +79,7 @@ where
|
||||
{
|
||||
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);
|
||||
if matches!(query.data, QueryData::Stale) {
|
||||
cx.update_global::<Store<F::Context>, _>(|store, cx| {
|
||||
@@ -93,37 +90,91 @@ where
|
||||
})
|
||||
.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
|
||||
}
|
||||
|
||||
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>
|
||||
where
|
||||
F: QueryFn,
|
||||
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| {
|
||||
store.invalidate_query(self, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_query<'a, F, T>(
|
||||
query: &Entity<F>,
|
||||
cx: &'a gpui::Context<T>,
|
||||
) -> QueryStatus<'a, F::Data, F::Error>
|
||||
impl<F> Deref for Entity<F>
|
||||
where
|
||||
F: QueryFn,
|
||||
{
|
||||
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
|
||||
F: QueryFn,
|
||||
T: 'static,
|
||||
{
|
||||
let state = query.raw.read(cx);
|
||||
|
||||
@@ -143,45 +194,55 @@ where
|
||||
C: Context,
|
||||
{
|
||||
query_data: HashMap<String, gpui::Entity<Query>>,
|
||||
query_context: gpui::Entity<C>,
|
||||
query_context: C,
|
||||
}
|
||||
|
||||
impl<C> Store<C>
|
||||
where
|
||||
C: Context + 'static,
|
||||
{
|
||||
pub fn new(ctx: C, cx: &mut gpui::App) -> Self {
|
||||
pub fn new(ctx: C) -> Self {
|
||||
Self {
|
||||
query_context: cx.new(|_| ctx),
|
||||
query_context: ctx,
|
||||
query_data: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_for<Q, T>(&mut self, query: &Q, cx: &mut gpui::Context<T>) -> Entity<Q>
|
||||
where
|
||||
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,
|
||||
}
|
||||
pub(crate) fn update_query_context(&mut self, f: impl FnOnce(&mut C)) {
|
||||
f(&mut self.query_context);
|
||||
}
|
||||
|
||||
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
|
||||
T: 'static,
|
||||
Q: QueryFn<Context = C>,
|
||||
{
|
||||
let entity = self.entity_for(query, cx);
|
||||
@@ -191,20 +252,19 @@ where
|
||||
});
|
||||
|
||||
if should_execute {
|
||||
self.execute_query(query, cx).detach();
|
||||
self.execute_query_detached(query, cx).detach();
|
||||
}
|
||||
|
||||
entity
|
||||
}
|
||||
|
||||
fn execute_query<Q, T>(
|
||||
fn execute_query_detached<Q>(
|
||||
&mut self,
|
||||
query: &Q,
|
||||
cx: &mut gpui::Context<T>,
|
||||
cx: &mut gpui::App,
|
||||
) -> gpui::Task<anyhow::Result<()>>
|
||||
where
|
||||
Q: QueryFn<Context = C>,
|
||||
T: 'static,
|
||||
{
|
||||
let entity = self.entity_for(query, cx);
|
||||
|
||||
@@ -216,14 +276,21 @@ where
|
||||
let q = query.clone();
|
||||
let query_context = self.query_context.clone();
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
let c = query_context.read_with(cx, |c, _| c.clone())?;
|
||||
let result = q.run(&c).await;
|
||||
cx.spawn(async move |cx| {
|
||||
println!("[query] {}", q.key());
|
||||
|
||||
let result = q.run(&query_context).await;
|
||||
|
||||
entity.raw.update(cx, |state, cx| {
|
||||
state.data = match result {
|
||||
Ok(data) => QueryData::Some(Box::new(data)),
|
||||
Err(err) => QueryData::Err(Box::new(err)),
|
||||
Ok(data) => {
|
||||
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();
|
||||
})?;
|
||||
@@ -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>)
|
||||
where
|
||||
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 {}
|
||||
|
||||
@@ -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 crate::{
|
||||
api, app,
|
||||
component::text::text,
|
||||
query::{self, QueryStatus, read_query, use_lazy_query},
|
||||
component::{button::button, text::text},
|
||||
query::{self, QueryStatus, fetch_query, read_query, use_query},
|
||||
storage, theme,
|
||||
};
|
||||
|
||||
pub(crate) struct GithubStepView {
|
||||
last_tick: Instant,
|
||||
has_opened_link: bool,
|
||||
placeholder_code: String,
|
||||
|
||||
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 {
|
||||
GithubStepView {
|
||||
last_tick: Instant::now(),
|
||||
let mut view = GithubStepView {
|
||||
has_opened_link: false,
|
||||
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 {
|
||||
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 {
|
||||
let rng = app::rng(cx);
|
||||
(0..8)
|
||||
@@ -35,75 +99,195 @@ 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 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 {
|
||||
fn render(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
_window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> 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 is_loading_code = matches!(create_device_code_query, QueryStatus::Loading);
|
||||
|
||||
let now = Instant::now();
|
||||
let should_tick = now.duration_since(self.last_tick) >= Duration::from_millis(50);
|
||||
let theme = app::current_theme(cx);
|
||||
|
||||
if is_loading_code {
|
||||
cx.on_next_frame(window, move |this, _, cx| {
|
||||
if should_tick {
|
||||
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<_>>();
|
||||
let displayed_code = match create_device_code_query {
|
||||
QueryStatus::Loaded(data) => &data.user_code,
|
||||
_ => &self.placeholder_code,
|
||||
};
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.px_4()
|
||||
.py_12()
|
||||
.pt_12()
|
||||
.child(header())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.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 {
|
||||
div()
|
||||
.flex()
|
||||
|
||||
@@ -59,8 +59,6 @@ impl gpui::Render for Screen {
|
||||
let theme = app::current_theme(cx);
|
||||
|
||||
div()
|
||||
.id("awd")
|
||||
.on_click(cx.listener(|a, b, c, d| {}))
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
|
||||
13
src/storage.rs
Normal file
13
src/storage.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user