feat: connect to github step

This commit is contained in:
2026-04-25 00:49:50 +01:00
parent a9f4d1d923
commit a54cc84660
12 changed files with 450 additions and 122 deletions

View File

@@ -3,15 +3,19 @@ use std::time::Duration;
use futures_lite::StreamExt;
use gpui::{
BorrowAppContext, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, Timer,
div, prelude::FluentBuilder,
div, img, prelude::FluentBuilder,
};
use rand::RngExt;
use crate::{
api, app,
component::{button::button, text::text},
query::{self, QueryStatus, fetch_query, read_query, use_query},
storage, theme,
component::{
button::button,
font_icon::{FontIcon, font_icon},
text::text,
},
query::{self, QueryStatus, fetch_query, read_query, use_lazy_query, use_query},
storage,
};
pub(crate) struct GithubStepView {
@@ -21,6 +25,8 @@ pub(crate) struct GithubStepView {
create_device_code_query: query::Entity<api::auth::CreateDeviceCode>,
request_access_token_query: Option<query::Entity<api::auth::RequestAccessToken>>,
user_query: Option<query::Entity<api::user::Fetch>>,
on_success: Option<Box<dyn Fn(&mut gpui::App) + 'static>>,
}
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,
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,
user_query: None,
on_success: None,
};
view.on_create(cx);
view
@@ -39,6 +47,11 @@ pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
impl GithubStepView {
const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
pub(crate) fn on_success(&mut self, f: impl Fn(&mut gpui::App) + 'static) -> &mut Self {
self.on_success = Some(Box::new(f));
self
}
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
cx.observe(&self.create_device_code_query, |this, _, cx| {
let code = {
@@ -72,7 +85,7 @@ impl GithubStepView {
if matches!(is_code_loaded, Ok(true) | Err(_)) {
timer.clear();
} else {
this.update(cx, |this, cx| {
let _ = this.update(cx, |this, cx| {
this.placeholder_code = this.generate_random_code(cx);
cx.notify();
});
@@ -135,6 +148,13 @@ impl GithubStepView {
let Some(query) = &self.request_access_token_query else {
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) {
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| {
let ent = fetch_query(api::user::Fetch, cx).await;
@@ -168,18 +190,27 @@ impl GithubStepView {
} 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();
}
QueryStatus::Err(api::Error::Github(api::GithubError { error, .. })) => {
if error == "authorization_pending" {
cx.spawn(async |weak, cx| {
Timer::after(Duration::from_secs(1)).await;
cx.spawn(async move |weak, cx| {
Timer::after(Duration::from_secs(poll_interval)).await;
if let Ok(Some(query)) =
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);
});
}
@@ -191,14 +222,8 @@ impl GithubStepView {
_ => {}
}
}
}
impl gpui::Render for GithubStepView {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
fn device_code_area(&self, cx: &mut gpui::Context<Self>) -> gpui::Div {
let create_device_code_query = read_query(&self.create_device_code_query, cx);
let is_loading_code = matches!(create_device_code_query, QueryStatus::Loading);
@@ -209,38 +234,86 @@ impl gpui::Render for GithubStepView {
_ => &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()
.flex()
.flex_col()
.size_full()
.px_4()
.pt_12()
.child(header())
.child(
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(header)
.child(body)
.child(
div().flex().flex_row().justify_end().w_full().pb_4().child(
button("connect-to-github-next")
@@ -251,44 +324,7 @@ impl gpui::Render for GithubStepView {
}
}
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() -> gpui::Div {
div()
.flex()
.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.",
).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()),
),
)
}

View File

@@ -1,5 +1,16 @@
mod github_step;
mod screen;
mod storage;
mod welcome_step;
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];

View File

@@ -1,22 +1,20 @@
use gpui::{
AppContext, BorrowAppContext, InteractiveElement, IntoElement, ParentElement,
StatefulInteractiveElement, Styled, div,
AppContext, BorrowAppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder,
};
use crate::{
app,
component::text::text,
screen::setup_wizard::{github_step, welcome_step::welcome_step},
component::text::{Text, text},
screen::setup_wizard::{
ALL_SETUP_STEPS, Step, github_step,
storage::{StoredSetupState, store_setup_state},
welcome_step::welcome_step,
},
};
pub(crate) struct Screen {
current_step: Step,
github_step_view: gpui::Entity<github_step::GithubStepView>,
}
enum Step {
Welcome,
ConnectToGithub,
github_step_view: Option<gpui::Entity<github_step::GithubStepView>>,
}
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 {
current_step: Step::Welcome,
github_step_view: cx.new(|cx| github_step::new(cx)),
github_step_view: None,
}
}
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 {
Step::Welcome => self.current_step = Step::ConnectToGithub,
Step::ConnectToGithub => {}
Step::Welcome => {
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 {
let step_view = match self.current_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(),
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);
@@ -78,7 +154,7 @@ impl gpui::Render for Screen {
.bold()
.styled(|it| it.absolute().top_20().left_8()),
)
.child(step_list(cx)),
.child(self.step_list()),
)
.child(
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!"),
])
}

View 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(())
}