wip: connect to github

This commit is contained in:
2026-04-23 11:18:43 +01:00
parent 302d0d3222
commit b327648d31
12 changed files with 399 additions and 97 deletions

View File

@@ -7,6 +7,7 @@ edition = "2024"
anyhow = "1"
gpui = { version = "*" }
paste = "1.0"
rand = "0.10.1"
reqwest = "0.13.2"
serde = "1.0.228"
serde_json = "1.0.149"

View File

@@ -9,6 +9,7 @@ use crate::{api, app};
pub struct Global {
pub safe_area: gpui::Bounds<gpui::Pixels>,
pub current_theme: theme::Theme,
pub rng: rand::prelude::ThreadRng,
}
pub struct Chrome {}
@@ -50,10 +51,14 @@ impl gpui::Render for Chrome {
impl gpui::Global for Global {}
pub fn current_theme<'a, E>(cx: &'a gpui::Context<E>) -> &'a theme::Theme {
pub fn current_theme(cx: &gpui::App) -> &theme::Theme {
&cx.global::<Global>().current_theme
}
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> {
cx.global::<query::Store<api::QueryContext>>()
}

View File

@@ -1,3 +0,0 @@
pub mod button;
pub mod font_icon;
pub mod text;

View File

@@ -3,23 +3,20 @@ use gpui::{
StatefulInteractiveElement, Styled, div, prelude::FluentBuilder,
};
use crate::{app, component::text::Text};
use crate::{app, component::text::TextContent, theme};
#[derive(gpui::IntoElement)]
pub struct Button {
id: gpui::ElementId,
text_color: gpui::Rgba,
label: Option<gpui::AnyElement>,
leading: Option<gpui::Svg>,
trailing: Option<gpui::Svg>,
on_click: Option<Box<dyn Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App)>>,
}
pub fn button<E>(id: impl Into<gpui::ElementId>, cx: &gpui::Context<E>) -> Button {
let theme = app::current_theme(cx);
pub fn button(id: impl Into<gpui::ElementId>) -> Button {
Button {
id: id.into(),
text_color: theme.colors.accent_text,
label: None,
leading: None,
trailing: None,
@@ -28,40 +25,53 @@ pub fn button<E>(id: impl Into<gpui::ElementId>, cx: &gpui::Context<E>) -> Butto
}
impl Button {
pub fn label(mut self, s: impl Text) -> Self {
pub fn label(mut self, s: impl TextContent) -> Self {
self.label = Some(s.into_any_element());
self
}
pub fn leading(mut self, s: gpui::Svg) -> Self {
self.leading = Some(s.size_3().text_color(self.text_color));
self.leading = Some(s.size_3());
self
}
pub fn trailing(mut self, s: gpui::Svg) -> Self {
self.trailing = Some(s.text_color(self.text_color));
self.trailing = Some(s.size_3());
self
}
pub fn on_click(mut self, fn: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App)) -> Self {
pub fn on_click(
mut self,
f: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> Self {
self.on_click = Some(Box::new(f));
self
}
}
impl gpui::RenderOnce for Button {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
let theme = app::current_theme(cx);
let mut children: Vec<AnyElement> = Vec::with_capacity(3);
if let Some(leading) = self.leading {
children.push(leading.into_any_element());
children.push(
leading
.text_color(theme.colors.accent_text)
.into_any_element(),
);
}
if let Some(label) = self.label {
children.push(label);
}
if let Some(trailing) = self.trailing {
children.push(trailing.into_any_element());
children.push(
trailing
.text_color(theme.colors.accent_text)
.into_any_element(),
);
}
let theme = cx.global::<app::Global>().current_theme;
div()
.id(self.id)
.flex()

3
src/component/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub(crate) mod button;
pub(crate) mod font_icon;
pub(crate) mod text;

View File

@@ -1,13 +1,177 @@
use crate::app;
use gpui::{ParentElement, Styled, div};
pub trait Text: gpui::IntoElement {}
impl Text for &'static str {}
impl Text for String {}
impl Text for gpui::SharedString {}
pub fn text<'a, Content: Text, T>(s: Content, cx: &gpui::Context<T>) -> gpui::Div {
let theme = cx.global::<app::Global>().current_theme;
div().text_color(theme.colors.text).child(s)
#[derive(gpui::IntoElement)]
pub(crate) struct Text {
content: gpui::AnyElement,
font_weight: gpui::FontWeight,
opacity: f32,
text_align: gpui::TextAlign,
text_size: Option<gpui::AbsoluteLength>,
line_height: gpui::DefiniteLength,
styled: Option<Box<dyn Fn(gpui::Div) -> gpui::Div>>,
}
pub(crate) trait TextContent: gpui::IntoElement {}
impl TextContent for &'static str {}
impl TextContent for String {}
impl TextContent for gpui::SharedString {}
pub(crate) fn text(content: impl TextContent) -> Text {
Text {
content: content.into_any_element(),
font_weight: gpui::FontWeight::NORMAL,
opacity: 1.,
text_align: gpui::TextAlign::Left,
text_size: None,
line_height: gpui::relative(1.5),
styled: None,
}
}
impl Text {
pub(crate) fn light(mut self) -> Self {
self.font_weight = gpui::FontWeight::LIGHT;
self
}
pub(crate) fn medium(mut self) -> Self {
self.font_weight = gpui::FontWeight::MEDIUM;
self
}
pub(crate) fn bold(mut self) -> Self {
self.font_weight = gpui::FontWeight::BOLD;
self
}
pub(crate) fn opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity;
self
}
pub(crate) fn text_size(mut self, size: impl Into<gpui::AbsoluteLength>) -> Self {
self.text_size = Some(size.into());
self
}
pub(crate) fn text_xs(self) -> Self {
self.text_size(gpui::rems(0.75))
}
pub(crate) fn text_sm(self) -> Self {
self.text_size(gpui::rems(0.875))
}
pub(crate) fn text_base(self) -> Self {
self.text_size(gpui::rems(1.0))
}
pub(crate) fn text_lg(self) -> Self {
self.text_size(gpui::rems(1.125))
}
pub(crate) fn text_xl(self) -> Self {
self.text_size(gpui::rems(1.25))
}
pub(crate) fn text_2xl(self) -> Self {
self.text_size(gpui::rems(1.5))
}
pub(crate) fn text_3xl(self) -> Self {
self.text_size(gpui::rems(1.875))
}
pub(crate) fn line_height(mut self, line_height: impl Into<gpui::DefiniteLength>) -> Self {
self.line_height = line_height.into();
self
}
pub(crate) fn leading_none(self) -> Self {
self.line_height(gpui::relative(1.0))
}
pub(crate) fn leading_tight(self) -> Self {
self.line_height(gpui::relative(1.25))
}
pub(crate) fn leading_snug(self) -> Self {
self.line_height(gpui::relative(1.375))
}
pub(crate) fn leading_normal(self) -> Self {
self.line_height(gpui::relative(1.5))
}
pub(crate) fn leading_relaxed(self) -> Self {
self.line_height(gpui::relative(1.625))
}
pub(crate) fn leading_loose(self) -> Self {
self.line_height(gpui::relative(2.0))
}
pub(crate) fn leading_3(self) -> Self {
self.line_height(gpui::rems(0.75))
}
pub(crate) fn leading_4(self) -> Self {
self.line_height(gpui::rems(1.0))
}
pub(crate) fn leading_5(self) -> Self {
self.line_height(gpui::rems(1.25))
}
pub(crate) fn leading_6(self) -> Self {
self.line_height(gpui::rems(1.5))
}
pub(crate) fn leading_7(self) -> Self {
self.line_height(gpui::rems(1.75))
}
pub(crate) fn leading_8(self) -> Self {
self.line_height(gpui::rems(2.0))
}
pub(crate) fn leading_9(self) -> Self {
self.line_height(gpui::rems(2.25))
}
pub(crate) fn leading_10(self) -> Self {
self.line_height(gpui::rems(2.5))
}
pub(crate) fn styled(mut self, styled: impl Fn(gpui::Div) -> gpui::Div + 'static) -> Self {
self.styled = Some(Box::new(styled));
self
}
pub(crate) fn centered(mut self) -> Self {
self.text_align = gpui::TextAlign::Center;
self
}
}
impl gpui::RenderOnce for Text {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
let theme = app::current_theme(cx);
let mut div = div()
.text_color(theme.colors.text)
.font_weight(self.font_weight)
.opacity(self.opacity)
.text_align(self.text_align)
.line_height(self.line_height)
.child(self.content);
if let Some(text_size) = self.text_size {
div = div.text_size(text_size);
}
if let Some(styled) = self.styled {
div = styled(div);
}
div
}
}

View File

@@ -43,6 +43,7 @@ fn setup_application(cx: &mut gpui::App) {
let global = app::Global {
safe_area: bounds(point(px(0.), px(0.)), size(px(72.), px(12.))),
current_theme: cx.window_appearance().into(),
rng: rand::rng(),
};
let top_left = global.safe_area.origin;
@@ -61,7 +62,7 @@ fn setup_application(cx: &mut gpui::App) {
is_resizable: false,
..Default::default()
},
|_window, cx| cx.new(|cx| setup_wizard::new(cx)),
|window, cx| cx.new(|cx| setup_wizard::new(window, cx)),
)
.unwrap();
}

View File

@@ -1,39 +1,117 @@
use gpui::{FontWeight, ParentElement, Styled, div};
use std::time::{Duration, Instant};
use gpui::{AppContext, FontWeight, ParentElement, Styled, div, prelude::FluentBuilder};
use rand::RngExt;
use crate::{
api,
api, app,
component::text::text,
query::{self, use_lazy_query},
query::{self, QueryStatus, read_query, use_lazy_query},
};
pub(crate) struct GithubStepView {
last_tick: Instant,
placeholder_code: String,
create_device_code_query: query::Entity<api::auth::CreateDeviceCode>,
}
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
GithubStepView {
last_tick: Instant::now(),
placeholder_code: "ABCDEFGH".to_owned(),
create_device_code_query: use_lazy_query(api::auth::CreateDeviceCode, cx),
}
}
impl GithubStepView {
const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
fn generate_random_code(&mut self, cx: &mut gpui::Context<Self>) -> String {
let rng = app::rng(cx);
(0..8)
.map(|_| {
let idx = rng.random_range(0..Self::CHAR_POOL.len());
Self::CHAR_POOL.chars().nth(idx).unwrap()
})
.collect()
}
}
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 {
div().flex().flex_col().size_full().child(header(cx))
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);
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<_>>();
div()
.flex()
.flex_col()
.size_full()
.px_4()
.py_12()
.child(header())
.child(
div()
.flex()
.flex_row()
.flex_1()
.items_center()
.justify_center()
.gap_1p5()
.children(letter_boxes),
)
}
}
fn header(cx: &gpui::Context<GithubStepView>) -> impl gpui::IntoElement {
fn header() -> impl gpui::IntoElement {
div()
.flex()
.flex_col()
.items_center()
.child(text("Connect to GitHub", cx).font_weight(FontWeight(700.)))
.gap_1p5()
.child(text("Connect to GitHub").text_xl().bold())
.child(text(
"You will be redirected to GitHub to authorize access. Copy the device code below into GitHub.",
cx
).opacity(0.8))
"You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub.",
).leading_tight().centered().opacity(0.8))
}

View File

@@ -1,14 +1,17 @@
use gpui::{AppContext, FontWeight, IntoElement, ParentElement, Styled, div};
use gpui::{
AppContext, BorrowAppContext, InteractiveElement, IntoElement, ParentElement,
StatefulInteractiveElement, Styled, div,
};
use crate::{
api, app,
app,
component::text::text,
query::{self, use_lazy_query},
screen::setup_wizard::{github_step, welcome_step},
screen::setup_wizard::{github_step, welcome_step::welcome_step},
};
pub(crate) struct Screen {
current_step: Step,
github_step_view: gpui::Entity<github_step::GithubStepView>,
}
enum Step {
@@ -16,9 +19,27 @@ enum Step {
ConnectToGithub,
}
pub(crate) fn new(cx: &mut gpui::Context<Screen>) -> Screen {
pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context<Screen>) -> Screen {
cx.observe_window_appearance(window, |_, window, cx| {
cx.update_global::<app::Global, ()>(|global, cx| {
global.current_theme = window.appearance().into();
cx.notify();
});
})
.detach();
Screen {
current_step: Step::Welcome,
github_step_view: cx.new(|cx| github_step::new(cx)),
}
}
impl Screen {
fn advance_to_next_step(&mut self) {
match self.current_step {
Step::Welcome => self.current_step = Step::ConnectToGithub,
Step::ConnectToGithub => {}
}
}
}
@@ -29,13 +50,17 @@ impl gpui::Render for Screen {
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let step_view = match self.current_step {
Step::Welcome => welcome_step::new(cx).into_any_element(),
Step::ConnectToGithub => cx.new(|cx| github_step::new(cx)).into_any_element(),
Step::Welcome => welcome_step()
.on_next(cx.listener(|this, _, _window, _cx| this.advance_to_next_step()))
.into_any_element(),
Step::ConnectToGithub => self.github_step_view.clone().into_any_element(),
};
let theme = app::current_theme(cx);
div()
.id("awd")
.on_click(cx.listener(|a, b, c, d| {}))
.flex()
.flex_row()
.items_center()
@@ -51,11 +76,9 @@ impl gpui::Render for Screen {
.bg(theme.colors.surface)
.relative()
.child(
text("Novem", cx)
.font_weight(FontWeight(700.))
.absolute()
.top_20()
.left_8(),
text("Novem")
.bold()
.styled(|it| it.absolute().top_20().left_8()),
)
.child(step_list(cx)),
)
@@ -83,9 +106,9 @@ fn step_list(cx: &gpui::Context<impl gpui::Render>) -> impl gpui::IntoElement {
.gap_3()
.text_sm()
.children(vec![
text("Welcome!", cx),
text("Connect to GitHub", cx),
text("Customize Novem", cx),
text("Complete!", cx),
text("Welcome!"),
text("Connect to GitHub"),
text("Customize Novem"),
text("Complete!"),
])
}

View File

@@ -1,47 +1,62 @@
use gpui::{FontWeight, ParentElement, Styled, div};
use gpui::{ParentElement, Styled, div, prelude::FluentBuilder};
use crate::{
app,
component::{button::button, text::text},
};
use crate::component::{button::button, text::text};
struct WelcomeStep {
on_next: Option<FnOnce>,
#[derive(gpui::IntoElement)]
pub(crate) struct WelcomeStep {
on_next: Option<Box<dyn Fn(&(), &mut gpui::Window, &mut gpui::App) + 'static>>,
}
pub(crate) fn new<E>(cx: &gpui::Context<E>) -> impl gpui::IntoElement {
let theme = app::current_theme(cx);
div()
.flex()
.flex_col()
.size_full()
.items_start()
.justify_center()
.child(
div()
.flex()
.flex_col()
.flex_1()
.justify_center()
.w_full()
.p_8()
.child(
text(
"Welcome to Novem!\nThis wizard will guide you through setting up Novem.\n",
cx,
pub(crate) fn welcome_step() -> WelcomeStep {
WelcomeStep { on_next: None }
}
impl WelcomeStep {
pub fn on_next(mut self, f: impl Fn(&(), &mut gpui::Window, &mut gpui::App) + 'static) -> Self {
self.on_next = Some(Box::new(f));
self
}
}
impl gpui::RenderOnce for WelcomeStep {
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl gpui::IntoElement {
let next_button = button("next")
.label("Next")
.when(self.on_next.is_some(), |b| {
b.on_click(move |_, window, cx| self.on_next.as_ref().unwrap()(&(), window, cx))
});
div()
.flex()
.flex_col()
.size_full()
.items_start()
.justify_center()
.child(
div()
.flex()
.flex_col()
.flex_1()
.justify_center()
.w_full()
.p_8()
.child(
text(
"Welcome to Novem!\nThis wizard will guide you through setting up Novem.\n",
)
.opacity(0.8),
)
.opacity(0.8),
)
.child(text("Press 'Next' to begin setup.", cx).font_weight(FontWeight(500.))),
)
.child(
div()
.flex()
.flex_row()
.justify_end()
.w_full()
.p_4()
.pt_0()
.child(button("next", cx).label("Next")),
)
.child(text("Press 'Next' to begin setup.").medium()),
)
.child(
div()
.flex()
.flex_row()
.justify_end()
.w_full()
.p_4()
.pt_0()
.child(next_button),
)
}
}

View File

@@ -47,6 +47,10 @@ pub enum Variant {
VioletDark,
}
pub(crate) fn current(cx: &gpui::App) -> &Theme {
cx.global::<Theme>()
}
impl Variant {
#[allow(dead_code)]
pub const ALL: [Self; 2] = [Self::VioletLight, Self::VioletDark];
@@ -115,3 +119,5 @@ impl From<gpui::WindowAppearance> for Theme {
variant.theme()
}
}
impl gpui::Global for Theme {}

View File

@@ -1,4 +1,3 @@
use gpui::prelude::FluentBuilder;
use gpui::{ParentElement, Styled, div};
use crate::component::button::button;
@@ -36,7 +35,7 @@ impl gpui::Render for TitleBar {
let user_avatar = match user {
QueryStatus::Err(api::Error::Unauthenticated) => div().absolute().right_2p5().child(
button("login-btn", cx)
button("login-btn")
.leading(font_icon(FontIcon::Github, cx))
.label("Login"),
),
@@ -78,6 +77,6 @@ fn repo_selector<T: 'static>(cx: &gpui::Context<T>) -> gpui::Div {
.gap_1()
.text_xs()
.child(font_icon(FontIcon::FolderGit, cx).size_3())
.child(text("test/repo", cx))
.child(text("test/repo"))
.child(font_icon(FontIcon::ChevronDown, cx).size_3())
}