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,3 +1,4 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::query;
@@ -36,8 +37,9 @@ pub(crate) enum Error {
#[cfg(debug_assertions)]
MissingMockFixture(String),
Github(GithubError),
MalformedResponse(serde_json::Error),
MalformedResponse(String),
HttpError(reqwest::Error),
GraphQLError(Vec<graphql_client::Error>),
}
#[derive(Debug, Deserialize)]
@@ -66,6 +68,16 @@ impl QueryContext {
.header("User-Agent", "kennethnym")
.bearer_auth(&auth.access_token))
}
fn github_graphql_request<V>(
&self,
request: &graphql_client::QueryBody<V>,
) -> Result<reqwest::RequestBuilder, Error>
where
V: serde::Serialize,
{
Ok(self.github_request(Method::POST, "/graphql")?.json(request))
}
}
#[cfg(debug_assertions)]
@@ -88,7 +100,7 @@ impl From<reqwest::Error> for Error {
impl From<serde_json::Error> for Error {
fn from(value: serde_json::Error) -> Self {
Self::MalformedResponse(value)
Self::MalformedResponse(value.to_string())
}
}
@@ -118,10 +130,13 @@ where
async fn parse_graphql_response<T>(
res: reqwest::Response,
) -> Result<graphql_client::Response<T>, Error>
) -> Result<(graphql_client::Response<T>, T), Error>
where
T: serde::de::DeserializeOwned,
{
let data: graphql_client::Response<T> = res.json().await?;
Ok(data)
let mut body: graphql_client::Response<T> = res.json().await?;
match body.data.take() {
None => Err(Error::GraphQLError(body.errors.unwrap_or_default())),
Some(data) => Ok((body, data)),
}
}

View File

@@ -0,0 +1,11 @@
query PullRequestQuery($id: ID!) {
node(id: $id) {
__typename
... on PullRequest {
title
body
state
isDraft
}
}
}

View File

@@ -0,0 +1,267 @@
query PullRequestTimelineQuery($id: ID!, $first: Int!, $after: String) {
node(id: $id) {
__typename
... on PullRequest {
timelineItems(first: $first, after: $after) {
pageInfo {
endCursor
hasNextPage
}
nodes {
__typename
... on AssignedEvent {
createdAt
actor {
...actorFields
}
assignee {
...assigneeFields
}
}
... on UnassignedEvent {
createdAt
actor {
...actorFields
}
assignee {
...assigneeFields
}
}
... on IssueComment {
createdAt
author {
...actorFields
}
body
}
... on PullRequestCommit {
commit {
committedDate
abbreviatedOid
messageHeadline
}
}
... on PullRequestReview {
createdAt
author {
...actorFields
}
state
body
}
... on ReviewRequestedEvent {
createdAt
actor {
...actorFields
}
requestedReviewer {
...requestedReviewerFields
}
}
... on ReviewRequestRemovedEvent {
createdAt
actor {
...actorFields
}
requestedReviewer {
...requestedReviewerFields
}
}
... on ReviewDismissedEvent {
createdAt
actor {
...actorFields
}
}
... on MergedEvent {
createdAt
actor {
...actorFields
}
}
... on ClosedEvent {
createdAt
actor {
...actorFields
}
}
... on ReopenedEvent {
createdAt
actor {
...actorFields
}
}
... on ConvertToDraftEvent {
createdAt
actor {
...actorFields
}
}
... on ReadyForReviewEvent {
createdAt
actor {
...actorFields
}
}
... on HeadRefForcePushedEvent {
createdAt
actor {
...actorFields
}
beforeCommit {
abbreviatedOid
}
afterCommit {
abbreviatedOid
}
}
... on BaseRefChangedEvent {
createdAt
actor {
...actorFields
}
}
... on LabeledEvent {
createdAt
actor {
...actorFields
}
label {
name
}
}
... on UnlabeledEvent {
createdAt
actor {
...actorFields
}
label {
name
}
}
... on MilestonedEvent {
createdAt
actor {
...actorFields
}
milestoneTitle
}
... on DemilestonedEvent {
createdAt
actor {
...actorFields
}
milestoneTitle
}
... on ReferencedEvent {
createdAt
actor {
...actorFields
}
}
... on CrossReferencedEvent {
createdAt
actor {
...actorFields
}
}
... on AutoMergeEnabledEvent {
createdAt
actor {
...actorFields
}
}
... on AutoMergeDisabledEvent {
createdAt
actor {
...actorFields
}
reason
}
... on AddedToMergeQueueEvent {
createdAt
actor {
...actorFields
}
}
... on RemovedFromMergeQueueEvent {
createdAt
actor {
...actorFields
}
}
}
}
}
}
}
fragment actorFields on Actor {
__typename
login
avatarUrl(size: 100)
}
fragment assigneeFields on Assignee {
__typename
... on Bot {
login
avatarUrl(size: 100)
}
... on Mannequin {
login
avatarUrl(size: 100)
}
... on Organization {
login
avatarUrl(size: 100)
}
... on User {
login
avatarUrl(size: 100)
}
}
fragment requestedReviewerFields on RequestedReviewer {
__typename
... on Bot {
login
avatarUrl(size: 100)
}
... on Mannequin {
login
avatarUrl(size: 100)
}
... on Team {
name
}
... on User {
login
avatarUrl(size: 100)
}
}

View File

@@ -1,10 +1,11 @@
query PullRequestQuery($query: String!) {
query PullRequestPaginationQuery($query: String!) {
search(query: $query, first: 10, type: ISSUE) {
issueCount
edges {
node {
__typename
... on PullRequest {
id
isDraft
title
state

View File

@@ -1,17 +1,22 @@
use std::ops::Deref;
use graphql_client::{GraphQLQuery, Response};
use reqwest::Method;
use graphql_client::GraphQLQuery;
use serde::Deserialize;
use crate::{
api::{
self,
issues::pull_request_query::{PullRequestQuerySearchEdgesNode, PullRequestState},
issues::{
pull_request_pagination_query::PullRequestPaginationQuerySearchEdgesNode,
pull_request_query::PullRequestQueryNode,
},
},
query,
};
type DateTime = String;
type URI = String;
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(transparent)]
#[repr(transparent)]
@@ -25,6 +30,24 @@ impl Deref for Id {
}
}
impl From<&str> for Id {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl From<String> for Id {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<Id> for String {
fn from(value: Id) -> Self {
value.0
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct PullRequestPaginatedResponse {
pub(crate) items: Vec<PullRequest>,
@@ -34,18 +57,155 @@ pub(crate) struct PullRequestPaginatedResponse {
#[derive(Debug, Deserialize)]
pub(crate) struct PullRequest {
pub(crate) id: Id,
pub(crate) title: String,
pub(crate) state: IssueState,
pub(crate) state: PullRequestState,
pub(crate) is_draft: bool,
pub(crate) repo_slug: String,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub(crate) enum IssueState {
Open,
Closed,
Merged,
Unknown,
#[derive(Debug, Deserialize)]
pub(crate) struct DetailedPullRequest {
pub(crate) title: String,
pub(crate) state: PullRequestState,
pub(crate) is_draft: bool,
pub(crate) body: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PullRequestTimeline {
pub(crate) items: Vec<PullRequestTimelineItem>,
pub(crate) end_cursor: Option<String>,
pub(crate) has_next_page: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PullRequestTimelineItem {
Assigned {
created_at: String,
actor: Option<TimelineActor>,
assignee: Option<TimelineActor>,
},
Unassigned {
created_at: String,
actor: Option<TimelineActor>,
assignee: Option<TimelineActor>,
},
Comment {
created_at: String,
author: Option<TimelineActor>,
body: String,
},
Commit {
committed_at: String,
abbreviated_oid: String,
message_headline: String,
},
Review {
created_at: String,
author: Option<TimelineActor>,
state: String,
body: String,
},
ReviewRequested {
created_at: String,
actor: Option<TimelineActor>,
reviewer: Option<TimelineActor>,
},
ReviewRequestRemoved {
created_at: String,
actor: Option<TimelineActor>,
reviewer: Option<TimelineActor>,
},
ReviewDismissed {
created_at: String,
actor: Option<TimelineActor>,
},
Merged {
created_at: String,
actor: Option<TimelineActor>,
},
Closed {
created_at: String,
actor: Option<TimelineActor>,
},
Reopened {
created_at: String,
actor: Option<TimelineActor>,
},
ConvertToDraft {
created_at: String,
actor: Option<TimelineActor>,
},
ReadyForReview {
created_at: String,
actor: Option<TimelineActor>,
},
HeadRefForcePushed {
created_at: String,
actor: Option<TimelineActor>,
before_commit_oid: Option<String>,
after_commit_oid: Option<String>,
},
BaseRefChanged {
created_at: String,
actor: Option<TimelineActor>,
},
Labeled {
created_at: String,
actor: Option<TimelineActor>,
label: String,
},
Unlabeled {
created_at: String,
actor: Option<TimelineActor>,
label: String,
},
Milestoned {
created_at: String,
actor: Option<TimelineActor>,
milestone_title: String,
},
Demilestoned {
created_at: String,
actor: Option<TimelineActor>,
milestone_title: String,
},
Referenced {
created_at: String,
actor: Option<TimelineActor>,
},
CrossReferenced {
created_at: String,
actor: Option<TimelineActor>,
},
AutoMergeEnabled {
created_at: String,
actor: Option<TimelineActor>,
},
AutoMergeDisabled {
created_at: String,
actor: Option<TimelineActor>,
reason: String,
},
AddedToMergeQueue {
created_at: String,
actor: Option<TimelineActor>,
},
RemovedFromMergeQueue {
created_at: String,
actor: Option<TimelineActor>,
},
Other {
typename: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TimelineActor {
pub(crate) kind: String,
pub(crate) name: String,
pub(crate) avatar_url: Option<String>,
}
impl std::fmt::Display for Id {
@@ -54,24 +214,50 @@ impl std::fmt::Display for Id {
}
}
impl From<PullRequestState> for IssueState {
fn from(state: PullRequestState) -> Self {
match state {
PullRequestState::OPEN => Self::Open,
PullRequestState::CLOSED => Self::Closed,
PullRequestState::MERGED => Self::Merged,
_ => Self::Unknown,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum PullRequestState {
Open,
Closed,
Merged,
}
#[derive(graphql_client::GraphQLQuery)]
#[graphql(
schema_path = "src/api/graphql/schema.json",
query_path = "src/api/graphql/list_pull_requests.graphql"
extern_enums("PullRequestState")
)]
struct PullRequestPaginationQuery;
#[derive(graphql_client::GraphQLQuery)]
#[graphql(
schema_path = "src/api/graphql/schema.json",
query_path = "src/api/graphql/fetch_pull_request.graphql"
extern_enums("PullRequestState")
)]
struct PullRequestQuery;
#[derive(graphql_client::GraphQLQuery)]
#[graphql(
schema_path = "src/api/graphql/schema.json",
query_path = "src/api/graphql/fetch_pull_request_timeline.graphql"
)]
struct PullRequestTimelineQuery;
pub(super) type PullRequestTimelineResponse = pull_request_timeline_query::ResponseData;
#[cfg(test)]
pub(super) type PullRequestTimelineResponseNode = PullRequestTimelineQueryNode;
#[cfg(test)]
pub(super) type PullRequestTimelineNode =
pull_request_timeline_query::PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes;
use self::pull_request_timeline_query::{
PullRequestReviewState, PullRequestTimelineQueryNode,
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes, actorFields, actorFieldsOn,
assigneeFields, requestedReviewerFields,
};
#[derive(Clone)]
pub(crate) struct ListPullRequests {
pub filter: Option<&'static str>,
@@ -103,20 +289,15 @@ impl query::QueryFn for ListPullRequests {
None => "is:pr archived:false sort:updated-desc".into(),
};
let gql = PullRequestQuery::build_query(pull_request_query::Variables {
query: query_string,
});
let gql =
PullRequestPaginationQuery::build_query(pull_request_pagination_query::Variables {
query: query_string,
});
let res = c
.github_request(Method::POST, "/graphql")?
.json(&gql)
.send()
.await?;
let res = c.github_graphql_request(&gql)?.send().await?;
let data = api::parse_graphql_response::<pull_request_query::ResponseData>(res)
.await?
.data
.unwrap();
let (_, data) =
api::parse_graphql_response::<pull_request_pagination_query::ResponseData>(res).await?;
Ok(PullRequestPaginatedResponse {
items: data
@@ -127,10 +308,11 @@ impl query::QueryFn for ListPullRequests {
.flatten()
.filter_map(|edge| {
edge.node.and_then(|n| match n {
PullRequestQuerySearchEdgesNode::PullRequest(p) => {
PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
Some(PullRequest {
id: p.id.into(),
title: p.title,
state: p.state.into(),
state: p.state,
is_draft: p.is_draft,
repo_slug: format!(
"{}/{}",
@@ -149,3 +331,399 @@ impl query::QueryFn for ListPullRequests {
})
}
}
#[derive(Clone)]
pub(crate) struct FetchPullRequest {
pub(crate) id: Id,
}
impl query::QueryFn for FetchPullRequest {
type Data = DetailedPullRequest;
type Error = api::Error;
type Context = api::QueryContext;
fn key(&self) -> query::Key {
format!("issues/{}", self.id).into()
}
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
#[cfg(debug_assertions)]
if c.should_use_fixtures {
return super::mock::fetch_pull_request(&self.id);
}
let gql = PullRequestQuery::build_query(pull_request_query::Variables {
id: self.id.clone().into(),
});
let res = c.github_graphql_request(&gql)?.send().await?;
let (_, data) =
api::parse_graphql_response::<pull_request_query::ResponseData>(res).await?;
data.node
.ok_or(api::Error::MalformedResponse(
"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,
}),
_ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestQuery".into(),
)),
})
}
}
#[derive(Clone)]
pub(crate) struct FetchPullRequestTimeline {
pub(crate) id: Id,
pub(crate) first: i64,
pub(crate) after: Option<String>,
}
impl FetchPullRequestTimeline {
pub(crate) fn new(id: Id, first: i64, after: Option<String>) -> Self {
Self { id, first, after }
}
}
impl query::QueryFn for FetchPullRequestTimeline {
type Data = PullRequestTimeline;
type Error = api::Error;
type Context = api::QueryContext;
fn key(&self) -> query::Key {
format!(
"issues/{}/timeline?first={}&after={}",
self.id,
self.first,
self.after.as_deref().unwrap_or_default()
)
.into()
}
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
fn normalize_actor(actor: actorFields) -> TimelineActor {
let actorFields {
login,
avatar_url,
on,
} = actor;
TimelineActor {
kind: match on {
actorFieldsOn::Bot => "Bot",
actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
actorFieldsOn::Mannequin => "Mannequin",
actorFieldsOn::Organization => "Organization",
actorFieldsOn::User => "User",
}
.into(),
name: login,
avatar_url: Some(avatar_url),
}
}
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),
},
}
}
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),
},
}
}
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",
}
.into()
}
fn normalize_item(
value: PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes,
) -> PullRequestTimelineItem {
match value {
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AssignedEvent(
event,
) => PullRequestTimelineItem::Assigned {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
assignee: event.assignee.map(normalize_assignee),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::UnassignedEvent(
event,
) => PullRequestTimelineItem::Unassigned {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
assignee: event.assignee.map(normalize_assignee),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::IssueComment(
event,
) => PullRequestTimelineItem::Comment {
created_at: event.created_at,
author: event.author.map(normalize_actor),
body: event.body,
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::PullRequestCommit(
event,
) => PullRequestTimelineItem::Commit {
committed_at: event.commit.committed_date,
abbreviated_oid: event.commit.abbreviated_oid,
message_headline: event.commit.message_headline,
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::PullRequestReview(
event,
) => PullRequestTimelineItem::Review {
created_at: event.created_at,
author: event.author.map(normalize_actor),
state: normalize_review_state(event.state),
body: event.body,
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewRequestedEvent(
event,
) => PullRequestTimelineItem::ReviewRequested {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
reviewer: event.requested_reviewer.map(normalize_requested_reviewer),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewRequestRemovedEvent(
event,
) => PullRequestTimelineItem::ReviewRequestRemoved {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
reviewer: event.requested_reviewer.map(normalize_requested_reviewer),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewDismissedEvent(
event,
) => PullRequestTimelineItem::ReviewDismissed {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::MergedEvent(
event,
) => PullRequestTimelineItem::Merged {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ClosedEvent(
event,
) => PullRequestTimelineItem::Closed {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReopenedEvent(
event,
) => PullRequestTimelineItem::Reopened {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ConvertToDraftEvent(
event,
) => PullRequestTimelineItem::ConvertToDraft {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReadyForReviewEvent(
event,
) => PullRequestTimelineItem::ReadyForReview {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::HeadRefForcePushedEvent(
event,
) => PullRequestTimelineItem::HeadRefForcePushed {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
before_commit_oid: event.before_commit.map(|commit| commit.abbreviated_oid),
after_commit_oid: event.after_commit.map(|commit| commit.abbreviated_oid),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::BaseRefChangedEvent(
event,
) => PullRequestTimelineItem::BaseRefChanged {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::LabeledEvent(
event,
) => PullRequestTimelineItem::Labeled {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
label: event.label.name,
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::UnlabeledEvent(
event,
) => PullRequestTimelineItem::Unlabeled {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
label: event.label.name,
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::MilestonedEvent(
event,
) => PullRequestTimelineItem::Milestoned {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
milestone_title: event.milestone_title,
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::DemilestonedEvent(
event,
) => PullRequestTimelineItem::Demilestoned {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
milestone_title: event.milestone_title,
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReferencedEvent(
event,
) => PullRequestTimelineItem::Referenced {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::CrossReferencedEvent(
event,
) => PullRequestTimelineItem::CrossReferenced {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AutoMergeEnabledEvent(
event,
) => PullRequestTimelineItem::AutoMergeEnabled {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AutoMergeDisabledEvent(
event,
) => PullRequestTimelineItem::AutoMergeDisabled {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
reason: event.reason.unwrap_or_default(),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AddedToMergeQueueEvent(
event,
) => PullRequestTimelineItem::AddedToMergeQueue {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::RemovedFromMergeQueueEvent(
event,
) => PullRequestTimelineItem::RemovedFromMergeQueue {
created_at: event.created_at,
actor: event.actor.map(normalize_actor),
},
_ => PullRequestTimelineItem::Other {
typename: "Other".into(),
},
}
}
#[cfg(debug_assertions)]
let data = if c.should_use_fixtures {
super::mock::fetch_pull_request_timeline(&self.id, self.after.as_deref())?
} else {
let gql =
PullRequestTimelineQuery::build_query(pull_request_timeline_query::Variables {
id: self.id.clone().into(),
first: self.first,
after: self.after.clone(),
});
let res = c.github_graphql_request(&gql)?.send().await?;
api::parse_graphql_response::<PullRequestTimelineResponse>(res)
.await?
.1
};
#[cfg(not(debug_assertions))]
let data = {
let gql =
PullRequestTimelineQuery::build_query(pull_request_timeline_query::Variables {
id: self.id.clone().into(),
first: self.first,
after: self.after.clone(),
});
let res = c.github_graphql_request(&gql)?.send().await?;
api::parse_graphql_response::<PullRequestTimelineResponse>(res)
.await?
.1
};
let pull_request = data
.node
.ok_or(api::Error::MalformedResponse(
"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(),
)),
})?;
let timeline = pull_request.timeline_items;
let items = timeline
.nodes
.unwrap_or_default()
.into_iter()
.flatten()
.map(normalize_item)
.collect();
Ok(PullRequestTimeline {
items,
end_cursor: timeline.page_info.end_cursor,
has_next_page: timeline.page_info.has_next_page,
})
}
}

View File

@@ -29,11 +29,56 @@ pub(crate) fn list_pull_requests(
page: u32,
) -> Result<issues::PullRequestPaginatedResponse, api::Error> {
let filter = filter.unwrap_or_default();
let json = issues_pull_requests(filter, page).ok_or_else(|| {
api::Error::MissingMockFixture(format!("issues.pull_requests filter={filter} page={page}"))
let fixture_filter = issue_filter_fixture_key(filter);
let json = issues_pull_requests(fixture_filter, page).ok_or_else(|| {
api::Error::MissingMockFixture(format!(
"issues.pull_requests filter={filter} fixture_filter={fixture_filter} page={page}"
))
})?;
parse_fixture(&format!("issues.pull_requests.{filter}.page{page}"), json)
parse_fixture(
&format!("issues.pull_requests.{fixture_filter}.page{page}"),
json,
)
}
pub(crate) fn fetch_pull_request(
id: &issues::Id,
) -> Result<issues::DetailedPullRequest, api::Error> {
let id = id.to_string();
let json = issues_pull_request(&id)
.ok_or_else(|| api::Error::MissingMockFixture(format!("issues.pull_request id={id}")))?;
parse_fixture(&format!("issues.pull_request.{id}"), json)
}
pub(crate) fn fetch_pull_request_timeline(
id: &issues::Id,
after: Option<&str>,
) -> Result<issues::PullRequestTimelineResponse, api::Error> {
let id = id.to_string();
let json = issues_pull_request_timeline(&id, after).ok_or_else(|| {
api::Error::MissingMockFixture(format!(
"issues.pull_request_timeline id={id} after={}",
after.unwrap_or_default()
))
})?;
parse_fixture(
&format!(
"issues.pull_request_timeline.{id}.after.{}",
after.unwrap_or("start")
),
json,
)
.map_err(|err| {
println!(
"[mock fixture] failed to parse issues.pull_request_timeline.{id}.after.{}: {:?}",
after.unwrap_or("start"),
err
);
err
})
}
fn parse_fixture<T>(name: &str, json: &'static str) -> Result<T, api::Error>
@@ -42,6 +87,184 @@ where
{
serde_json::from_str(json).map_err(|err| {
println!("[mock fixture] failed to parse {name}: {err}");
api::Error::MalformedResponse(err)
api::Error::MalformedResponse(err.to_string())
})
}
fn issue_filter_fixture_key(filter: &str) -> &str {
let filter = filter.trim();
if filter.is_empty() {
return "all";
}
if filter.contains("author:@me") {
return "created";
}
if filter.contains("review-requested:@me") || filter.contains("assignee:@me") {
return "assigned";
}
filter
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_pull_request_fixtures_parse_with_current_filter_strings() {
let authored = list_pull_requests(Some("author:@me state:open"), 1)
.expect("authored fixture should parse");
let assigned = list_pull_requests(Some("review-requested:@me"), 1)
.expect("assigned fixture should parse");
let all = list_pull_requests(None, 1).expect("all fixture should parse");
assert_eq!(authored.items[0].id, issues::Id::from("PR_kwDONovem85"));
assert_eq!(assigned.items[0].state, issues::PullRequestState::Merged);
assert_eq!(all.items[0].state, issues::PullRequestState::Open);
}
#[test]
fn pull_request_detail_fixtures_parse() {
let merged = fetch_pull_request(&issues::Id::from("PR_kwDOSprint62"))
.expect("merged pull request fixture should parse");
let documented_failover = fetch_pull_request(&issues::Id::from("PR_kwDOInfra19"))
.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");
assert_eq!(merged.state, issues::PullRequestState::Merged);
assert!(merged.body.contains("| Stage | Owner | Status |"));
assert!(
documented_failover
.body
.contains("./scripts/failover promote-standby")
);
assert!(dashboard_markdown.body.contains("```rust"));
}
#[test]
fn pull_request_timeline_fixtures_parse() {
let first_page = fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), None)
.expect("first timeline fixture page should parse");
let first_page_json: serde_json::Value =
serde_json::from_str(issues_pull_request_timeline("PR_kwDOSprint62", None).unwrap())
.expect("first timeline fixture json should parse");
let after = first_page_json
.get("node")
.and_then(|node| node.get("timelineItems"))
.and_then(|timeline| timeline.get("pageInfo"))
.and_then(|page_info| page_info.get("endCursor"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.expect("first timeline fixture page should provide an end cursor");
let second_page =
fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), Some(&after))
.expect("second timeline fixture page should parse");
let second_page_json: serde_json::Value = serde_json::from_str(
issues_pull_request_timeline("PR_kwDOSprint62", Some(&after)).unwrap(),
)
.expect("second timeline fixture json should parse");
let first_nodes = first_page_json
.get("node")
.and_then(|node| node.get("timelineItems"))
.and_then(|timeline| timeline.get("nodes"))
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.expect("first timeline fixture page should contain nodes");
let second_has_next_page = second_page_json
.get("node")
.and_then(|node| node.get("timelineItems"))
.and_then(|timeline| timeline.get("pageInfo"))
.and_then(|page_info| page_info.get("hasNextPage"))
.and_then(serde_json::Value::as_bool)
.expect("second timeline fixture page should contain pagination data");
let third_cursor = second_page_json
.get("node")
.and_then(|node| node.get("timelineItems"))
.and_then(|timeline| timeline.get("pageInfo"))
.and_then(|page_info| page_info.get("endCursor"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.expect("second timeline fixture page should provide an end cursor");
let third_page =
fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), Some(&third_cursor))
.expect("third timeline fixture page should parse");
let third_page_json: serde_json::Value = serde_json::from_str(
issues_pull_request_timeline("PR_kwDOSprint62", Some(&third_cursor)).unwrap(),
)
.expect("third timeline fixture json should parse");
let first_page_nodes = match first_page.node.as_ref() {
Some(issues::PullRequestTimelineResponseNode::PullRequest(pull_request)) => {
pull_request
.timeline_items
.nodes
.as_ref()
.expect("first timeline fixture page should contain timeline nodes")
}
_ => panic!("first timeline fixture page should resolve to a pull request node"),
};
let second_page_nodes = match second_page.node.as_ref() {
Some(issues::PullRequestTimelineResponseNode::PullRequest(pull_request)) => {
pull_request
.timeline_items
.nodes
.as_ref()
.expect("second timeline fixture page should contain timeline nodes")
}
_ => panic!("second timeline fixture page should resolve to a pull request node"),
};
let third_page_nodes = match third_page.node.as_ref() {
Some(issues::PullRequestTimelineResponseNode::PullRequest(pull_request)) => {
pull_request
.timeline_items
.nodes
.as_ref()
.expect("third timeline fixture page should contain timeline nodes")
}
_ => panic!("third timeline fixture page should resolve to a pull request node"),
};
assert_eq!(
first_page_json["node"]["timelineItems"]["pageInfo"]["endCursor"],
"timeline:PR_kwDOSprint62:page1"
);
assert!(first_nodes >= 5);
assert!(second_has_next_page);
assert_eq!(
second_page_json["node"]["timelineItems"]["pageInfo"]["hasNextPage"],
true
);
assert_eq!(
third_page_json["node"]["timelineItems"]["pageInfo"]["hasNextPage"],
false
);
assert!(matches!(
first_page_nodes.first(),
Some(Some(issues::PullRequestTimelineNode::AssignedEvent(_)))
));
assert!(second_page_nodes.iter().flatten().any(|item| matches!(
item,
issues::PullRequestTimelineNode::AutoMergeDisabledEvent(_)
)));
assert!(
third_page_nodes
.iter()
.flatten()
.any(|item| matches!(item, issues::PullRequestTimelineNode::MergedEvent(_)))
);
}
}

502
src/component/markdown.rs Normal file
View File

@@ -0,0 +1,502 @@
// markdown treesitter playground: https://ikatyang.github.io/tree-sitter-markdown/
use std::ops::Range;
use gpui::{AppContext, ParentElement, Refineable, RenderOnce, Styled, div, px, rems};
use tree_sitter::Node;
use crate::{app, theme};
const MARKDOWN_KIND_ID_SETEXT_H1_UNDERLINE: u16 = 8;
const MARKDOWN_KIND_ID_SETEXT_H2_UNDERLINE: u16 = 9;
const MARKDOWN_KIND_ID_ATX_H1_MARKER: u16 = 11;
const MARKDOWN_KIND_ID_ATX_H2_MARKER: u16 = 12;
const MARKDOWN_KIND_ID_ATX_H3_MARKER: u16 = 13;
const MARKDOWN_KIND_ID_ATX_H4_MARKER: u16 = 14;
const MARKDOWN_KIND_ID_ATX_H5_MARKER: u16 = 15;
const MARKDOWN_KIND_ID_ATX_H6_MARKER: u16 = 16;
const MARKDOWN_KIND_ID_LIST_MARKER: u16 = 48;
const MARKDOWN_KIND_ID_BACKSLASH_ESCAPE: u16 = 56;
const MARKDOWN_KIND_ID_CHARACTER_REFERENCE: u16 = 57;
const MARKDOWN_KIND_ID_TABLE_COLUMN_ALIGNMENT: u16 = 107;
const MARKDOWN_KIND_ID_HARD_LINE_BREAK: u16 = 110;
const MARKDOWN_KIND_ID_SOFT_LINE_BREAK: u16 = 111;
const MARKDOWN_KIND_ID_HTML_TAG_NAME: u16 = 117;
const MARKDOWN_KIND_ID_VIRTUAL_SPACE: u16 = 118;
const MARKDOWN_KIND_ID_DOCUMENT: u16 = 119;
const MARKDOWN_KIND_ID_THEMATIC_BREAK: u16 = 122;
const MARKDOWN_KIND_ID_PARAGRAPH: u16 = 124;
const MARKDOWN_KIND_ID_LINK_REFERENCE_DEFINITION: u16 = 126;
const MARKDOWN_KIND_ID_SETEXT_HEADING: u16 = 129;
const MARKDOWN_KIND_ID_ATX_HEADING: u16 = 132;
const MARKDOWN_KIND_ID_INDENTED_CODE_BLOCK: u16 = 134;
const MARKDOWN_KIND_ID_FENCED_CODE_BLOCK: u16 = 136;
const MARKDOWN_KIND_ID_CODE_FENCE_CONTENT: u16 = 138;
const MARKDOWN_KIND_ID_HTML_BLOCK_SCRIPT: u16 = 140;
const MARKDOWN_KIND_ID_HTML_BLOCK_COMMENT: u16 = 142;
const MARKDOWN_KIND_ID_HTML_BLOCK_PROCESSING: u16 = 144;
const MARKDOWN_KIND_ID_HTML_BLOCK_DECLARATION: u16 = 146;
const MARKDOWN_KIND_ID_HTML_BLOCK_CDATA: u16 = 148;
const MARKDOWN_KIND_ID_HTML_BLOCK_DIV: u16 = 150;
const MARKDOWN_KIND_ID_HTML_BLOCK_CMP: u16 = 152;
const MARKDOWN_KIND_ID_BLOCK_QUOTE: u16 = 154;
const MARKDOWN_KIND_ID_TIGHT_LIST: u16 = 156;
const MARKDOWN_KIND_ID_LOOSE_LIST: u16 = 158;
const MARKDOWN_KIND_ID_LIST_ITEM_TIGHT: u16 = 160;
const MARKDOWN_KIND_ID_TASK_LIST_ITEM_TIGHT: u16 = 161;
const MARKDOWN_KIND_ID_LIST_ITEM_LOOSE: u16 = 163;
const MARKDOWN_KIND_ID_TASK_LIST_ITEM_LOOSE: u16 = 164;
const MARKDOWN_KIND_ID_PARAGRAPH_TASK_LIST: u16 = 166;
const MARKDOWN_KIND_ID_SETEXT_HEADING_TASK_LIST: u16 = 168;
const MARKDOWN_KIND_ID_HEADING_CONTENT: u16 = 169;
const MARKDOWN_KIND_ID_TABLE: u16 = 170;
const MARKDOWN_KIND_ID_TABLE_HEADER_ROW: u16 = 172;
const MARKDOWN_KIND_ID_TABLE_DELIMITER_ROW: u16 = 174;
const MARKDOWN_KIND_ID_TABLE_DATA_ROW: u16 = 176;
const MARKDOWN_KIND_ID_EMPHASIS: u16 = 181;
const MARKDOWN_KIND_ID_STRONG_EMPHASIS: u16 = 182;
const MARKDOWN_KIND_ID_STRIKETHROUGH: u16 = 183;
const MARKDOWN_KIND_ID_LINK: u16 = 184;
const MARKDOWN_KIND_ID_IMAGE: u16 = 185;
const MARKDOWN_KIND_ID_LINK_DESTINATION: u16 = 190;
const MARKDOWN_KIND_ID_LINK_TITLE: u16 = 191;
const MARKDOWN_KIND_ID_WWW_AUTOLINK: u16 = 192;
const MARKDOWN_KIND_ID_URI_AUTOLINK_EXTENDED: u16 = 194;
const MARKDOWN_KIND_ID_EMAIL_AUTOLINK_EXTENDED: u16 = 196;
const MARKDOWN_KIND_ID_URI_AUTOLINK_ANGLE: u16 = 198;
const MARKDOWN_KIND_ID_EMAIL_AUTOLINK_ANGLE: u16 = 199;
const MARKDOWN_KIND_ID_CODE_SPAN: u16 = 200;
const MARKDOWN_KIND_ID_HTML_OPEN_TAG: u16 = 201;
const MARKDOWN_KIND_ID_HTML_SELF_CLOSING_TAG: u16 = 202;
const MARKDOWN_KIND_ID_HTML_CLOSE_TAG: u16 = 204;
const MARKDOWN_KIND_ID_HTML_COMMENT: u16 = 205;
const MARKDOWN_KIND_ID_HTML_PROCESSING_INSTRUCTION: u16 = 206;
const MARKDOWN_KIND_ID_HTML_DECLARATION: u16 = 207;
const MARKDOWN_KIND_ID_HTML_CDATA_SECTION: u16 = 208;
const MARKDOWN_KIND_ID_HTML_ATTRRIBUTE: u16 = 209;
const MARKDOWN_KIND_ID_HTML_ATTRIBUTE_VALUE: u16 = 210;
const MARKDOWN_KIND_ID_TEXT: u16 = 211;
const MARKDOWN_KIND_ID_HTML_ATTRIBUTE_KEY: u16 = 228;
const MARKDOWN_KIND_ID_HTML_DECLARATION_NAME: u16 = 229;
const MARKDOWN_KIND_ID_IMAGE_DESCRIPTION: u16 = 230;
const MARKDOWN_KIND_ID_INFO_STRING: u16 = 231;
const MARKDOWN_KIND_ID_LINE_BREAK: u16 = 232;
const MARKDOWN_KIND_ID_LINK_LABEL: u16 = 233;
const MARKDOWN_KIND_ID_LINK_TEXT: u16 = 234;
const MARKDOWN_KIND_ID_TABLE_CELL: u16 = 235;
const MARKDOWN_KIND_ID_TASK_LIST_ITEM_MARKER: u16 = 236;
pub(crate) struct MarkdownText {
content: gpui::SharedString,
blocks: Vec<ContentBlock>,
}
enum ContentBlock {
Text {
decoration: Option<&'static str>,
text: gpui::SharedString,
highlights: Vec<(Range<usize>, gpui::HighlightStyle)>,
links: Vec<(Range<usize>, gpui::SharedString)>,
style: gpui::StyleRefinement,
},
}
pub(crate) fn new(
content: gpui::SharedString,
cx: &mut gpui::Context<MarkdownText>,
) -> MarkdownText {
let mut view = MarkdownText {
content,
blocks: Vec::new(),
};
view.on_create(cx);
view
}
impl Styled for ContentBlock {
fn style(&mut self) -> &mut gpui::StyleRefinement {
match self {
| ContentBlock::Text { style, .. } => style,
}
}
}
impl MarkdownText {
fn on_create(&mut self, cx: &gpui::Context<Self>) {
let content = self.content.clone();
let t = cx.background_spawn(async move {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(tree_sitter_markdown::language())
.expect("tree-sitter-markdown language should load");
parser.parse(content.as_str(), None)
});
cx.spawn(async |weak, cx| {
if let Some(tree) = t.await {
_ = weak.update(cx, |this, cx| {
let theme = app::current_theme(cx);
this.render_tree(&tree, &theme);
cx.notify();
});
};
})
.detach();
}
fn on_open_link(&self, _link: &str, _cx: &gpui::Context<Self>) {}
fn render_tree(&mut self, tree: &tree_sitter::Tree, theme: &theme::Theme) {
let mut cursor = tree.walk();
cursor.goto_first_child();
fn block_for_node(
cursor: &mut tree_sitter::TreeCursor,
content: &str,
// byte_offset is the number of bytes to offset the content start byte by
byte_offset: usize,
theme: &theme::Theme,
) -> ContentBlock {
let node_start_byte = cursor.node().start_byte();
let mut highlights: Vec<(Range<usize>, gpui::HighlightStyle)> = Vec::new();
let mut links: Vec<(Range<usize>, gpui::SharedString)> = Vec::new();
cursor.goto_first_child();
loop {
let node = cursor.node();
macro_rules! node_range {
() => {
(node.start_byte() - node_start_byte - byte_offset)
..(node.end_byte() - node_start_byte - byte_offset)
};
}
match node.kind_id() {
| MARKDOWN_KIND_ID_EMPHASIS => {
highlights.push((
node_range!(),
gpui::HighlightStyle {
font_style: Some(gpui::FontStyle::Italic),
..Default::default()
},
));
}
| MARKDOWN_KIND_ID_STRONG_EMPHASIS => highlights.push((
node_range!(),
gpui::HighlightStyle {
font_weight: Some(gpui::FontWeight::BOLD),
..Default::default()
},
)),
| MARKDOWN_KIND_ID_LINK => {
if cursor.goto_first_child() {
highlights.push((
node_range!(),
gpui::HighlightStyle {
color: Some(theme.colors.accent.into()),
underline: Some(gpui::UnderlineStyle {
color: Some(theme.colors.accent.into()),
thickness: px(1.),
wavy: false,
}),
..Default::default()
},
));
if cursor.goto_next_sibling()
&& let Ok(src) = cursor.node().utf8_text(content.as_bytes())
{
links
.push((node_range!(), gpui::SharedString::from(String::from(src))));
} else {
// the link src is invalid, use an empty string as a fallback
// link on click handler will ignore empty string
links.push((node_range!(), "".into()))
}
}
}
| _ => {
// extend here to support more markdown node stylings
}
};
if !cursor.goto_next_sibling() {
break;
}
}
cursor.goto_parent();
ContentBlock::Text {
decoration: None,
text: gpui::SharedString::new(
&content[(node_start_byte + byte_offset)..cursor.node().end_byte()],
),
highlights: highlights,
links: links,
style: gpui::StyleRefinement::default(),
}
}
loop {
let current_node = cursor.node();
fn render_fallback_content(
cursor: &tree_sitter::TreeCursor,
content: &str,
blocks: &mut Vec<ContentBlock>,
) {
blocks.push(ContentBlock::Text {
decoration: None,
text: gpui::SharedString::new(&content[cursor.node().byte_range()]),
highlights: Vec::new(),
links: Vec::new(),
style: gpui::StyleRefinement::default(),
});
}
fn render_list_node(
cursor: &mut tree_sitter::TreeCursor,
content: &str,
blocks: &mut Vec<ContentBlock>,
theme: &theme::Theme,
indentation: usize,
) -> bool {
// expected tree shape for node pointed to by cursor:
// tight_list
// list_item
// list_marker
// paragraph
// tight_list <-- recursive point
// go to list_item node
if !cursor.goto_first_child() {
render_fallback_content(&cursor, content, blocks);
return false;
}
let mut list_index = 0;
loop {
if cursor.node().kind_id() != MARKDOWN_KIND_ID_LIST_ITEM_TIGHT
// if is list_item node, dive into list_marker node
|| !cursor.goto_first_child()
{
// encountered non lists item node under tight list node
// dont know what to do, so skipping this node
if !cursor.goto_next_sibling() {
return false;
}
continue;
}
let marker_node = cursor.node();
let marker_content = &content[marker_node.byte_range()];
match marker_content {
// unordered list item
| "-" | "+" | "*" => {
// go to paragraph sibling node
let block = if cursor.goto_next_sibling() {
let mut b = block_for_node(cursor, content, 0, theme);
match b {
| ContentBlock::Text {
ref mut decoration, ..
} => *decoration = Some(""),
}
b
} else {
ContentBlock::Text {
decoration: Some(""),
text: gpui::SharedString::default(),
highlights: Vec::new(),
links: Vec::new(),
style: gpui::StyleRefinement::default(),
}
}
.text_sm()
.text_color(theme.colors.text)
.p(rems(indentation as f32));
blocks.push(block);
// if there is a nested tight_light after paragraph
// render it recursively
if cursor.goto_next_sibling()
&& cursor.node().kind_id() == MARKDOWN_KIND_ID_TIGHT_LIST
{
render_list_node(cursor, content, blocks, theme, indentation + 1);
}
}
| _ => {
render_fallback_content(&cursor, content, blocks);
return false;
}
}
// go back to list_item node
cursor.goto_parent();
if !cursor.goto_next_sibling() {
// no more list_item in tight_list node
// go back up to tight_list node
cursor.goto_parent();
return true;
}
}
}
match current_node.kind_id() {
| MARKDOWN_KIND_ID_ATX_HEADING => {
if !cursor.goto_first_child() {
render_fallback_content(&cursor, &self.content, &mut self.blocks);
continue;
}
let marker_node_kind = cursor.node().kind_id();
let block = if cursor.goto_next_sibling()
&& cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT
{
// because HEADING_CONTENT node includes the space after the heading marker
// offset by 1 to exclude the space
block_for_node(&mut cursor, &self.content, 1, theme)
} else {
ContentBlock::Text {
decoration: None,
text: gpui::SharedString::new(&self.content[current_node.byte_range()]),
highlights: Vec::new(),
links: Vec::new(),
style: gpui::StyleRefinement::default(),
}
};
let block = match marker_node_kind {
| MARKDOWN_KIND_ID_ATX_H1_MARKER => block
.text_size(rems(2.25))
.font_weight(gpui::FontWeight::EXTRA_BOLD)
.mb_8(),
| MARKDOWN_KIND_ID_ATX_H2_MARKER => block
.text_2xl()
.font_weight(gpui::FontWeight::BOLD)
.mt_12()
.mb_6(),
| MARKDOWN_KIND_ID_ATX_H3_MARKER => block
.text_xl()
.font_weight(gpui::FontWeight::SEMIBOLD)
.mt_8()
.mb_3(),
| MARKDOWN_KIND_ID_ATX_H4_MARKER => block
.text_base()
.font_weight(gpui::FontWeight::SEMIBOLD)
.mt_6()
.mb_2(),
| _ => block,
}
.text_color(theme.colors.text);
cursor.goto_parent();
self.blocks.push(block);
}
| MARKDOWN_KIND_ID_PARAGRAPH => {
let block = block_for_node(&mut cursor, &self.content, 0, theme)
.text_color(theme.colors.text)
.text_sm();
self.blocks.push(block);
}
| MARKDOWN_KIND_ID_TIGHT_LIST => {
let is_rendered =
render_list_node(&mut cursor, &self.content, &mut self.blocks, theme, 0);
if !is_rendered {
continue;
}
}
| _ => {
println!(
"[WARN] formatting not implemenetd for node type {:?}",
current_node.kind()
);
let block = block_for_node(&mut cursor, &self.content, 0, theme)
.text_color(theme.colors.text)
.text_sm();
self.blocks.push(block);
}
}
if !cursor.goto_next_sibling() {
break;
}
}
}
}
impl gpui::Render for MarkdownText {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::prelude::Context<Self>,
) -> impl gpui::prelude::IntoElement {
let theme = app::current_theme(cx);
let children = self.blocks.iter().enumerate().map(|(i, block)| {
match block {
| ContentBlock::Text {
decoration,
text,
highlights,
links,
style,
} => {
let styled_text =
gpui::StyledText::new(text.clone()).with_highlights(highlights.clone());
let div = decoration
.map(|d| div().flex().flex_row().gap_2().items_start().child(d))
.unwrap_or_else(|| div());
let mut div = if links.is_empty() {
// if no link in block, interactive text is not needed
div.child(styled_text)
} else {
// if link in block, interactive text is needed
// to handle link clicks
let (link_ranges, srcs): (Vec<_>, Vec<_>) = links.iter().cloned().unzip();
let weak = cx.entity();
let t = gpui::InteractiveText::new(i, styled_text).on_click(
link_ranges,
move |i, _, cx| {
if let Some(src) = srcs.get(i) {
weak.update(cx, |this, cx| {
this.on_open_link(src, cx);
cx.notify();
})
}
},
);
div.child(t)
};
div.style().refine(&style);
div
}
}
});
div().flex().flex_col().children(children)
}
}

View File

@@ -1,3 +1,4 @@
pub(crate) mod button;
pub(crate) mod font_icon;
pub(crate) mod markdown;
pub(crate) mod text;

View File

@@ -100,6 +100,10 @@ impl gpui::RenderOnce for Text {
div = div.text_color(theme.colors.text);
}
if div.style().border_color.as_ref().is_none() {
div = div.border_color(theme.colors.border);
}
div
}
}

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()),
),
)
}