feat: impl dashboard & issue list

This commit is contained in:
2026-05-06 01:42:38 +08:00
parent bef3a0b9ed
commit 7de0039d38
36 changed files with 2381 additions and 107 deletions

View File

@@ -0,0 +1,165 @@
use gpui::{IntoElement, ParentElement, Styled, div, list, prelude::FluentBuilder, px};
use crate::{
api::{self},
app,
component::{
font_icon::{FontIcon, font_icon},
text::text,
},
query::{self, QueryStatus, read_query, use_query},
};
pub(crate) struct IssueList {
pr_query: query::Entity<api::issues::ListPullRequests>,
list_state: gpui::ListState,
list_items: Vec<IssueListItem>,
}
#[derive(Clone)]
enum IssueStatus {
Draft,
Open,
Closed,
}
#[derive(gpui::IntoElement, Clone)]
struct IssueListItem {
repo_name: Option<gpui::SharedString>,
title: gpui::SharedString,
description: Option<gpui::SharedString>,
status: IssueStatus,
is_last: bool,
}
pub(crate) fn new(cx: &mut gpui::Context<IssueList>) -> IssueList {
let mut list = IssueList {
pr_query: use_query(
api::issues::ListPullRequests {
filter: Some(api::issues::Issue::FILTER_ALL),
page: 1,
},
cx,
),
list_state: gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.)),
list_items: Vec::new(),
};
list.on_create(cx);
list
}
impl IssueList {
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
cx.observe(&self.pr_query, |this, _, cx| {
let data = read_query(&this.pr_query, cx);
if let QueryStatus::Loaded(issues) = data {
let old_len = this.list_state.item_count();
let new_len = issues.len();
this.list_items = issues
.iter()
.enumerate()
.map(|(i, it)| IssueListItem {
repo_name: it.repository.as_ref().map(|it| {
gpui::SharedString::new(format!("{}/{}", it.owner.login, it.name))
}),
title: gpui::SharedString::new(it.title.as_str()),
description: it
.body_text
.as_ref()
.map(|it| gpui::SharedString::new(it.as_str())),
status: if it.state == "open" {
IssueStatus::Open
} else if it.state == "closed" {
IssueStatus::Closed
} else {
IssueStatus::Draft
},
is_last: i == new_len - 1,
})
.collect::<Vec<_>>();
this.list_state.splice(old_len..old_len, new_len);
}
})
.detach();
}
}
impl gpui::Render for IssueList {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let this = cx.entity();
list(self.list_state.clone(), move |i, _, cx| {
let this = this.read(cx);
this.list_items[i].clone().into_any_element()
})
.size_full()
}
}
impl gpui::RenderOnce for IssueListItem {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let theme = app::current_theme(cx);
let repo_name_text = match self.repo_name {
Some(name) => text(name),
None => text("Unknown repo"),
}
.text_xs()
.opacity(0.5);
let icon = match self.status {
IssueStatus::Draft => font_icon(FontIcon::PullRequestDraft)
.text_color(theme.colors.text)
.opacity(0.5),
IssueStatus::Open => {
font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success)
}
IssueStatus::Closed => {
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger)
}
}
.flex_shrink_0()
.size_4();
let description_text = match self.description {
Some(description) => text(description).text_xs(),
None => text("No description provided").opacity(0.5).text_xs(),
};
div()
.w_full()
.p_2()
.gap_2()
.flex()
.flex_row()
.items_center()
.child(icon)
.child(
div()
.flex_1()
.flex()
.flex_col()
.w_full()
.pr_2()
.child(repo_name_text)
.child(
text(self.title)
.text_sm()
.leading_tight()
.medium()
.styled(|it| it.w_full().min_w_0().line_clamp(2)),
)
.child(description_text),
)
.when(!self.is_last, |it| {
it.border_b_1().border_color(theme.colors.border)
})
}
}

View File

@@ -1,12 +1,14 @@
mod issue_list;
mod screen;
mod sidebar;
mod titlebar;
use gpui::{AppContext, BorrowAppContext, point, px, size};
pub(crate) use screen::new;
use crate::{app, screen::dashboard::screen::Screen};
use crate::app;
pub fn open_window(screen: Screen, cx: &mut gpui::App) -> anyhow::Result<()> {
pub fn open_window(cx: &mut gpui::App) -> anyhow::Result<()> {
let (top_left, window_bounds) = cx.read_global::<app::Global, _>(|global, cx| {
(
global.safe_area.origin,
@@ -14,7 +16,8 @@ pub fn open_window(screen: Screen, cx: &mut gpui::App) -> anyhow::Result<()> {
)
});
cx.open_window(
app::open_window(
cx,
gpui::WindowOptions {
window_bounds: Some(gpui::WindowBounds::Windowed(window_bounds)),
titlebar: Some(gpui::TitlebarOptions {
@@ -24,20 +27,7 @@ pub fn open_window(screen: Screen, cx: &mut gpui::App) -> anyhow::Result<()> {
}),
..Default::default()
},
|window, cx| {
cx.new(|cx| {
cx.observe_window_appearance(window, |_, window, cx| {
cx.update_global::<app::Global, ()>(|global, cx| {
global.current_theme = global
.theme_family
.theme_for_appearance(window.appearance());
cx.notify();
});
})
.detach();
screen
})
},
|_window, cx| new(cx),
)
.map(|_| ())
}

View File

@@ -1,14 +1,54 @@
use gpui::{AppContext, ParentElement, Styled, div};
use gpui::{AppContext, BorrowAppContext, ParentElement, Styled, div};
use crate::{app, screen::dashboard::titlebar};
use crate::{
app,
screen::dashboard::{
issue_list::{self, IssueList},
sidebar::{self, Sidebar, SidebarItemValue},
titlebar::{self, TitleBar},
},
};
pub(crate) struct Screen {
titlebar: gpui::Entity<titlebar::TitleBar>,
titlebar: gpui::Entity<TitleBar>,
issue_list: gpui::Entity<IssueList>,
sidebar: gpui::Entity<Sidebar>,
issue_filter: Option<&'static str>,
}
pub(crate) fn new(cx: &mut gpui::App) -> Screen {
Screen {
titlebar: cx.new(|cx| titlebar::new(cx)),
pub(crate) fn new(cx: &mut gpui::Context<Screen>) -> Screen {
let mut screen = Screen {
titlebar: cx.new(titlebar::new),
issue_list: cx.new(issue_list::new),
sidebar: cx.new(|_| sidebar::new()),
issue_filter: None,
};
screen.on_create(cx);
screen
}
impl Screen {
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
let on_item_change = cx.listener(|this, value, _, cx| {
this.handle_sidebar_item_change(value, cx);
});
self.sidebar.update(cx, |sidebar, _| {
sidebar.on_item_change(on_item_change);
});
}
fn handle_sidebar_item_change(
&mut self,
value: &SidebarItemValue,
cx: &mut gpui::Context<Self>,
) {
match value {
SidebarItemValue::PullRequest { filter } => {
self.issue_filter = Some(*filter);
cx.notify();
}
}
}
}
@@ -31,11 +71,26 @@ impl gpui::Render for Screen {
.flex_row()
.flex_1()
.w_full()
.gap_2()
.px_3()
.pb_3()
.child(div().w_1_4().h_full().rounded_lg().bg(theme.colors.surface))
.child(div().w_3_4().h_full().rounded_lg().bg(theme.colors.surface)),
.child(div().w_40().h_full().child(self.sidebar.clone()))
.child(
div()
.w_80()
.h_full()
.bg(theme.colors.surface)
.border_x_1()
.border_color(theme.colors.border)
.mr_2()
.overflow_hidden()
.child(self.issue_list.clone()),
)
.child(
div()
.flex_1()
.h_full()
.bg(theme.colors.surface)
.border_l_1()
.border_color(theme.colors.border),
),
)
}
}

View File

@@ -0,0 +1,195 @@
use gpui::{
InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, div,
prelude::FluentBuilder,
};
use crate::{
api, app,
component::{
font_icon::{FontIcon, font_icon},
text::text,
},
};
pub(crate) struct Sidebar {
selected_item_id: Option<&'static str>,
on_item_change: Option<Box<dyn Fn(&SidebarItemValue, &mut gpui::Window, &mut gpui::App)>>,
}
#[derive(gpui::IntoElement)]
struct SidebarItem {
id: &'static str,
title: &'static str,
icon: FontIcon,
value: SidebarItemValue,
is_selected: bool,
on_click: Option<Box<dyn Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App)>>,
}
pub enum SidebarItemValue {
PullRequest { filter: &'static str },
}
pub fn new() -> Sidebar {
Sidebar {
selected_item_id: Some("all"),
on_item_change: None,
}
}
impl Sidebar {
const ALL_ITEMS: [SidebarItem; 3] = [
SidebarItem {
id: "all",
title: "All",
icon: FontIcon::List,
value: SidebarItemValue::PullRequest {
filter: api::issues::Issue::FILTER_ALL,
},
is_selected: false,
on_click: None,
},
SidebarItem {
id: "authored",
title: "Authored",
icon: FontIcon::PencilLine,
value: SidebarItemValue::PullRequest {
filter: api::issues::Issue::FILTER_CREATED,
},
is_selected: false,
on_click: None,
},
SidebarItem {
id: "assigned",
title: "Assigned",
icon: FontIcon::UserPlus,
value: SidebarItemValue::PullRequest {
filter: api::issues::Issue::FILTER_ASSIGNED,
},
is_selected: false,
on_click: None,
},
];
fn select_sidebar_item(&mut self, id: &str, cx: &mut gpui::Context<Self>) {
let Some(item) = Sidebar::ALL_ITEMS.iter().find(|item| item.id == id) else {
return;
};
self.selected_item_id = Some(item.id);
cx.notify();
}
}
impl Sidebar {
pub fn on_item_change(
&mut self,
f: impl Fn(&SidebarItemValue, &mut gpui::Window, &mut gpui::App) + 'static,
) {
self.on_item_change = Some(Box::new(f));
}
pub fn handle_item_change(
&mut self,
id: &'static str,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) {
let Some(item) = Sidebar::ALL_ITEMS.iter().find(|item| item.id == id) else {
return;
};
self.selected_item_id = Some(id);
if let Some(ref f) = self.on_item_change {
f(&item.value, window, cx)
}
cx.notify();
}
}
impl gpui::Render for Sidebar {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let pull_request_sidebar_items = Sidebar::ALL_ITEMS
.into_iter()
.map(|it| {
let id = it.id;
let selected = id == self.selected_item_id.unwrap_or_default();
it.selected(selected)
.on_click(cx.listener(move |this, _, window, cx| {
this.handle_item_change(id, window, cx);
}))
})
.collect::<Vec<_>>();
div().flex().flex_col().size_full().pt_1().child(
div()
.flex()
.flex_col()
.w_full()
.child(
text("PULL REQUESTS")
.text_xs()
.medium()
.opacity(0.5)
.styled(|it| it.pl_3().py_1()),
)
.children(pull_request_sidebar_items),
)
}
}
impl SidebarItem {
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
}
pub fn selected(mut self, selected: bool) -> Self {
self.is_selected = selected;
self
}
}
impl gpui::RenderOnce for SidebarItem {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
let theme = app::current_theme(cx);
div()
.id(self.id)
.flex()
.flex_row()
.items_center()
.child(div().h_full().w_1())
.child(
div()
.flex_1()
.flex()
.flex_row()
.items_center()
.px_2()
.py_1()
.gap_2()
.child(font_icon(self.icon).size_3().when(self.is_selected, |it| {
it.text_color(theme.colors.accent_text)
}))
.child(
text(self.title)
.text_sm()
.leading_tight()
.when(self.is_selected, |it| {
it.text_color(theme.colors.accent_text)
}),
),
)
.when_some(self.on_click, |it, f| it.on_click(f))
.when(self.is_selected, |it| it.bg(theme.colors.accent))
}
}

View File

@@ -1,7 +1,7 @@
use gpui::{ParentElement, Styled, TitlebarOptions, div};
use crate::component::button::button;
use crate::query::{self, QueryStatus, read_query, use_query};
use crate::query::{self, QueryStatus, read_query, use_lazy_query, use_query};
use crate::{
api, app,
component::{
@@ -18,7 +18,7 @@ pub struct RepoSelector {}
pub fn new(cx: &mut gpui::Context<TitleBar>) -> TitleBar {
TitleBar {
fetch_user_query: use_query(api::user::Fetch, cx),
fetch_user_query: use_lazy_query(api::user::Fetch, cx),
}
}
@@ -53,6 +53,8 @@ impl gpui::Render for TitleBar {
.bg(g.current_theme.colors.background)
.text_color(g.current_theme.colors.text)
.relative()
.border_b_1()
.border_color(g.current_theme.colors.border)
.child(repo_selector(cx))
.child(user_avatar)
}
@@ -60,9 +62,6 @@ impl gpui::Render for TitleBar {
impl RepoSelector {
pub fn new(cx: &mut gpui::Context<Self>) -> Self {
use_query(api::repo::List, cx);
use_query(api::user::Fetch, cx);
Self {}
}
}

View File

@@ -32,7 +32,8 @@ pub fn open_window(screen: Screen, cx: &mut gpui::App) -> anyhow::Result<()> {
)
});
cx.open_window(
app::open_window(
cx,
gpui::WindowOptions {
window_bounds: Some(gpui::WindowBounds::Windowed(window_bounds)),
titlebar: Some(gpui::TitlebarOptions {
@@ -43,22 +44,8 @@ pub fn open_window(screen: Screen, cx: &mut gpui::App) -> anyhow::Result<()> {
is_resizable: false,
..Default::default()
},
|window, cx| {
cx.new(|cx| {
cx.observe_window_appearance(window, |_, window, cx| {
cx.update_global::<app::Global, ()>(|global, cx| {
global.current_theme = global
.theme_family
.theme_for_appearance(window.appearance());
cx.notify();
});
})
.detach();
screen
})
},
|_window, _cx| screen,
)
.map(|_| ())
}
impl Step {