wip: pull request view & md rendering

This commit is contained in:
2026-05-11 00:32:12 +08:00
parent 9f1e051073
commit c29a923e0e
36 changed files with 2716 additions and 99 deletions

View File

@@ -1,4 +1,9 @@
use gpui::{IntoElement, ParentElement, Styled, div, list, prelude::FluentBuilder, px};
use std::ops::Deref;
use gpui::{
InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled, div, list,
prelude::FluentBuilder, px,
};
use crate::{
api::{self},
@@ -15,21 +20,21 @@ pub(crate) struct IssueList {
list_state: gpui::ListState,
list_items: Vec<IssueListItem>,
selected_item: Option<(usize, gpui::SharedString)>,
}
#[derive(Clone)]
enum IssueStatus {
Draft,
Open,
Closed,
pub(crate) enum Event {
ItemSelected(api::issues::Id),
}
#[derive(gpui::IntoElement, Clone)]
struct IssueListItem {
pub(crate) struct IssueListItem {
id: gpui::SharedString,
repo_name: Option<gpui::SharedString>,
title: gpui::SharedString,
description: Option<gpui::SharedString>,
status: api::issues::IssueState,
status: api::issues::PullRequestState,
is_selected: bool,
is_last: bool,
is_draft: bool,
}
@@ -46,6 +51,7 @@ pub(crate) fn new(cx: &mut gpui::Context<IssueList>) -> IssueList {
list_state: gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.)),
list_items: Vec::new(),
selected_item: None,
};
list.on_create(cx);
list
@@ -60,10 +66,16 @@ impl IssueList {
let new_len = res.items.len();
let new_items = res.items.iter().enumerate().map(|(i, it)| IssueListItem {
id: gpui::SharedString::from(it.id.deref()),
repo_name: Some(gpui::SharedString::new(it.repo_slug.as_str())),
title: gpui::SharedString::new(it.title.as_str()),
description: None,
status: it.state,
is_selected: this
.selected_item
.as_ref()
.map(|(_, id)| id.as_str() == it.id.as_str())
.unwrap_or(false),
is_last: i == new_len - 1,
is_draft: it.is_draft,
});
@@ -74,6 +86,17 @@ impl IssueList {
})
.detach();
}
fn on_item_click(&mut self, i: usize, cx: &mut gpui::Context<Self>) {
let Some(item_id) = self.list_items.get(i).map(|item| item.id.clone()) else {
return;
};
for (j, item) in self.list_items.iter_mut().enumerate() {
item.is_selected = i == j;
}
cx.notify();
cx.emit(Event::ItemSelected(item_id.as_str().into()));
}
}
impl gpui::Render for IssueList {
@@ -82,15 +105,31 @@ impl gpui::Render for IssueList {
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let this = cx.entity();
let weak = cx.entity();
list(self.list_state.clone(), move |i, _, cx| {
let this = this.read(cx);
this.list_items[i].clone().into_any_element()
let item = {
let this = weak.read(cx);
this.list_items[i].clone()
};
let weak = weak.clone();
div()
.id(item.id.clone())
.on_click(move |_, _, cx| {
_ = weak.update(cx, |this, cx| {
this.on_item_click(i, cx);
})
})
.child(item)
.into_any_element()
})
.size_full()
}
}
impl gpui::EventEmitter<Event> for IssueList {}
impl gpui::RenderOnce for IssueListItem {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let theme = app::current_theme(cx);
@@ -108,10 +147,10 @@ impl gpui::RenderOnce for IssueListItem {
.opacity(0.5)
} else {
match self.status {
api::issues::IssueState::Closed => {
api::issues::PullRequestState::Closed => {
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger)
}
api::issues::IssueState::Merged => {
api::issues::PullRequestState::Merged => {
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.success)
}
_ => font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success),
@@ -127,7 +166,8 @@ impl gpui::RenderOnce for IssueListItem {
div()
.w_full()
.p_2()
.px_1p5()
.py_1()
.gap_2()
.flex()
.flex_row()
@@ -155,5 +195,12 @@ impl gpui::RenderOnce for IssueListItem {
.when(!self.is_last, |it| {
it.border_b_1().border_color(theme.colors.border)
})
.when(self.is_selected, |it| {
it.bg(theme.colors.surface_elevated)
.border_r_1()
.border_b_0()
.border_color(theme.colors.accent)
.pb(px(5.))
})
}
}

View File

@@ -1,4 +1,5 @@
mod issue_list;
mod pull_request_view;
mod screen;
mod sidebar;
mod titlebar;

View File

@@ -0,0 +1,156 @@
use gpui::{AppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder};
use crate::{
api::{self, issues::PullRequest},
app,
component::{
font_icon::{FontIcon, font_icon},
markdown::{self, MarkdownText},
text::text,
},
query::{self, QueryStatus, read_query, use_query},
};
pub(crate) struct PullRequestView {
markdown_viewer: Option<gpui::Entity<MarkdownText>>,
pull_request_query: Option<query::Entity<api::issues::FetchPullRequest>>,
}
pub fn new(cx: &mut gpui::Context<PullRequestView>) -> PullRequestView {
PullRequestView {
markdown_viewer: None,
pull_request_query: None,
}
}
impl PullRequestView {
pub(crate) fn change_displayed_pull_request(
&mut self,
id: api::issues::Id,
cx: &mut gpui::Context<Self>,
) {
let query = use_query(api::issues::FetchPullRequest { id }, cx);
self.pull_request_query = Some(query.clone());
_ = cx
.observe(&query.clone(), move |this, _, cx| {
let maybe_content = {
let data = read_query(&query, cx);
if let QueryStatus::Loaded(pr) = data {
Some(gpui::SharedString::new(pr.body.as_str()))
} else {
None
}
};
this.markdown_viewer =
maybe_content.map(|content| cx.new(|cx| markdown::new(content, cx)))
})
.detach();
cx.notify();
}
fn pr_content(
&self,
pr: &api::issues::DetailedPullRequest,
cx: &gpui::Context<Self>,
) -> gpui::Div {
let theme = app::current_theme(cx);
let mut status_pill = div()
.flex()
.flex_row()
.items_center()
.gap_1()
.px_2()
.rounded_full();
match pr.state {
| api::issues::PullRequestState::Open => {
status_pill = status_pill
.bg(theme.colors.success)
.child(
font_icon(FontIcon::PullRequestArrow)
.size_3()
.text_color(theme.colors.accent_text),
)
.child(text("Open").text_color(theme.colors.accent_text).text_xs());
}
| api::issues::PullRequestState::Closed => {
status_pill = status_pill
.bg(theme.colors.danger)
.child(
font_icon(FontIcon::PullRequestClosed)
.size_3()
.text_color(theme.colors.accent_text),
)
.child(
text("Closed")
.text_color(theme.colors.accent_text)
.text_xs(),
);
}
| api::issues::PullRequestState::Merged => {
status_pill = status_pill.bg(theme.colors.accent).child(
text("Merged")
.text_color(theme.colors.accent_text)
.text_xs(),
);
}
}
let author_pill = div()
.px_2()
.border_1()
.border_color(theme.colors.border)
.rounded_full()
.bg(theme.colors.surface_elevated)
.child(text("kennethnym").text_xs());
let row = div()
.flex()
.flex_row()
.gap_2()
.child(status_pill)
.child(author_pill);
div()
.size_full()
.flex()
.flex_col()
.child(
div()
.w_full()
.px_3p5()
.py_3()
.border_b_1()
.border_color(theme.colors.border)
.child(text(pr.title.clone()).w_full().text_xl().mb_2())
.child(row),
)
.when_some(self.markdown_viewer.as_ref(), |it, viewer| {
it.child(div().h_full().p_3p5().child(viewer.clone()))
})
}
}
impl gpui::Render for PullRequestView {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
match &self.pull_request_query {
| Some(q) => match read_query(q, cx) {
| QueryStatus::Loaded(pr) => self.pr_content(pr, cx),
| QueryStatus::Err(e) => div().child(format!("{:?}", e)),
| QueryStatus::Loading => div().child("loading pr content"),
},
| None => div().child("no pr selected"),
}
}
}

View File

@@ -1,9 +1,10 @@
use gpui::{AppContext, BorrowAppContext, ParentElement, Styled, div};
use gpui::{AppContext, ParentElement, Styled, div};
use crate::{
app,
api, app,
screen::dashboard::{
issue_list::{self, IssueList},
pull_request_view::{self, PullRequestView},
sidebar::{self, Sidebar, SidebarItemValue},
titlebar::{self, TitleBar},
},
@@ -13,6 +14,7 @@ pub(crate) struct Screen {
titlebar: gpui::Entity<TitleBar>,
issue_list: gpui::Entity<IssueList>,
sidebar: gpui::Entity<Sidebar>,
pull_request_view: gpui::Entity<PullRequestView>,
issue_filter: Option<&'static str>,
}
@@ -22,6 +24,8 @@ pub(crate) fn new(cx: &mut gpui::Context<Screen>) -> Screen {
titlebar: cx.new(titlebar::new),
issue_list: cx.new(issue_list::new),
sidebar: cx.new(|_| sidebar::new()),
pull_request_view: cx.new(pull_request_view::new),
issue_filter: None,
};
screen.on_create(cx);
@@ -36,6 +40,14 @@ impl Screen {
self.sidebar.update(cx, |sidebar, _| {
sidebar.on_item_change(on_item_change);
});
_ = cx
.subscribe(&self.issue_list, |this, _, event, cx| match event {
issue_list::Event::ItemSelected(pr_id) => {
this.handle_issue_list_item_selected(pr_id, cx);
}
})
.detach();
}
fn handle_sidebar_item_change(
@@ -50,6 +62,19 @@ impl Screen {
}
}
}
fn handle_issue_list_item_selected(
&mut self,
id: &api::issues::Id,
cx: &mut gpui::Context<Self>,
) {
println!("handle issue list item selected: {:?}", id);
self.pull_request_view.update(cx, |view, cx| {
view.change_displayed_pull_request(id.clone(), cx);
println!("change displayed pull request: {:?}", id);
cx.notify();
})
}
}
impl gpui::Render for Screen {
@@ -71,10 +96,17 @@ impl gpui::Render for Screen {
.flex_row()
.flex_1()
.w_full()
.child(div().w_40().h_full().child(self.sidebar.clone()))
.child(
div()
.w_80()
.w_40()
.flex_shrink_0()
.h_full()
.child(self.sidebar.clone()),
)
.child(
div()
.w_64()
.flex_shrink_0()
.h_full()
.bg(theme.colors.surface)
.border_x_1()
@@ -86,10 +118,12 @@ impl gpui::Render for Screen {
.child(
div()
.flex_1()
.min_w_0()
.h_full()
.bg(theme.colors.surface)
.border_l_1()
.border_color(theme.colors.border),
.border_color(theme.colors.border)
.child(self.pull_request_view.clone()),
),
)
}