2026-05-23 18:45:44 +01:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-05-11 02:14:05 +08:00
|
|
|
use gpui::{
|
|
|
|
|
AppContext, InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled,
|
2026-05-13 20:02:26 +08:00
|
|
|
div, img, prelude::FluentBuilder,
|
2026-05-11 02:14:05 +08:00
|
|
|
};
|
2026-05-11 00:32:12 +08:00
|
|
|
|
|
|
|
|
use crate::{
|
2026-05-11 02:14:05 +08:00
|
|
|
api::{self},
|
2026-05-11 00:32:12 +08:00
|
|
|
app,
|
|
|
|
|
component::{
|
2026-05-13 02:23:20 +08:00
|
|
|
button::{self, Button, button},
|
2026-05-11 00:32:12 +08:00
|
|
|
font_icon::{FontIcon, font_icon},
|
|
|
|
|
markdown::{self, MarkdownText},
|
|
|
|
|
text::text,
|
|
|
|
|
},
|
|
|
|
|
query::{self, QueryStatus, read_query, use_query},
|
2026-05-24 16:44:10 +01:00
|
|
|
screen::dashboard::pull_request_diff_view::{self, PullRequestDiffView},
|
2026-05-11 00:32:12 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pub(crate) struct PullRequestView {
|
|
|
|
|
markdown_viewer: Option<gpui::Entity<MarkdownText>>,
|
2026-05-18 22:30:46 +08:00
|
|
|
diff_view: Option<gpui::Entity<PullRequestDiffView>>,
|
2026-05-11 00:32:12 +08:00
|
|
|
|
|
|
|
|
pull_request_query: Option<query::Entity<api::issues::FetchPullRequest>>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 02:23:20 +08:00
|
|
|
#[derive(gpui::IntoElement)]
|
|
|
|
|
struct Toolbar {}
|
|
|
|
|
|
2026-05-13 20:02:26 +08:00
|
|
|
pub fn new(_cx: &mut gpui::Context<PullRequestView>) -> PullRequestView {
|
2026-05-11 00:32:12 +08:00
|
|
|
PullRequestView {
|
|
|
|
|
markdown_viewer: None,
|
2026-05-18 22:30:46 +08:00
|
|
|
diff_view: None,
|
2026-05-11 00:32:12 +08:00
|
|
|
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| {
|
2026-05-11 02:34:09 +08:00
|
|
|
this.load_markdown_content(cx);
|
2026-05-24 16:44:10 +01:00
|
|
|
this.load_pr_diff(cx);
|
2026-05-11 00:32:12 +08:00
|
|
|
})
|
|
|
|
|
.detach();
|
|
|
|
|
|
2026-05-11 02:34:09 +08:00
|
|
|
// cached query will not trigger observe callback
|
|
|
|
|
// this is required so that content is loaded immediately for cached query
|
|
|
|
|
self.load_markdown_content(cx);
|
2026-05-24 16:44:10 +01:00
|
|
|
self.load_pr_diff(cx);
|
2026-05-11 02:34:09 +08:00
|
|
|
|
|
|
|
|
cx.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_markdown_content(&mut self, cx: &mut gpui::Context<Self>) {
|
|
|
|
|
let Some(query) = &self.pull_request_query else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let maybe_content = {
|
|
|
|
|
let data = read_query(&query, cx);
|
|
|
|
|
if let QueryStatus::Loaded(pr) = data {
|
2026-05-23 18:45:44 +01:00
|
|
|
Some(Arc::clone(&pr.body))
|
2026-05-11 02:34:09 +08:00
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
self.markdown_viewer = maybe_content.map(|content| cx.new(|cx| markdown::new(content, cx)));
|
|
|
|
|
|
2026-05-11 00:32:12 +08:00
|
|
|
cx.notify();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 16:44:10 +01:00
|
|
|
fn load_pr_diff(&mut self, cx: &mut gpui::Context<Self>) {
|
|
|
|
|
let Some(query) = &self.pull_request_query else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let pr_id = {
|
|
|
|
|
let data = read_query(&query, cx);
|
|
|
|
|
if let QueryStatus::Loaded(pr) = data {
|
|
|
|
|
Some(pr.id.clone())
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
self.diff_view = pr_id.map(|id| cx.new(|cx| pull_request_diff_view::new(id, cx)));
|
|
|
|
|
|
|
|
|
|
cx.notify();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:32:12 +08:00
|
|
|
fn pr_content(
|
|
|
|
|
&self,
|
|
|
|
|
pr: &api::issues::DetailedPullRequest,
|
|
|
|
|
cx: &gpui::Context<Self>,
|
2026-05-11 02:14:05 +08:00
|
|
|
) -> gpui::AnyElement {
|
2026-05-11 00:32:12 +08:00
|
|
|
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 {
|
2026-05-14 00:05:31 +08:00
|
|
|
| api::issues::PullRequestState::Open => {
|
|
|
|
|
status_pill = status_pill
|
|
|
|
|
.bg(theme.colors.success_solid)
|
|
|
|
|
.child(
|
|
|
|
|
font_icon(FontIcon::PullRequestArrow)
|
|
|
|
|
.size_3()
|
|
|
|
|
.text_color(theme.colors.success_on_solid),
|
|
|
|
|
)
|
|
|
|
|
.child(
|
|
|
|
|
text("Open")
|
|
|
|
|
.text_color(theme.colors.success_on_solid)
|
2026-05-11 00:32:12 +08:00
|
|
|
.text_xs(),
|
|
|
|
|
);
|
2026-05-14 00:05:31 +08:00
|
|
|
}
|
|
|
|
|
| api::issues::PullRequestState::Closed => {
|
|
|
|
|
status_pill = status_pill
|
|
|
|
|
.bg(theme.colors.danger_solid)
|
|
|
|
|
.child(
|
|
|
|
|
font_icon(FontIcon::PullRequestClosed)
|
|
|
|
|
.size_3()
|
|
|
|
|
.text_color(theme.colors.danger_on_solid),
|
|
|
|
|
)
|
|
|
|
|
.child(
|
|
|
|
|
text("Closed")
|
|
|
|
|
.text_color(theme.colors.danger_on_solid)
|
|
|
|
|
.text_xs(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
| api::issues::PullRequestState::Merged => {
|
|
|
|
|
status_pill = status_pill.bg(theme.colors.accent_solid).child(
|
|
|
|
|
text("Merged")
|
|
|
|
|
.text_color(theme.colors.accent_on_solid)
|
|
|
|
|
.text_xs(),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-11 00:32:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 22:30:46 +08:00
|
|
|
let merge_text = pr.author.as_ref().map(|author| {
|
2026-05-23 18:45:44 +01:00
|
|
|
let base_branch = &pr.base_branch_name;
|
|
|
|
|
let head_branch = &pr.head_branch_name;
|
2026-05-14 00:05:31 +08:00
|
|
|
let str = format!(
|
|
|
|
|
"{} requested to merge {} into {}",
|
|
|
|
|
author.login, head_branch, base_branch
|
|
|
|
|
);
|
2026-05-11 00:32:12 +08:00
|
|
|
|
2026-05-14 00:05:31 +08:00
|
|
|
let head_branch_text_offset = author.login.len() + 20;
|
|
|
|
|
let base_branch_text_offset = head_branch_text_offset + head_branch.len() + 6;
|
2026-05-12 01:34:33 +08:00
|
|
|
|
2026-05-14 00:05:31 +08:00
|
|
|
let highlights = [
|
|
|
|
|
(
|
|
|
|
|
0..author.login.len(),
|
|
|
|
|
gpui::HighlightStyle {
|
|
|
|
|
font_weight: Some(gpui::FontWeight::BOLD),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
head_branch_text_offset..head_branch_text_offset + head_branch.len(),
|
|
|
|
|
gpui::HighlightStyle {
|
|
|
|
|
font_weight: Some(gpui::FontWeight::BOLD),
|
|
|
|
|
color: Some(theme.colors.accent_fg.into()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
base_branch_text_offset..base_branch_text_offset + base_branch.len(),
|
|
|
|
|
gpui::HighlightStyle {
|
|
|
|
|
font_weight: Some(gpui::FontWeight::BOLD),
|
|
|
|
|
color: Some(theme.colors.accent_fg.into()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
];
|
2026-05-12 01:34:33 +08:00
|
|
|
|
2026-05-18 22:30:46 +08:00
|
|
|
(
|
2026-05-14 00:05:31 +08:00
|
|
|
author,
|
|
|
|
|
gpui::StyledText::new(str).with_highlights(highlights),
|
2026-05-18 22:30:46 +08:00
|
|
|
)
|
|
|
|
|
});
|
2026-05-12 01:34:33 +08:00
|
|
|
|
2026-05-23 18:45:44 +01:00
|
|
|
let pr_title = gpui::SharedString::new(Arc::clone(&pr.title));
|
|
|
|
|
|
2026-05-12 01:34:33 +08:00
|
|
|
let metadata_line =
|
|
|
|
|
div()
|
|
|
|
|
.flex()
|
|
|
|
|
.flex_row()
|
|
|
|
|
.gap_2()
|
|
|
|
|
.when_some(merge_text, |it, (author, t)| {
|
|
|
|
|
it.child(
|
|
|
|
|
div()
|
|
|
|
|
.flex()
|
|
|
|
|
.flex_row()
|
|
|
|
|
.items_center()
|
|
|
|
|
.gap_1p5()
|
2026-05-23 18:45:44 +01:00
|
|
|
.child(img(author.avatar_url.as_ref()).size_4().rounded_full())
|
2026-05-12 01:34:33 +08:00
|
|
|
.child(
|
|
|
|
|
div()
|
|
|
|
|
.min_w_0()
|
|
|
|
|
.w_full()
|
|
|
|
|
.text_color(theme.colors.text)
|
|
|
|
|
.text_xs()
|
|
|
|
|
.font_weight(gpui::FontWeight::LIGHT)
|
|
|
|
|
.opacity(0.8)
|
|
|
|
|
.child(t),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
});
|
2026-05-11 00:32:12 +08:00
|
|
|
|
|
|
|
|
div()
|
|
|
|
|
.size_full()
|
|
|
|
|
.flex()
|
|
|
|
|
.flex_col()
|
2026-05-14 00:05:31 +08:00
|
|
|
.bg(theme.colors.surface)
|
2026-05-11 02:14:05 +08:00
|
|
|
.overflow_hidden()
|
2026-05-13 02:23:20 +08:00
|
|
|
.child(Toolbar {})
|
2026-05-11 00:32:12 +08:00
|
|
|
.child(
|
|
|
|
|
div()
|
2026-05-12 02:44:42 +08:00
|
|
|
.flex()
|
|
|
|
|
.flex_row()
|
|
|
|
|
.justify_between()
|
|
|
|
|
.items_center()
|
2026-05-11 00:32:12 +08:00
|
|
|
.w_full()
|
|
|
|
|
.px_3p5()
|
|
|
|
|
.py_3()
|
|
|
|
|
.border_b_1()
|
2026-05-14 00:05:31 +08:00
|
|
|
.border_color(theme.colors.border_muted)
|
2026-05-12 02:19:08 +08:00
|
|
|
.child(
|
|
|
|
|
div()
|
|
|
|
|
.w_full()
|
|
|
|
|
.flex()
|
2026-05-12 02:44:42 +08:00
|
|
|
.flex_col()
|
|
|
|
|
.items_start()
|
2026-05-23 18:45:44 +01:00
|
|
|
.child(text(pr_title).w_full().text_xl().mb_1())
|
2026-05-12 02:44:42 +08:00
|
|
|
.child(metadata_line),
|
2026-05-12 02:19:08 +08:00
|
|
|
)
|
2026-05-12 02:44:42 +08:00
|
|
|
.child(div().flex().flex_col().items_end().gap_1().when_some(
|
|
|
|
|
pr.created_at,
|
|
|
|
|
|it, created_at| {
|
|
|
|
|
it.child(
|
|
|
|
|
text(created_at.format("%Y-%m-%d %H:%M").to_string())
|
|
|
|
|
.opacity(0.5)
|
|
|
|
|
.text_xs(),
|
|
|
|
|
)
|
|
|
|
|
.child(status_pill)
|
|
|
|
|
},
|
|
|
|
|
)),
|
2026-05-11 00:32:12 +08:00
|
|
|
)
|
2026-05-11 02:14:05 +08:00
|
|
|
.child(
|
|
|
|
|
div().flex_1().min_h_0().w_full().child(
|
|
|
|
|
div()
|
|
|
|
|
.id("pr-body-content")
|
|
|
|
|
.size_full()
|
|
|
|
|
.overflow_y_scroll()
|
|
|
|
|
.when_some(self.markdown_viewer.as_ref(), |it, viewer| {
|
|
|
|
|
it.child(div().w_full().p_3p5().child(viewer.clone()))
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.into_any_element()
|
2026-05-11 00:32:12 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl gpui::Render for PullRequestView {
|
|
|
|
|
fn render(
|
|
|
|
|
&mut self,
|
2026-05-11 02:14:05 +08:00
|
|
|
_window: &mut gpui::Window,
|
2026-05-11 00:32:12 +08:00
|
|
|
cx: &mut gpui::Context<Self>,
|
|
|
|
|
) -> impl gpui::IntoElement {
|
2026-05-11 02:14:05 +08:00
|
|
|
div().size_full().child(match &self.pull_request_query {
|
2026-05-14 00:05:31 +08:00
|
|
|
| Some(q) => match read_query(q, cx) {
|
2026-05-24 16:44:10 +01:00
|
|
|
| QueryStatus::Loaded(pr) => match &self.diff_view {
|
|
|
|
|
| Some(v) => v.clone().into_any_element(),
|
|
|
|
|
| None => self.pr_content(pr, cx),
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-14 00:05:31 +08:00
|
|
|
| QueryStatus::Err(e) => div()
|
|
|
|
|
.size_full()
|
|
|
|
|
.child(format!("{:?}", e))
|
|
|
|
|
.into_any_element(),
|
|
|
|
|
| QueryStatus::Loading => div()
|
|
|
|
|
.size_full()
|
|
|
|
|
.child("loading pr content")
|
|
|
|
|
.into_any_element(),
|
|
|
|
|
},
|
2026-05-24 16:44:10 +01:00
|
|
|
|
2026-05-14 00:05:31 +08:00
|
|
|
| None => div().size_full().child("no pr selected").into_any_element(),
|
2026-05-11 02:14:05 +08:00
|
|
|
})
|
2026-05-11 00:32:12 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 02:23:20 +08:00
|
|
|
|
|
|
|
|
impl gpui::RenderOnce for Toolbar {
|
2026-05-13 20:02:26 +08:00
|
|
|
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
2026-05-13 02:23:20 +08:00
|
|
|
fn toolbar_button(id: impl Into<gpui::ElementId>) -> Button {
|
|
|
|
|
button(id)
|
|
|
|
|
.px_2p5()
|
|
|
|
|
.py_1()
|
|
|
|
|
.variant(button::Variant::Secondary)
|
2026-05-14 00:05:31 +08:00
|
|
|
.border_0()
|
2026-05-13 02:23:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn divider() -> gpui::Div {
|
|
|
|
|
div().h_full().w_px()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let theme = app::current_theme(cx);
|
2026-05-14 00:05:31 +08:00
|
|
|
|
2026-05-13 02:23:20 +08:00
|
|
|
div()
|
|
|
|
|
.w_full()
|
|
|
|
|
.flex()
|
|
|
|
|
.flex_row()
|
|
|
|
|
.items_center()
|
|
|
|
|
.justify_start()
|
2026-05-13 17:35:07 +08:00
|
|
|
.p_1()
|
2026-05-14 00:05:31 +08:00
|
|
|
.bg(theme.colors.surface)
|
2026-05-13 02:23:20 +08:00
|
|
|
.border_b_1()
|
2026-05-14 00:05:31 +08:00
|
|
|
.border_color(theme.colors.border_muted)
|
2026-05-13 02:23:20 +08:00
|
|
|
.child(
|
|
|
|
|
toolbar_button("pr-review-btn")
|
|
|
|
|
.leading(font_icon(FontIcon::Eye))
|
2026-05-13 17:35:07 +08:00
|
|
|
.mr_1(),
|
2026-05-13 02:23:20 +08:00
|
|
|
)
|
|
|
|
|
.child(
|
|
|
|
|
toolbar_button("pr-review-btn")
|
|
|
|
|
.leading(font_icon(FontIcon::RefreshCw))
|
2026-05-14 00:05:31 +08:00
|
|
|
.mr_1(),
|
2026-05-13 02:23:20 +08:00
|
|
|
)
|
2026-05-14 00:05:31 +08:00
|
|
|
.child(divider().bg(theme.colors.border).mr_1())
|
2026-05-13 17:35:07 +08:00
|
|
|
.child(toolbar_button("pr-review-btn").leading(font_icon(FontIcon::Star)))
|
2026-05-13 02:23:20 +08:00
|
|
|
.child(div().flex_1())
|
|
|
|
|
.child(
|
|
|
|
|
toolbar_button("pr-close-btn")
|
|
|
|
|
.leading(font_icon(FontIcon::PullRequestClosed))
|
2026-05-13 17:35:07 +08:00
|
|
|
.mr_1(),
|
2026-05-13 02:23:20 +08:00
|
|
|
)
|
|
|
|
|
.child(
|
|
|
|
|
toolbar_button("pr-merge-btn")
|
|
|
|
|
.variant(button::Variant::Primary)
|
|
|
|
|
.leading(font_icon(FontIcon::GitMerge))
|
|
|
|
|
.rounded_r_none(),
|
|
|
|
|
)
|
|
|
|
|
.child(divider())
|
|
|
|
|
.child(
|
|
|
|
|
toolbar_button("chevron")
|
|
|
|
|
.py_1()
|
|
|
|
|
.px_0p5()
|
|
|
|
|
.variant(button::Variant::Primary)
|
|
|
|
|
.leading(font_icon(FontIcon::ChevronDown))
|
|
|
|
|
.rounded_l_none(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|