450 lines
14 KiB
Rust
450 lines
14 KiB
Rust
use std::time::Duration;
|
|
|
|
use futures_lite::StreamExt;
|
|
use gpui::{
|
|
BorrowAppContext, InteractiveElement, ParentElement, Styled, Timer, div, img,
|
|
prelude::FluentBuilder,
|
|
};
|
|
use rand::RngExt;
|
|
|
|
use crate::{
|
|
api, app,
|
|
component::{
|
|
button::button,
|
|
font_icon::{FontIcon, font_icon},
|
|
text::text,
|
|
},
|
|
query::{self, QueryStatus, fetch_query, read_query, use_query},
|
|
storage,
|
|
util::timeout::set_timeout,
|
|
};
|
|
|
|
pub(crate) struct GithubStepView {
|
|
is_opening_link: bool,
|
|
has_opened_link: bool,
|
|
placeholder_code: String,
|
|
has_copied_code: bool,
|
|
|
|
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(api::user::Id, &mut gpui::App) + 'static>>,
|
|
}
|
|
|
|
pub(crate) fn new(cx: &mut gpui::Context<GithubStepView>) -> GithubStepView {
|
|
let mut view = GithubStepView {
|
|
is_opening_link: false,
|
|
has_opened_link: false,
|
|
placeholder_code: "ABCDEFGH".to_owned(),
|
|
has_copied_code: false,
|
|
|
|
create_device_code_query: use_query(api::auth::CreateDeviceCode, cx),
|
|
request_access_token_query: None,
|
|
user_query: None,
|
|
|
|
on_success: None,
|
|
};
|
|
view.on_create(cx);
|
|
view
|
|
}
|
|
|
|
impl GithubStepView {
|
|
const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
|
|
pub(crate) fn on_success(
|
|
&mut self,
|
|
f: impl Fn(api::user::Id, &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 = {
|
|
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.is_opening_link
|
|
{
|
|
this.is_opening_link = true;
|
|
this.copy_device_code(code, cx);
|
|
|
|
let code = code.clone();
|
|
set_timeout(
|
|
move |weak, cx| {
|
|
_ = weak.update(cx, |this, cx| {
|
|
this.has_opened_link = true;
|
|
this.is_opening_link = false;
|
|
this.begin_auth_flow(&code, cx);
|
|
cx.notify();
|
|
});
|
|
},
|
|
Duration::from_secs(2),
|
|
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 {
|
|
let _ = 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)
|
|
.map(|_| {
|
|
let idx = rng.random_range(0..Self::CHAR_POOL.len());
|
|
Self::CHAR_POOL.chars().nth(idx).unwrap()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn copy_device_code(&mut self, code: &str, cx: &mut gpui::Context<Self>) {
|
|
cx.write_to_clipboard(gpui::ClipboardItem::new_string(code.to_owned()));
|
|
|
|
self.has_copied_code = true;
|
|
set_timeout(
|
|
|weak, cx| {
|
|
_ = weak.update(cx, |this, cx| {
|
|
this.has_copied_code = false;
|
|
cx.notify();
|
|
});
|
|
},
|
|
Duration::from_secs(3),
|
|
cx,
|
|
);
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
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;
|
|
};
|
|
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) => {
|
|
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());
|
|
});
|
|
});
|
|
|
|
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;
|
|
|
|
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();
|
|
|
|
_ = 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 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())
|
|
{
|
|
let _ = weak.update(cx, |_this, cx| {
|
|
query.refetch(cx);
|
|
});
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn on_next_clicked(&mut self, cx: &mut gpui::Context<Self>) {
|
|
let (Some(f), Some(query)) = (&self.on_success, &self.user_query) else {
|
|
return;
|
|
};
|
|
let QueryStatus::Loaded(user) = read_query(query, cx) else {
|
|
return;
|
|
};
|
|
f(user.id, cx);
|
|
}
|
|
|
|
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);
|
|
|
|
let theme = app::current_theme(cx);
|
|
|
|
let displayed_code = match create_device_code_query {
|
|
QueryStatus::Loaded(data) => &data.user_code,
|
|
_ => &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 if self.is_opening_link {
|
|
"Copied to clipboard! Opening the browser…"
|
|
} else if self.has_copied_code {
|
|
"Copied to clipboard!"
|
|
} else {
|
|
"Click to copy"
|
|
})
|
|
.text_sm()
|
|
.when(!self.is_opening_link && !self.has_copied_code, |it| {
|
|
it.opacity(0.5)
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn header(&self) -> gpui::Div {
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.items_center()
|
|
.gap_1p5()
|
|
.child(text("Connect to GitHub").text_xl().bold())
|
|
.child(text(
|
|
if self.has_copied_code {
|
|
"You will be redirected to GitHub to authorize access.\nPaste the device code below into GitHub."
|
|
} else {
|
|
"You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub."
|
|
}
|
|
).leading_tight().centered().opacity(0.8))
|
|
}
|
|
}
|
|
|
|
impl gpui::Render for GithubStepView {
|
|
fn render(
|
|
&mut self,
|
|
_window: &mut gpui::Window,
|
|
cx: &mut gpui::Context<Self>,
|
|
) -> impl gpui::IntoElement {
|
|
let (can_go_next, header, body) = match self.user_query {
|
|
None => (false, self.header(), self.device_code_area(cx)),
|
|
Some(ref q) => {
|
|
let user_query = read_query(q, cx);
|
|
match user_query {
|
|
QueryStatus::Loaded(user) => {
|
|
(true, connected_header(), connected_body(user, cx))
|
|
}
|
|
_ => (false, self.header(), self.device_code_area(cx)),
|
|
}
|
|
}
|
|
};
|
|
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.size_full()
|
|
.px_4()
|
|
.pt_12()
|
|
.child(header)
|
|
.child(body)
|
|
.child(
|
|
div().flex().flex_row().justify_end().w_full().pb_4().child(
|
|
button("connect-to-github-next")
|
|
.label(if can_go_next {
|
|
"Next"
|
|
} else {
|
|
"Waiting for authentication"
|
|
})
|
|
.on_click(cx.listener(|this, _, _, cx| {
|
|
this.on_next_clicked(cx);
|
|
}))
|
|
.when(!can_go_next, |it| it.disabled()),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
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()),
|
|
),
|
|
)
|
|
}
|