feat: subtext under pr title

This commit is contained in:
2026-05-12 01:34:33 +08:00
parent bfcfac61e8
commit 2fe3f7b94f
12 changed files with 290 additions and 105 deletions

View File

@@ -6,6 +6,17 @@ query PullRequestQuery($id: ID!) {
body
state
isDraft
baseRef {
name
}
headRef {
name
}
author {
__typename
login
avatarUrl(size: 32)
}
}
}
}

View File

@@ -70,6 +70,9 @@ pub(crate) struct DetailedPullRequest {
pub(crate) state: PullRequestState,
pub(crate) is_draft: bool,
pub(crate) body: String,
pub(crate) author: Option<super::user::Actor>,
pub(crate) base_branch_name: Option<String>,
pub(crate) head_branch_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -285,8 +288,8 @@ impl query::QueryFn for ListPullRequests {
}
let query_string = match self.filter {
Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
None => "is:pr archived:false sort:updated-desc".into(),
| Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
| None => "is:pr archived:false sort:updated-desc".into(),
};
let gql =
@@ -308,19 +311,19 @@ impl query::QueryFn for ListPullRequests {
.flatten()
.filter_map(|edge| {
edge.node.and_then(|n| match n {
PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
Some(PullRequest {
id: p.id.into(),
title: p.title,
state: p.state,
is_draft: p.is_draft,
repo_slug: format!(
"{}/{}",
p.repository.owner.login, p.repository.name
),
})
}
_ => None,
| PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
Some(PullRequest {
id: p.id.into(),
title: p.title,
state: p.state,
is_draft: p.is_draft,
repo_slug: format!(
"{}/{}",
p.repository.owner.login, p.repository.name
),
})
}
| _ => None,
})
})
.collect::<Vec<_>>()
@@ -365,15 +368,21 @@ impl query::QueryFn for FetchPullRequest {
"missing 'node' field on PullRequestQuery response".into(),
))
.and_then(|n| match n {
PullRequestQueryNode::PullRequest(p) => Ok(DetailedPullRequest {
title: p.title,
state: p.state,
is_draft: p.is_draft,
body: p.body,
| PullRequestQueryNode::PullRequest(p) => Ok(DetailedPullRequest {
title: p.title,
state: p.state,
is_draft: p.is_draft,
body: p.body,
author: p.author.map(|it| api::user::Actor {
login: it.login,
avatar_url: it.avatar_url,
}),
_ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestQuery".into(),
)),
base_branch_name: p.base_ref.map(|r| r.name),
head_branch_name: p.head_ref.map(|r| r.name),
}),
| _ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestQuery".into(),
)),
})
}
}
@@ -416,11 +425,11 @@ impl query::QueryFn for FetchPullRequestTimeline {
TimelineActor {
kind: match on {
actorFieldsOn::Bot => "Bot",
actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
actorFieldsOn::Mannequin => "Mannequin",
actorFieldsOn::Organization => "Organization",
actorFieldsOn::User => "User",
| actorFieldsOn::Bot => "Bot",
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
| actorFieldsOn::Mannequin => "Mannequin",
| actorFieldsOn::Organization => "Organization",
| actorFieldsOn::User => "User",
}
.into(),
name: login,
@@ -430,62 +439,62 @@ impl query::QueryFn for FetchPullRequestTimeline {
fn normalize_assignee(actor: assigneeFields) -> TimelineActor {
match actor {
assigneeFields::Bot(actor) => TimelineActor {
kind: "Bot".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
assigneeFields::Mannequin(actor) => TimelineActor {
kind: "Mannequin".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
assigneeFields::Organization(actor) => TimelineActor {
kind: "Organization".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
assigneeFields::User(actor) => TimelineActor {
kind: "User".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
| assigneeFields::Bot(actor) => TimelineActor {
kind: "Bot".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
| assigneeFields::Mannequin(actor) => TimelineActor {
kind: "Mannequin".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
| assigneeFields::Organization(actor) => TimelineActor {
kind: "Organization".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
| assigneeFields::User(actor) => TimelineActor {
kind: "User".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
}
}
fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor {
match actor {
requestedReviewerFields::Bot(actor) => TimelineActor {
kind: "Bot".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
requestedReviewerFields::Mannequin(actor) => TimelineActor {
kind: "Mannequin".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
requestedReviewerFields::Team(actor) => TimelineActor {
kind: "Team".into(),
name: actor.name,
avatar_url: None,
},
requestedReviewerFields::User(actor) => TimelineActor {
kind: "User".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
| requestedReviewerFields::Bot(actor) => TimelineActor {
kind: "Bot".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
| requestedReviewerFields::Mannequin(actor) => TimelineActor {
kind: "Mannequin".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
| requestedReviewerFields::Team(actor) => TimelineActor {
kind: "Team".into(),
name: actor.name,
avatar_url: None,
},
| requestedReviewerFields::User(actor) => TimelineActor {
kind: "User".into(),
name: actor.login,
avatar_url: Some(actor.avatar_url),
},
}
}
fn normalize_review_state(state: PullRequestReviewState) -> String {
match state {
PullRequestReviewState::PENDING => "PENDING",
PullRequestReviewState::COMMENTED => "COMMENTED",
PullRequestReviewState::APPROVED => "APPROVED",
PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
PullRequestReviewState::DISMISSED => "DISMISSED",
_ => "OTHER",
| PullRequestReviewState::PENDING => "PENDING",
| PullRequestReviewState::COMMENTED => "COMMENTED",
| PullRequestReviewState::APPROVED => "APPROVED",
| PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
| PullRequestReviewState::DISMISSED => "DISMISSED",
| _ => "OTHER",
}
.into()
}
@@ -705,10 +714,10 @@ impl query::QueryFn for FetchPullRequestTimeline {
"missing 'node' field on PullRequestTimelineQuery response".into(),
))
.and_then(|node| match node {
PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
_ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestTimelineQuery".into(),
)),
| PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
| _ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestTimelineQuery".into(),
)),
})?;
let timeline = pull_request.timeline_items;

View File

@@ -133,15 +133,80 @@ mod tests {
.expect("closed pull request fixture should parse");
let dashboard_markdown = fetch_pull_request(&issues::Id::from("PR_kwDONovem84"))
.expect("dashboard pull request fixture should parse");
let cached_repo_picker = fetch_pull_request(&issues::Id::from("PR_kwDONovem85"))
.expect("repo picker pull request fixture should parse");
let worker_split = fetch_pull_request(&issues::Id::from("PR_kwDOAgent47"))
.expect("worker split pull request fixture should parse");
let spacing_tokens = fetch_pull_request(&issues::Id::from("PR_kwDODesign31"))
.expect("spacing token pull request fixture should parse");
assert_eq!(merged.state, issues::PullRequestState::Merged);
assert!(merged.body.contains("| Stage | Owner | Status |"));
assert_eq!(
merged.author.as_ref().map(|author| author.login.as_str()),
Some("rorycraft")
);
assert_eq!(merged.base_branch_name.as_deref(), Some("main"));
assert_eq!(
merged.head_branch_name.as_deref(),
Some("feat/release-handoff-checklist")
);
assert!(
documented_failover
.body
.contains("./scripts/failover promote-standby")
);
assert_eq!(
documented_failover
.author
.as_ref()
.map(|author| author.login.as_str()),
Some("kennethnym")
);
assert_eq!(documented_failover.base_branch_name.as_deref(), Some("main"));
assert_eq!(
documented_failover.head_branch_name.as_deref(),
Some("docs/manual-failover-steps")
);
assert!(dashboard_markdown.body.contains("```rust"));
assert_eq!(dashboard_markdown.base_branch_name.as_deref(), Some("main"));
assert_eq!(
dashboard_markdown.head_branch_name.as_deref(),
Some("feat/cached-issue-pane")
);
assert_eq!(
cached_repo_picker
.author
.as_ref()
.map(|author| author.login.as_str()),
Some("kennethnym")
);
assert_eq!(cached_repo_picker.base_branch_name.as_deref(), Some("main"));
assert_eq!(
cached_repo_picker.head_branch_name.as_deref(),
Some("feat/cached-repo-picker")
);
assert_eq!(
worker_split.author.as_ref().map(|author| author.login.as_str()),
Some("leaferiksen")
);
assert_eq!(worker_split.base_branch_name.as_deref(), Some("main"));
assert_eq!(
worker_split.head_branch_name.as_deref(),
Some("feat/worker-context-envelope")
);
assert_eq!(
spacing_tokens
.author
.as_ref()
.map(|author| author.login.as_str()),
Some("mariahops")
);
assert_eq!(spacing_tokens.base_branch_name.as_deref(), Some("main"));
assert_eq!(
spacing_tokens.head_branch_name.as_deref(),
Some("chore/dashboard-spacing-scale")
);
}
#[test]

View File

@@ -20,6 +20,12 @@ pub struct User {
pub email: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Actor {
pub(crate) login: String,
pub(crate) avatar_url: String,
}
impl Deref for Id {
type Target = u64;

View File

@@ -1,6 +1,6 @@
use gpui::{
AppContext, InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled,
div, prelude::FluentBuilder,
div, img, prelude::FluentBuilder,
};
use crate::{
@@ -118,20 +118,80 @@ impl PullRequestView {
}
}
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 merge_text = match (
pr.author.as_ref(),
pr.base_branch_name.as_ref(),
pr.head_branch_name.as_ref(),
) {
| (Some(author), Some(base_branch), Some(head_branch)) => {
let str = format!(
"{} requested to merge {} into {}",
author.login, head_branch, base_branch
);
let row = div()
.flex()
.flex_row()
.gap_2()
.child(status_pill)
.child(author_pill);
let head_branch_text_offset = author.login.len() + 20;
let base_branch_text_offset = head_branch_text_offset + head_branch.len() + 6;
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.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.into()),
..Default::default()
},
),
];
Some((
author,
gpui::StyledText::new(str).with_highlights(highlights),
))
}
| _ => None,
};
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()
.child(img(author.avatar_url.clone()).size_4().rounded_full())
.child(
div()
.min_w_0()
.w_full()
.text_color(theme.colors.text)
.text_xs()
.font_weight(gpui::FontWeight::LIGHT)
.opacity(0.8)
.child(t),
),
)
});
div()
.size_full()
@@ -145,8 +205,8 @@ impl PullRequestView {
.py_3()
.border_b_1()
.border_color(theme.colors.border)
.child(text(pr.title.clone()).w_full().text_xl().mb_2())
.child(row),
.child(text(pr.title.clone()).w_full().text_xl().mb_1())
.child(metadata_line),
)
.child(
div().flex_1().min_h_0().w_full().child(

View File

@@ -43,9 +43,9 @@ impl Screen {
_ = 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);
}
| issue_list::Event::ItemSelected(pr_id) => {
this.handle_issue_list_item_selected(pr_id, cx);
}
})
.detach();
}
@@ -56,17 +56,18 @@ impl Screen {
cx: &mut gpui::Context<Self>,
) {
match value {
SidebarItemValue::PullRequest { filter } => {
self.issue_filter = Some(*filter);
cx.notify();
}
| SidebarItemValue::PullRequest { filter } => {
self.issue_filter = Some(*filter);
cx.notify();
}
}
}
fn handle_issue_list_item_selected(
&mut self,
id: &api::issues::Id,
cx: &mut gpui::Context<Self>, ) {
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);
@@ -111,7 +112,6 @@ impl gpui::Render for Screen {
.bg(theme.colors.surface)
.border_x_1()
.border_color(theme.colors.border)
.mr_2()
.overflow_hidden()
.child(self.issue_list.clone()),
)
@@ -123,8 +123,6 @@ impl gpui::Render for Screen {
.h_full()
.overflow_hidden()
.bg(theme.colors.surface)
.border_l_1()
.border_color(theme.colors.border)
.child(self.pull_request_view.clone()),
),
)