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" anyhow = "1"
gpui = { version = "*" } gpui = { version = "*" }
paste = "1.0" paste = "1.0"
rand = "0.10.1"
reqwest = "0.13.2" reqwest = "0.13.2"
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.149" serde_json = "1.0.149"

View File

@@ -9,6 +9,7 @@ use crate::{api, app};
pub struct Global { pub struct Global {
pub safe_area: gpui::Bounds<gpui::Pixels>, pub safe_area: gpui::Bounds<gpui::Pixels>,
pub current_theme: theme::Theme, pub current_theme: theme::Theme,
pub rng: rand::prelude::ThreadRng,
} }
pub struct Chrome {} pub struct Chrome {}
@@ -50,10 +51,14 @@ impl gpui::Render for Chrome {
impl gpui::Global for Global {} 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 &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> { pub fn query_store<'a, E>(cx: &'a gpui::Context<E>) -> &'a query::Store<api::QueryContext> {
cx.global::<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, StatefulInteractiveElement, Styled, div, prelude::FluentBuilder,
}; };
use crate::{app, component::text::Text}; use crate::{app, component::text::TextContent, theme};
#[derive(gpui::IntoElement)] #[derive(gpui::IntoElement)]
pub struct Button { pub struct Button {
id: gpui::ElementId, id: gpui::ElementId,
text_color: gpui::Rgba,
label: Option<gpui::AnyElement>, label: Option<gpui::AnyElement>,
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)>>,
} }
pub fn button<E>(id: impl Into<gpui::ElementId>, cx: &gpui::Context<E>) -> Button { pub fn button(id: impl Into<gpui::ElementId>) -> Button {
let theme = app::current_theme(cx);
Button { Button {
id: id.into(), id: id.into(),
text_color: theme.colors.accent_text,
label: None, label: None,
leading: None, leading: None,
trailing: None, trailing: None,
@@ -28,40 +25,53 @@ pub fn button<E>(id: impl Into<gpui::ElementId>, cx: &gpui::Context<E>) -> Butto
} }
impl Button { 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.label = Some(s.into_any_element());
self self
} }
pub fn leading(mut self, s: gpui::Svg) -> 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 self
} }
pub fn trailing(mut self, s: gpui::Svg) -> 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 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 { impl gpui::RenderOnce for Button {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { 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); let mut children: Vec<AnyElement> = Vec::with_capacity(3);
if let Some(leading) = self.leading { 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 { if let Some(label) = self.label {
children.push(label); children.push(label);
} }
if let Some(trailing) = self.trailing { 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() div()
.id(self.id) .id(self.id)
.flex() .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 crate::app;
use gpui::{ParentElement, Styled, div}; use gpui::{ParentElement, Styled, div};
pub trait Text: gpui::IntoElement {} #[derive(gpui::IntoElement)]
pub(crate) struct Text {
impl Text for &'static str {} content: gpui::AnyElement,
impl Text for String {} font_weight: gpui::FontWeight,
impl Text for gpui::SharedString {} opacity: f32,
text_align: gpui::TextAlign,
pub fn text<'a, Content: Text, T>(s: Content, cx: &gpui::Context<T>) -> gpui::Div { text_size: Option<gpui::AbsoluteLength>,
let theme = cx.global::<app::Global>().current_theme; line_height: gpui::DefiniteLength,
div().text_color(theme.colors.text).child(s) 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 { 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.))),
current_theme: cx.window_appearance().into(), current_theme: cx.window_appearance().into(),
rng: rand::rng(),
}; };
let top_left = global.safe_area.origin; let top_left = global.safe_area.origin;
@@ -61,7 +62,7 @@ fn setup_application(cx: &mut gpui::App) {
is_resizable: false, is_resizable: false,
..Default::default() ..Default::default()
}, },
|_window, cx| cx.new(|cx| setup_wizard::new(cx)), |window, cx| cx.new(|cx| setup_wizard::new(window, cx)),
) )
.unwrap(); .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::{ use crate::{
api, api, app,
component::text::text, component::text::text,
query::{self, use_lazy_query}, query::{self, QueryStatus, read_query, use_lazy_query},
}; };
pub(crate) struct GithubStepView { pub(crate) struct GithubStepView {
last_tick: Instant,
placeholder_code: String,
create_device_code_query: query::Entity<api::auth::CreateDeviceCode>, create_device_code_query: query::Entity<api::auth::CreateDeviceCode>,
} }
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView { pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
GithubStepView { GithubStepView {
last_tick: Instant::now(),
placeholder_code: "ABCDEFGH".to_owned(),
create_device_code_query: use_lazy_query(api::auth::CreateDeviceCode, cx), 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 { 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 {
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() div()
.flex() .flex()
.flex_col() .flex_col()
.items_center() .items_center()
.child(text("Connect to GitHub", cx).font_weight(FontWeight(700.))) .gap_1p5()
.child(text("Connect to GitHub").text_xl().bold())
.child(text( .child(text(
"You will be redirected to GitHub to authorize access. Copy the device code below into GitHub.", "You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub.",
cx ).leading_tight().centered().opacity(0.8))
).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::{ use crate::{
api, app, app,
component::text::text, component::text::text,
query::{self, use_lazy_query}, screen::setup_wizard::{github_step, welcome_step::welcome_step},
screen::setup_wizard::{github_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>,
} }
enum Step { enum Step {
@@ -16,9 +19,27 @@ enum Step {
ConnectToGithub, 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 { Screen {
current_step: Step::Welcome, 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>, cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement { ) -> impl gpui::IntoElement {
let step_view = match self.current_step { let step_view = match self.current_step {
Step::Welcome => welcome_step::new(cx).into_any_element(), Step::Welcome => welcome_step()
Step::ConnectToGithub => cx.new(|cx| github_step::new(cx)).into_any_element(), .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); 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()
@@ -51,11 +76,9 @@ impl gpui::Render for Screen {
.bg(theme.colors.surface) .bg(theme.colors.surface)
.relative() .relative()
.child( .child(
text("Novem", cx) text("Novem")
.font_weight(FontWeight(700.)) .bold()
.absolute() .styled(|it| it.absolute().top_20().left_8()),
.top_20()
.left_8(),
) )
.child(step_list(cx)), .child(step_list(cx)),
) )
@@ -83,9 +106,9 @@ fn step_list(cx: &gpui::Context<impl gpui::Render>) -> impl gpui::IntoElement {
.gap_3() .gap_3()
.text_sm() .text_sm()
.children(vec![ .children(vec![
text("Welcome!", cx), text("Welcome!"),
text("Connect to GitHub", cx), text("Connect to GitHub"),
text("Customize Novem", cx), text("Customize Novem"),
text("Complete!", cx), text("Complete!"),
]) ])
} }

View File

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

View File

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

View File

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