mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(github): pull request tool window, timeline and review flow (#477)
Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication. - List with search, quick filters and paging; a tab per pull request and a New Pull Request tab. - Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen. - Timeline editor: comments, review threads, reactions and suggested changes. - Review in the diff viewer and, for the checked-out branch, in the editor (review mode). - New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands. - CSP: img-src allows https://avatars.githubusercontent.com for avatars. Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane. Co-authored-by: imacross <30753807+imacross@users.noreply.github.com>
This commit is contained in:
@@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability {
|
||||
}
|
||||
let (repo, login) = std::thread::scope(|s| {
|
||||
let repo = s.spawn(|| {
|
||||
let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?;
|
||||
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
|
||||
process::run(cmd, QUICK, None)
|
||||
});
|
||||
let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"]));
|
||||
@@ -278,6 +278,53 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
|
||||
run(root, &["pr", "comment", &n, "--body", body]).map(drop)
|
||||
}
|
||||
|
||||
/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
|
||||
/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
|
||||
/// placeholders from the project's remote).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GhApiRequest {
|
||||
/// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`.
|
||||
pub method: String,
|
||||
pub endpoint: String,
|
||||
/// JSON request body, sent on stdin.
|
||||
pub body: Option<String>,
|
||||
/// Follow REST `Link` pages; the result is the array of all pages (`--slurp`).
|
||||
pub paginate: bool,
|
||||
}
|
||||
|
||||
const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"];
|
||||
|
||||
/// `gh api` arguments for `req`.
|
||||
pub fn api_args(req: &GhApiRequest) -> IdeResult<Vec<String>> {
|
||||
let method = req.method.to_ascii_uppercase();
|
||||
if !METHODS.contains(&method.as_str()) {
|
||||
return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method)));
|
||||
}
|
||||
let endpoint = req.endpoint.trim();
|
||||
if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") {
|
||||
return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}")));
|
||||
}
|
||||
let mut args: Vec<String> = vec!["api".into(), "--method".into(), method, endpoint.into()];
|
||||
if req.body.is_some() {
|
||||
args.push("--input".into());
|
||||
args.push("-".into());
|
||||
}
|
||||
if req.paginate {
|
||||
args.push("--paginate".into());
|
||||
args.push("--slurp".into());
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`).
|
||||
pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult<String> {
|
||||
let args = api_args(req)?;
|
||||
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
/// Revisions to diff a pull request's files with, present in the local repository.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -34,6 +34,12 @@ pub struct GhRepo {
|
||||
pub name_with_owner: String,
|
||||
pub url: String,
|
||||
pub default_branch: Option<String>,
|
||||
/// Merge methods the repository allows (all of them when gh does not say).
|
||||
pub merge_commit_allowed: bool,
|
||||
pub squash_merge_allowed: bool,
|
||||
pub rebase_merge_allowed: bool,
|
||||
/// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown.
|
||||
pub viewer_permission: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -152,6 +158,8 @@ pub struct PullRequestDetails {
|
||||
pub base_oid: String,
|
||||
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
|
||||
pub mergeable: String,
|
||||
/// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`.
|
||||
pub merge_state_status: String,
|
||||
pub is_cross_repository: bool,
|
||||
pub merged_at: Option<String>,
|
||||
pub closed_at: Option<String>,
|
||||
@@ -242,7 +250,7 @@ impl RawSummary {
|
||||
pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions";
|
||||
|
||||
/// Fields requested from `gh pr view`.
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,mergeStateStatus,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
|
||||
pub fn parse_list(json: &str) -> serde_json::Result<Vec<PullRequestSummary>> {
|
||||
let raw: Vec<RawSummary> = serde_json::from_str(json)?;
|
||||
@@ -348,6 +356,7 @@ struct RawDetails {
|
||||
head_ref_oid: String,
|
||||
base_ref_oid: String,
|
||||
mergeable: String,
|
||||
merge_state_status: String,
|
||||
is_cross_repository: bool,
|
||||
merged_at: Option<String>,
|
||||
closed_at: Option<String>,
|
||||
@@ -442,6 +451,7 @@ pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
|
||||
head_oid: raw.head_ref_oid,
|
||||
base_oid: raw.base_ref_oid,
|
||||
mergeable: raw.mergeable,
|
||||
merge_state_status: raw.merge_state_status,
|
||||
is_cross_repository: raw.is_cross_repository,
|
||||
merged_at: raw.merged_at.filter(|s| !s.is_empty()),
|
||||
closed_at: raw.closed_at.filter(|s| !s.is_empty()),
|
||||
@@ -534,6 +544,10 @@ struct RawRepo {
|
||||
name_with_owner: String,
|
||||
url: String,
|
||||
default_branch_ref: Option<RawBranchRef>,
|
||||
merge_commit_allowed: Option<bool>,
|
||||
squash_merge_allowed: Option<bool>,
|
||||
rebase_merge_allowed: Option<bool>,
|
||||
viewer_permission: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -542,13 +556,21 @@ struct RawBranchRef {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// `gh repo view --json nameWithOwner,url,defaultBranchRef`.
|
||||
/// Fields requested from `gh repo view`.
|
||||
pub const REPO_FIELDS: &str =
|
||||
"nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
|
||||
|
||||
/// `gh repo view --json` with [`REPO_FIELDS`].
|
||||
pub fn parse_repo(json: &str) -> serde_json::Result<GhRepo> {
|
||||
let raw: RawRepo = serde_json::from_str(json)?;
|
||||
Ok(GhRepo {
|
||||
name_with_owner: raw.name_with_owner,
|
||||
url: raw.url,
|
||||
default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()),
|
||||
merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true),
|
||||
squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true),
|
||||
rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true),
|
||||
viewer_permission: raw.viewer_permission.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,30 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
run_with_input(cmd, None, timeout, cancel)
|
||||
}
|
||||
|
||||
/// [`run`] with `input` written to the child's stdin (then closed).
|
||||
pub(crate) fn run_with_input(
|
||||
mut cmd: Command,
|
||||
input: Option<&str>,
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
if input.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
|
||||
let input = input.to_owned();
|
||||
// A writer thread: a large body must not block on a full pipe while gh is not reading yet.
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let _ = stdin.write_all(input.as_bytes());
|
||||
});
|
||||
}
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"babakks@github.com","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
|
||||
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"babakks@github.com","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
|
||||
|
||||
@@ -22,6 +22,7 @@ fn pr_view() {
|
||||
assert_eq!(pr.summary.number, 14768);
|
||||
assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps");
|
||||
assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28");
|
||||
assert_eq!(pr.merge_state_status, "UNKNOWN");
|
||||
assert_eq!(pr.files.len(), 2);
|
||||
assert_eq!(pr.files[0].change_type, "MODIFIED");
|
||||
assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0");
|
||||
@@ -69,6 +70,16 @@ fn repo_view() {
|
||||
.unwrap();
|
||||
assert_eq!(r.name_with_owner, "l0ng-ai/openidea");
|
||||
assert_eq!(r.default_branch.as_deref(), Some("main"));
|
||||
assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed");
|
||||
assert_eq!(r.viewer_permission, "");
|
||||
|
||||
let r = parse_repo(
|
||||
r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r.merge_commit_allowed);
|
||||
assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed);
|
||||
assert_eq!(r.viewer_permission, "WRITE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,3 +98,24 @@ fn list_arguments() {
|
||||
assert!(joined.contains("--limit 500 --state merged --author @me"));
|
||||
assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_arguments() {
|
||||
let req =
|
||||
GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false };
|
||||
assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]);
|
||||
let req = GhApiRequest {
|
||||
method: "GET".into(),
|
||||
endpoint: "repos/{owner}/{repo}/labels".into(),
|
||||
body: None,
|
||||
paginate: true,
|
||||
};
|
||||
assert_eq!(
|
||||
api_args(&req).unwrap(),
|
||||
["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"]
|
||||
);
|
||||
for bad in ["--hostname", "https://evil.example/x", ""] {
|
||||
assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}");
|
||||
}
|
||||
assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err());
|
||||
}
|
||||
|
||||
@@ -736,6 +736,19 @@ pub async fn vcs_diff_revisions(
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
/// Request tab's three-dot diff.
|
||||
pub async fn vcs_merge_base(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
a: String,
|
||||
b: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.merge_base(&a, &b))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_create_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -976,6 +989,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_diff_revisions", |c| async move {
|
||||
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_merge_base", |c| async move {
|
||||
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
|
||||
});
|
||||
d.add("vcs_create_patch", |c| async move {
|
||||
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::state::AppState;
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_github::{
|
||||
CreatePullRequest, GhAvailability, PrDiffRevisions, PrListQuery, PrReviewComment, PullRequestDetails,
|
||||
CreatePullRequest, GhApiRequest, GhAvailability, PrDiffRevisions, PrListQuery, PrReviewComment, PullRequestDetails,
|
||||
PullRequestSummary, ReviewEvent,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
@@ -128,6 +128,14 @@ pub async fn gh_pr_comment(window: Window, state: State<'_, AppState>, number: u
|
||||
blocking(move || ide_github::add_comment(&root, number, &body)).await
|
||||
}
|
||||
|
||||
/// A GitHub GraphQL / REST call through `gh api`; returns the response body.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn gh_api(window: Window, state: State<'_, AppState>, request: GhApiRequest) -> IdeResult<String> {
|
||||
let root = root(&window, &state)?;
|
||||
blocking(move || ide_github::api(&root, &request)).await
|
||||
}
|
||||
|
||||
/// Fetches what is missing to diff a pull request locally; returns the revisions to compare.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -265,6 +265,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
vcs::vcs_remove_worktree,
|
||||
vcs::vcs_diff_with_worktree,
|
||||
vcs::vcs_diff_revisions,
|
||||
vcs::vcs_merge_base,
|
||||
vcs::vcs_create_patch,
|
||||
vcs::vcs_patch_preview,
|
||||
vcs::vcs_apply_patch,
|
||||
@@ -415,6 +416,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
docker::docker_registry_login,
|
||||
docker::docker_run_env,
|
||||
github::gh_availability,
|
||||
github::gh_api,
|
||||
github::gh_pr_list,
|
||||
github::gh_pr_view,
|
||||
github::gh_pr_review_comments,
|
||||
|
||||
@@ -535,6 +535,19 @@ pub async fn vcs_diff_revisions(
|
||||
ide_host::ops::vcs::vcs_diff_revisions(&state.host, window.label(), repo, from, to).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_merge_base(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
repo: Option<PathBuf>,
|
||||
a: String,
|
||||
b: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
ide_host::ops::vcs::vcs_merge_base(&state.host, window.label(), repo, a, b).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_create_patch(
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:*"
|
||||
"csp": "default-src 'self'; img-src 'self' asset: data: blob: https://avatars.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:*"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -503,6 +503,8 @@ export const commands = {
|
||||
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
|
||||
/** Files that differ between two revisions (the "N files updated" of Update Project). */
|
||||
vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })),
|
||||
/** The best common ancestor of `a` and `b` (`git merge-base`), if any. */
|
||||
vcsMergeBase: (repo: string | null, a: string, b: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_merge_base", { repo, a, b })),
|
||||
vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { repo, source })),
|
||||
vcsPatchPreview: (repo: string | null, patch: string) => typedError<PatchFile[], IdeError>(__TAURI_INVOKE("vcs_patch_preview", { repo, patch })),
|
||||
vcsApplyPatch: (repo: string | null, patch: string) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_apply_patch", { repo, patch })),
|
||||
@@ -897,6 +899,8 @@ export const commands = {
|
||||
*/
|
||||
dockerRunEnv: (endpoint: DockerEndpoint) => typedError<{ [key in string]: string }, IdeError>(__TAURI_INVOKE("docker_run_env", { endpoint })),
|
||||
ghAvailability: () => typedError<GhAvailability, IdeError>(__TAURI_INVOKE("gh_availability")),
|
||||
/** A GitHub GraphQL / REST call through `gh api`; returns the response body. */
|
||||
ghApi: (request: GhApiRequest) => typedError<string, IdeError>(__TAURI_INVOKE("gh_api", { request })),
|
||||
ghPrList: (query: PrListQuery) => typedError<PullRequestSummary[], IdeError>(__TAURI_INVOKE("gh_pr_list", { query })),
|
||||
ghPrView: (number: number) => typedError<PullRequestDetails, IdeError>(__TAURI_INVOKE("gh_pr_view", { number })),
|
||||
ghPrReviewComments: (number: number) => typedError<PrReviewComment[], IdeError>(__TAURI_INVOKE("gh_pr_review_comments", { number })),
|
||||
@@ -3121,6 +3125,21 @@ export type GeneralSettings = {
|
||||
projectName?: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
|
||||
* `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
|
||||
* placeholders from the project's remote).
|
||||
*/
|
||||
export type GhApiRequest = {
|
||||
/** `GET`, `POST`, `PATCH`, `PUT`, `DELETE`. */
|
||||
method: string,
|
||||
endpoint: string,
|
||||
/** JSON request body, sent on stdin. */
|
||||
body: string | null,
|
||||
/** Follow REST `Link` pages; the result is the array of all pages (`--slurp`). */
|
||||
paginate: boolean,
|
||||
};
|
||||
|
||||
/** Whether pull requests can be shown for the project, and why not. */
|
||||
export type GhAvailability = { kind: "ready"; repo: GhRepo;
|
||||
/** The signed-in user. */
|
||||
@@ -3137,6 +3156,12 @@ export type GhRepo = {
|
||||
nameWithOwner: string,
|
||||
url: string,
|
||||
defaultBranch: string | null,
|
||||
/** Merge methods the repository allows (all of them when gh does not say). */
|
||||
mergeCommitAllowed: boolean,
|
||||
squashMergeAllowed: boolean,
|
||||
rebaseMergeAllowed: boolean,
|
||||
/** The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown. */
|
||||
viewerPermission: string,
|
||||
};
|
||||
|
||||
export type GitExecutableInfo = {
|
||||
@@ -4745,6 +4770,8 @@ export type PullRequestDetails = {
|
||||
baseOid: string,
|
||||
/** `MERGEABLE`, `CONFLICTING`, `UNKNOWN`. */
|
||||
mergeable: string,
|
||||
/** GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`. */
|
||||
mergeStateStatus: string,
|
||||
isCrossRepository: boolean,
|
||||
mergedAt: string | null,
|
||||
closedAt: string | null,
|
||||
|
||||
@@ -276,6 +276,33 @@ export class LaneMarker extends GutterMarker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The floating row at the editor's top-right (IDEA's inspection widget area): widgets
|
||||
* append their element to it (CSS `order` sorts them; lower first) and remove it when
|
||||
* done. The inspection widget sits there; code review adds its Review Mode group.
|
||||
*
|
||||
* const el = document.createElement("div");
|
||||
* el.style.order = "-1";
|
||||
* editorCorner(view).appendChild(el);
|
||||
*/
|
||||
export function editorCorner(view: EditorView): HTMLElement {
|
||||
const found = view.dom.querySelector<HTMLElement>(":scope > .cm-openidea-corner");
|
||||
if (found) return found;
|
||||
const el = document.createElement("div");
|
||||
el.className = "cm-openidea-corner";
|
||||
Object.assign(el.style, {
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "16px",
|
||||
zIndex: "5",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
});
|
||||
view.dom.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
// --- language support without a language server ----------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { forEachDiagnostic } from "@codemirror/lint";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { editorCorner } from "@/editor/api";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import { gotoDiagnostic } from "./diagnostics";
|
||||
import { iconNode } from "./iconMarkup";
|
||||
@@ -50,7 +51,7 @@ class InspectionWidget {
|
||||
this.dom.className = "cm-openidea-inspection";
|
||||
this.dom.setAttribute("role", "toolbar");
|
||||
this.dom.setAttribute("aria-label", "Inspections");
|
||||
view.dom.appendChild(this.dom);
|
||||
editorCorner(view).appendChild(this.dom);
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -123,10 +124,6 @@ const inspectionPlugin = ViewPlugin.fromClass(InspectionWidget);
|
||||
|
||||
const inspectionTheme = EditorView.theme({
|
||||
".cm-openidea-inspection": {
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "16px",
|
||||
zIndex: "5",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1px",
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
.view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.flex {
|
||||
flex: 1;
|
||||
}
|
||||
.dim {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.ellipsis {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.repo {
|
||||
min-width: 0;
|
||||
margin-left: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.banner {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 2px 6px 2px 10px;
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.bannerText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* List pane */
|
||||
.listPane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.filters {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
.filterRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
min-height: var(--h-control);
|
||||
}
|
||||
.filterRow > :not(.flex) {
|
||||
flex: none;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.listRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.listTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.listMeta {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Details pane */
|
||||
.details {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-input-large);
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.conflict {
|
||||
color: var(--danger);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.actions > :not(.flex) {
|
||||
flex: none;
|
||||
}
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.section {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sectionTitle {
|
||||
margin: 0 0 6px;
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.checkRow,
|
||||
.commitRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: var(--h-row);
|
||||
}
|
||||
.check-success,
|
||||
.review-APPROVED {
|
||||
color: var(--success);
|
||||
}
|
||||
.check-failure,
|
||||
.review-CHANGES_REQUESTED {
|
||||
color: var(--danger);
|
||||
}
|
||||
.check-pending,
|
||||
.review-REQUESTED {
|
||||
color: var(--warning);
|
||||
}
|
||||
.check-skipped,
|
||||
.check-cancelled,
|
||||
.check-neutral,
|
||||
.review-COMMENTED,
|
||||
.review-DISMISSED {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.label {
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-small);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fileRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: var(--h-row);
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
border-radius: var(--radius-row);
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.fileRow:hover {
|
||||
background: var(--bg-tree-hover);
|
||||
}
|
||||
.fileName {
|
||||
flex: none;
|
||||
}
|
||||
.change-ADDED {
|
||||
color: var(--vcs-added);
|
||||
}
|
||||
.change-DELETED {
|
||||
color: var(--vcs-deleted);
|
||||
}
|
||||
.change-MODIFIED,
|
||||
.change-CHANGED,
|
||||
.change-RENAMED,
|
||||
.change-COPIED {
|
||||
color: var(--vcs-modified);
|
||||
}
|
||||
.added {
|
||||
color: var(--vcs-added);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.deleted {
|
||||
color: var(--vcs-deleted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.oid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.comment {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
.commentHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.threadItem {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.threadItem + .threadItem {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.newComment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.markdown {
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.markdown :global(pre) {
|
||||
padding: 6px 8px;
|
||||
overflow: auto;
|
||||
background: var(--bg-editor);
|
||||
border-radius: var(--radius-small);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.markdown :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.markdown :global(a) {
|
||||
color: var(--link);
|
||||
}
|
||||
.markdown :global(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
.markdown :global(h1),
|
||||
.markdown :global(h2),
|
||||
.markdown :global(h3) {
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.formRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.fieldLabel {
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
min-height: 48px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-input);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
@@ -1,609 +0,0 @@
|
||||
/** The Pull Requests tool window: filterable list on the left, the selected pull request on the right. */
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { GhAvailability, PrCheck, PrStateFilter, PullRequestDetails, PullRequestSummary } from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useMarkupHtml } from "@/editor/lsp/markdown";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
SearchField,
|
||||
Select,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import {
|
||||
checksSummary,
|
||||
checksText,
|
||||
relativeTime,
|
||||
reviewDecisionLabel,
|
||||
reviewStateLabel,
|
||||
reviewThreads,
|
||||
stateLabel,
|
||||
} from "./model";
|
||||
import { addComment, checkoutPullRequest, openCommentLocation, openFileDiff, openUrl, submitReview } from "./ops";
|
||||
import { cancelBusy, loadDetails, refreshAll, select, setQuery, useGithub, type DetailTab } from "./store";
|
||||
import styles from "./Github.module.css";
|
||||
import { t, tx } from "@/platform/i18n";
|
||||
|
||||
const STATES: PrStateFilter[] = ["open", "merged", "closed", "all"];
|
||||
|
||||
function Unavailable({ availability }: { availability: GhAvailability }) {
|
||||
const retry = (
|
||||
<Button size="small" onClick={() => void refreshAll()}>
|
||||
<T>Retry</T>
|
||||
</Button>
|
||||
);
|
||||
switch (availability.kind) {
|
||||
case "notInstalled":
|
||||
return (
|
||||
<EmptyState
|
||||
title="GitHub CLI is not installed"
|
||||
actions={
|
||||
<>
|
||||
<Button size="small" onClick={() => void openUrl("https://cli.github.com")}>
|
||||
<T>Install gh…</T>
|
||||
</Button>
|
||||
{retry}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<T>Pull requests use the</T> <code>gh</code> <T>command line tool. Install it, then run</T>{" "}
|
||||
<code>gh auth login</code>.
|
||||
</EmptyState>
|
||||
);
|
||||
case "notLoggedIn":
|
||||
return (
|
||||
<EmptyState
|
||||
title="Not logged in to GitHub"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
void executeAction("terminal.runCommand", {
|
||||
program: "gh",
|
||||
args: ["auth", "login"],
|
||||
name: "gh auth login",
|
||||
})
|
||||
}
|
||||
>
|
||||
<T>Run gh auth login</T>
|
||||
</Button>
|
||||
{retry}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<T>Run</T> <code>gh auth login</code> <T>in a terminal, then retry.</T>
|
||||
</EmptyState>
|
||||
);
|
||||
case "notGitHub":
|
||||
return (
|
||||
<EmptyState title="No GitHub repository" actions={retry}>
|
||||
<div>
|
||||
<T>This project has no remote on GitHub.</T>
|
||||
</div>
|
||||
<div className={styles.hint}>{availability.message}</div>
|
||||
</EmptyState>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<EmptyState title="Could not reach GitHub" actions={retry}>
|
||||
{availability.message}
|
||||
</EmptyState>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function StateBadge({ pr }: { pr: PullRequestSummary }) {
|
||||
const tone = pr.state === "merged" ? "accent" : pr.state === "closed" ? "danger" : pr.isDraft ? "neutral" : "success";
|
||||
return <Badge tone={tone}>{stateLabel(pr)}</Badge>;
|
||||
}
|
||||
|
||||
function Filters() {
|
||||
const query = useGithub((s) => s.query);
|
||||
const loading = useGithub((s) => s.listLoading);
|
||||
const [search, setSearch] = useState(query.search);
|
||||
useEffect(() => {
|
||||
if (search === useGithub.getState().query.search) return;
|
||||
const t = setTimeout(() => setQuery({ search }), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
<SearchField
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search (GitHub syntax)"
|
||||
aria-label="Search pull requests"
|
||||
className={styles.search}
|
||||
/>
|
||||
<div className={styles.filterRow}>
|
||||
<Select
|
||||
value={query.state}
|
||||
options={STATES.map((value) => ({ value, label: t(`github.prState.${value}`) }))}
|
||||
onChange={(state) => setQuery({ state })}
|
||||
aria-label="State"
|
||||
/>
|
||||
<Checkbox checked={query.authorMe} onChange={(authorMe) => setQuery({ authorMe })} label="Mine" />
|
||||
<Checkbox
|
||||
checked={query.reviewRequested}
|
||||
onChange={(reviewRequested) => setQuery({ reviewRequested })}
|
||||
label="Review requested"
|
||||
/>
|
||||
<span className={styles.flex} />
|
||||
{loading ? <Spinner size={14} /> : <IconButton icon="refresh" size="small" action="github.refresh" />}
|
||||
<IconButton icon="plus" size="small" action="github.createPullRequest" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListRow({ pr }: { pr: PullRequestSummary }) {
|
||||
const decision = reviewDecisionLabel(pr.reviewDecision);
|
||||
return (
|
||||
<div className={styles.listRow}>
|
||||
<div className={styles.listTitle}>
|
||||
<span className={styles.ellipsis}>{pr.title}</span>
|
||||
<StateBadge pr={pr} />
|
||||
</div>
|
||||
<div className={styles.listMeta}>
|
||||
#{pr.number} {tx`by ${pr.author}`} · {relativeTime(pr.updatedAt)}
|
||||
{decision ? ` · ${decision}` : ""}
|
||||
{pr.labels.length ? ` · ${pr.labels.map((l) => l.name).join(", ")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PullRequestList() {
|
||||
const list = useGithub((s) => s.list);
|
||||
const error = useGithub((s) => s.listError);
|
||||
const loading = useGithub((s) => s.listLoading);
|
||||
const selected = useGithub((s) => s.selected);
|
||||
const index = list.findIndex((p) => p.number === selected);
|
||||
const menu = (pr: PullRequestSummary, e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const items: MenuItem[] = [
|
||||
{ label: "Checkout", icon: "branch", run: () => void checkoutPullRequest(pr.number) },
|
||||
{ label: "Open in Browser", icon: "external-link", run: () => void openUrl(pr.url) },
|
||||
{ label: "Copy URL", icon: "copy", run: () => void navigator.clipboard?.writeText(pr.url) },
|
||||
];
|
||||
showContextMenu({ x: e.clientX, y: e.clientY }, items);
|
||||
};
|
||||
return (
|
||||
<div className={styles.listPane}>
|
||||
<Filters />
|
||||
{error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
) : (
|
||||
<List<PullRequestSummary>
|
||||
items={list}
|
||||
getKey={(p) => String(p.number)}
|
||||
rowHeight={46}
|
||||
activeIndex={index}
|
||||
onActiveChange={(i) => list[i] && select(list[i]!.number)}
|
||||
onActivate={(p) => select(p.number)}
|
||||
onContextMenu={(p, _i, e) => menu(p, e)}
|
||||
renderItem={(p) => <ListRow pr={p} />}
|
||||
empty={<EmptyState title={loading ? "Loading pull requests…" : "No pull requests"} />}
|
||||
aria-label="Pull requests"
|
||||
className={styles.list}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Markdown({ text }: { text: string }) {
|
||||
const html = useMarkupHtml({ kind: "markdown", value: text });
|
||||
if (!text.trim())
|
||||
return (
|
||||
<div className={styles.dim}>
|
||||
<T>No description provided.</T>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.markdown}
|
||||
onClick={(e) => {
|
||||
const a = (e.target as HTMLElement).closest("a");
|
||||
if (a?.href && /^https?:/.test(a.href)) {
|
||||
e.preventDefault();
|
||||
void openUrl(a.href);
|
||||
}
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECK_ICON: Record<PrCheck["status"], string> = {
|
||||
success: "success",
|
||||
failure: "error",
|
||||
pending: "clock",
|
||||
cancelled: "stop",
|
||||
skipped: "minus",
|
||||
neutral: "info",
|
||||
};
|
||||
|
||||
function Overview({ pr }: { pr: PullRequestDetails }) {
|
||||
const summary = checksSummary(pr.checks);
|
||||
return (
|
||||
<div className={styles.scroll}>
|
||||
<Markdown text={pr.body} />
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{tx`Checks · ${checksText(summary)}`}</h3>
|
||||
{pr.checks.map((c, i) => (
|
||||
<div key={i} className={styles.checkRow}>
|
||||
<Icon name={CHECK_ICON[c.status]} size={14} className={styles[`check-${c.status}`]} />
|
||||
<span className={styles.ellipsis}>
|
||||
{c.workflow ? `${c.workflow} / ` : ""}
|
||||
{c.name}
|
||||
</span>
|
||||
{c.url && (
|
||||
<button type="button" className={styles.link} onClick={() => void openUrl(c.url!)}>
|
||||
<T>Details</T>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
<T>Reviewers</T>
|
||||
</h3>
|
||||
{pr.reviewers.length === 0 && (
|
||||
<div className={styles.dim}>
|
||||
<T>No reviews</T>
|
||||
</div>
|
||||
)}
|
||||
{pr.reviewers.map((r) => (
|
||||
<div key={r.login} className={styles.checkRow}>
|
||||
<Icon
|
||||
name={
|
||||
r.state === "APPROVED"
|
||||
? "success"
|
||||
: r.state === "CHANGES_REQUESTED"
|
||||
? "error"
|
||||
: r.state === "REQUESTED"
|
||||
? "clock"
|
||||
: "info"
|
||||
}
|
||||
size={14}
|
||||
className={styles[`review-${r.state}`]}
|
||||
/>
|
||||
<span>{r.login}</span>
|
||||
<span className={styles.dim}>{reviewStateLabel(r.state)}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
{(pr.summary.labels.length > 0 || pr.assignees.length > 0) && (
|
||||
<section className={styles.section}>
|
||||
{pr.summary.labels.length > 0 && (
|
||||
<div className={styles.labels}>
|
||||
{pr.summary.labels.map((l) => (
|
||||
<span key={l.name} className={styles.label}>
|
||||
{l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pr.assignees.length > 0 && <div className={styles.dim}>{tx`Assignees: ${pr.assignees.join(", ")}`}</div>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Files({ pr }: { pr: PullRequestDetails }) {
|
||||
const comments = useGithub((s) => s.reviewComments);
|
||||
const counts = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const c of comments) m.set(c.path, (m.get(c.path) ?? 0) + 1);
|
||||
return m;
|
||||
}, [comments]);
|
||||
return (
|
||||
<div className={styles.scroll} role="list" aria-label={tx("Changed files")}>
|
||||
{pr.files.map((f) => {
|
||||
const slash = f.path.lastIndexOf("/");
|
||||
return (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={styles.fileRow}
|
||||
onClick={(e) => e.detail <= 1 && void openFileDiff(pr, f)}
|
||||
title={f.path}
|
||||
>
|
||||
<Icon name="diff" size={14} className={styles[`change-${f.changeType}`]} />
|
||||
<span className={clsx(styles.fileName, styles[`change-${f.changeType}`])}>{f.path.slice(slash + 1)}</span>
|
||||
<span className={clsx(styles.dim, styles.ellipsis)}>{slash > 0 ? f.path.slice(0, slash) : ""}</span>
|
||||
<span className={styles.flex} />
|
||||
{counts.get(f.path) ? <span className={styles.dim}>{tx`${counts.get(f.path)} comments`}</span> : null}
|
||||
<span className={styles.added}>+{f.additions}</span>
|
||||
<span className={styles.deleted}>−{f.deletions}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Commits({ pr }: { pr: PullRequestDetails }) {
|
||||
return (
|
||||
<div className={styles.scroll} role="list" aria-label={tx("Commits")}>
|
||||
{pr.commits.map((c) => (
|
||||
<div
|
||||
key={c.oid}
|
||||
role="listitem"
|
||||
className={styles.commitRow}
|
||||
title={c.body || c.headline}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu({ x: e.clientX, y: e.clientY }, [
|
||||
{ label: "Copy Revision Number", icon: "copy", run: () => void navigator.clipboard?.writeText(c.oid) },
|
||||
{
|
||||
label: "Open in Browser",
|
||||
icon: "external-link",
|
||||
run: () => void openUrl(`${pr.summary.url}/commits/${c.oid}`),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<span className={styles.ellipsis}>{c.headline}</span>
|
||||
<span className={styles.flex} />
|
||||
<span className={styles.dim}>{c.author}</span>
|
||||
<span className={styles.dim}>{relativeTime(c.date)}</span>
|
||||
<code className={styles.oid}>{c.oid.slice(0, 8)}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Conversation({ pr }: { pr: PullRequestDetails }) {
|
||||
const comments = useGithub((s) => s.reviewComments);
|
||||
const threads = useMemo(() => reviewThreads(comments), [comments]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [posting, setPosting] = useState(false);
|
||||
return (
|
||||
<div className={styles.scroll}>
|
||||
{pr.timeline.map((t, i) => (
|
||||
<article key={i} className={styles.comment}>
|
||||
<header className={styles.commentHead}>
|
||||
<strong>{t.author}</strong>
|
||||
{t.reviewState && (
|
||||
<span className={styles[`review-${t.reviewState}`]}>{reviewStateLabel(t.reviewState)}</span>
|
||||
)}
|
||||
<span className={styles.dim}>{relativeTime(t.createdAt)}</span>
|
||||
</header>
|
||||
{t.body.trim() && <Markdown text={t.body} />}
|
||||
</article>
|
||||
))}
|
||||
{threads.length > 0 && (
|
||||
<h3 className={styles.sectionTitle}>
|
||||
<T>Review comments</T>
|
||||
</h3>
|
||||
)}
|
||||
{threads.map((t) => (
|
||||
<article key={t.root.id} className={styles.comment}>
|
||||
<header className={styles.commentHead}>
|
||||
<button type="button" className={styles.link} onClick={() => void openCommentLocation(pr, t.root.path)}>
|
||||
{t.root.path}:{t.root.line ?? t.root.originalLine ?? "?"}
|
||||
</button>
|
||||
{t.root.line == null && (
|
||||
<span className={styles.dim}>
|
||||
<T>outdated</T>
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
{[t.root, ...t.replies].map((c) => (
|
||||
<div key={c.id} className={styles.threadItem}>
|
||||
<div className={styles.commentHead}>
|
||||
<strong>{c.author}</strong>
|
||||
<span className={styles.dim}>{relativeTime(c.createdAt)}</span>
|
||||
</div>
|
||||
<Markdown text={c.body} />
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
<div className={styles.newComment}>
|
||||
<textarea
|
||||
aria-label={tx("New comment")}
|
||||
placeholder={tx("Add a comment (Markdown)")}
|
||||
className={styles.textarea}
|
||||
rows={4}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<div className={styles.filterRow}>
|
||||
<span className={styles.flex} />
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
disabled={!draft.trim() || posting}
|
||||
onClick={async () => {
|
||||
setPosting(true);
|
||||
if (await addComment(pr.summary.number, draft)) setDraft("");
|
||||
setPosting(false);
|
||||
}}
|
||||
>
|
||||
<T>Comment</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Details() {
|
||||
const pr = useGithub((s) => s.details);
|
||||
const loading = useGithub((s) => s.detailsLoading);
|
||||
const error = useGithub((s) => s.detailsError);
|
||||
const selected = useGithub((s) => s.selected);
|
||||
const tab = useGithub((s) => s.tab);
|
||||
const comments = useGithub((s) => s.reviewComments);
|
||||
if (!pr || pr.summary.number !== selected) {
|
||||
if (error) return <EmptyState title="Could not load the pull request">{error}</EmptyState>;
|
||||
return loading ? (
|
||||
<div className={styles.loading}>
|
||||
<Spinner size={14} /> <T>Loading…</T>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Select a pull request" />
|
||||
);
|
||||
}
|
||||
const s = pr.summary;
|
||||
const reviewMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu({ x: r.left, y: r.bottom + 2 }, [
|
||||
{ label: "Approve…", icon: "success", run: () => void submitReview(s.number, "approve") },
|
||||
{ label: "Request Changes…", icon: "error", run: () => void submitReview(s.number, "requestChanges") },
|
||||
{ label: "Comment…", icon: "edit", run: () => void submitReview(s.number, "comment") },
|
||||
]);
|
||||
};
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<h2 className={styles.title}>{s.title}</h2>
|
||||
{loading && <Spinner size={12} />}
|
||||
</div>
|
||||
<div className={styles.metaRow}>
|
||||
<StateBadge pr={s} />
|
||||
<span>
|
||||
#{s.number} {tx`by ${s.author}`} · {s.baseRef} ← {s.headRef}
|
||||
</span>
|
||||
{reviewDecisionLabel(s.reviewDecision) && (
|
||||
<span className={styles.dim}>· {reviewDecisionLabel(s.reviewDecision)}</span>
|
||||
)}
|
||||
{pr.mergeable === "CONFLICTING" && (
|
||||
<span className={styles.conflict}>
|
||||
<T>· Conflicts</T>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button size="small" icon="branch" onClick={() => void checkoutPullRequest(s.number)}>
|
||||
<T>Checkout</T>
|
||||
</Button>
|
||||
<Button size="small" icon="edit" onClick={reviewMenu}>
|
||||
<T>Review ▾</T>
|
||||
</Button>
|
||||
<Button size="small" icon="external-link" onClick={() => void openUrl(s.url)}>
|
||||
<T>Open in Browser</T>
|
||||
</Button>
|
||||
<span className={styles.flex} />
|
||||
<IconButton icon="refresh" size="small" title="Reload" onClick={() => void loadDetails(s.number)} />
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
activeId={tab}
|
||||
onSelect={(id) => useGithub.setState({ tab: id as DetailTab })}
|
||||
aria-label="Pull request"
|
||||
items={[
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "files", label: tx`Files ${pr.files.length}` },
|
||||
{ id: "commits", label: tx`Commits ${pr.commits.length}` },
|
||||
{ id: "conversation", label: tx`Conversation ${pr.timeline.length + comments.length}` },
|
||||
]}
|
||||
/>
|
||||
{tab === "overview" && <Overview pr={pr} />}
|
||||
{tab === "files" && <Files pr={pr} />}
|
||||
{tab === "commits" && <Commits pr={pr} />}
|
||||
{tab === "conversation" && <Conversation pr={pr} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BusyBanner() {
|
||||
const busy = useGithub((s) => s.busy);
|
||||
if (!busy) return null;
|
||||
return (
|
||||
<div className={styles.banner} role="status">
|
||||
<Spinner size={12} />
|
||||
<span>{busy.title}</span>
|
||||
<span className={styles.bannerText}>{busy.line}</span>
|
||||
<IconButton icon="close" size="small" title="Cancel" onClick={cancelBusy} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Below this width the list sits above the details instead of beside them. */
|
||||
const SIDE_BY_SIDE_MIN = 560;
|
||||
|
||||
function useNarrow(el: HTMLElement | null): boolean {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(([e]) => setNarrow((e?.contentRect.width ?? Infinity) < SIDE_BY_SIDE_MIN));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [el]);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
export function PullRequestsView() {
|
||||
const availability = useGithub((s) => s.availability);
|
||||
const checking = useGithub((s) => s.checking);
|
||||
const [root, setRoot] = useState<HTMLDivElement | null>(null);
|
||||
const narrow = useNarrow(root);
|
||||
useEffect(() => {
|
||||
if (!useGithub.getState().availability) void refreshAll();
|
||||
}, []);
|
||||
if (!availability)
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
{checking && <Spinner size={14} />} {tx("Connecting to GitHub…")}
|
||||
</div>
|
||||
);
|
||||
if (availability.kind !== "ready")
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<Unavailable availability={availability} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.view} ref={setRoot}>
|
||||
<BusyBanner />
|
||||
<SplitGroup key={narrow ? "v" : "h"} orientation={narrow ? "vertical" : "horizontal"} className={styles.split}>
|
||||
<SplitPanel defaultSize={narrow ? "40%" : "38%"} minSize={narrow ? "120px" : "220px"}>
|
||||
<PullRequestList />
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel minSize={narrow ? "160px" : "280px"}>
|
||||
<Details />
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Header content: the repository the list is for. */
|
||||
export function PullRequestsHeader() {
|
||||
const availability = useGithub((s) => s.availability);
|
||||
if (availability?.kind !== "ready") return null;
|
||||
const name = availability.repo.nameWithOwner;
|
||||
return (
|
||||
<span className={styles.repo} title={name}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fragmentsOf, withFragments } from "./document";
|
||||
import { FRAGMENTS } from "./fragments";
|
||||
import * as Q from "./queries";
|
||||
import { GHGraphQLError, graphqlData, parseRest } from "./client";
|
||||
import { timelineItems } from "./requests";
|
||||
|
||||
const definitions = (text: string) => [...text.matchAll(/^\s*fragment\s+(\S+)\s+on\s/gm)].map((m) => m[1]!);
|
||||
const spreads = (text: string) => new Set([...text.matchAll(/^\s*\.\.\.([A-Za-z_]\w*)/gm)].map((m) => m[1]!));
|
||||
|
||||
describe("GraphQL documents", () => {
|
||||
it("ports every IntelliJ fragment and query", () => {
|
||||
expect(Object.keys(FRAGMENTS)).toHaveLength(39);
|
||||
expect(Object.keys(Q)).toHaveLength(40);
|
||||
for (const [name, text] of Object.entries(FRAGMENTS)) expect(definitions(text), name).toEqual([name]);
|
||||
});
|
||||
|
||||
it.each(Object.entries(Q))("sends %s with every fragment it needs, once", (_name, doc) => {
|
||||
const text = withFragments(doc);
|
||||
const defined = definitions(text);
|
||||
expect(new Set(defined).size).toBe(defined.length);
|
||||
for (const s of spreads(text)) expect(defined, s).toContain(s);
|
||||
// Only fragments that are spread somewhere are added.
|
||||
for (const d of fragmentsOf(doc)) expect(spreads(text).has(d), d).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves fragments transitively, like GHGQLQueryLoader", () => {
|
||||
const names = fragmentsOf(Q.issueSearch);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
"pageInfo",
|
||||
"pullRequestInfoShort",
|
||||
"rates",
|
||||
"nodeInfo",
|
||||
"actor",
|
||||
"actorInfo",
|
||||
"userInfo",
|
||||
"user",
|
||||
"labelInfo",
|
||||
"pullRequestReviewer",
|
||||
"teamInfo",
|
||||
"pullRequestReview",
|
||||
"reactions",
|
||||
"reaction",
|
||||
"enterpriseUserAccountInfo",
|
||||
"organizationInfo",
|
||||
]),
|
||||
);
|
||||
expect(names).not.toContain("repository");
|
||||
// Fragments go first, the operation last.
|
||||
expect(withFragments(Q.issueSearch).trim()).toMatch(/^fragment[\s\S]*\nquery issueSearch[\s\S]*\}$/);
|
||||
});
|
||||
|
||||
it("does not add fragments a document defines itself", () => {
|
||||
const text = withFragments(Q.pullRequestTimeline);
|
||||
expect(definitions(text).filter((d) => d === "gitRefName")).toHaveLength(1);
|
||||
expect(fragmentsOf(Q.pullRequestTimeline)).not.toContain("gitRefName");
|
||||
expect(fragmentsOf(Q.pullRequestTimeline)).not.toContain("issueCommentInfo");
|
||||
});
|
||||
|
||||
it("treats inline fragments as selections, not spreads", () => {
|
||||
expect(fragmentsOf(Q.findPendingReview)).toEqual(expect.arrayContaining(["pullRequestPendingReview", "nodeInfo"]));
|
||||
expect(fragmentsOf(`query { node(id: "x") { ... on User { id } } }`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails on an unknown fragment", () => {
|
||||
expect(() => withFragments(`query {\n viewer {\n ...nope\n }\n}`)).toThrow(/nope/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("returns data and throws GraphQL errors", () => {
|
||||
expect(graphqlData<{ a: number }>('{"data":{"a":1}}')).toEqual({ a: 1 });
|
||||
expect(() => graphqlData('{"errors":[{"message":"bad"},{"message":"worse"}]}')).toThrow(GHGraphQLError);
|
||||
expect(() => graphqlData('{"errors":[{"message":"bad"}]}')).toThrow("bad");
|
||||
});
|
||||
|
||||
it("flattens slurped REST pages and maps empty bodies to null", () => {
|
||||
expect(parseRest("[[1,2],[3]]", true)).toEqual([1, 2, 3]);
|
||||
expect(parseRest('[{"a":1}]', false)).toEqual([{ a: 1 }]);
|
||||
expect(parseRest(" ", false)).toBeNull();
|
||||
});
|
||||
|
||||
it("sends documents with their fragments through gh", async () => {
|
||||
const ipc = await import("@/platform/ipc");
|
||||
const ghApi = vi
|
||||
.spyOn(ipc.commands, "ghApi")
|
||||
.mockResolvedValue({ status: "ok", data: '{"data":{"repository":null}}' } as never);
|
||||
const { graphql } = await import("./client");
|
||||
await graphql(Q.findRepository, { repoOwner: "o", repoName: "r", cursor: undefined });
|
||||
const req = ghApi.mock.calls[0]![0];
|
||||
expect(req).toMatchObject({ method: "POST", endpoint: "graphql", paginate: false });
|
||||
const body = JSON.parse(req.body!) as { query: string; variables: Record<string, unknown> };
|
||||
expect(body.variables).toEqual({ repoOwner: "o", repoName: "r" });
|
||||
expect(definitions(body.query)).toEqual(expect.arrayContaining(["repository", "repositoryOwnerName", "rates"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeline items", () => {
|
||||
it("drops IntelliJ's ignored types and marks unknown ones", () => {
|
||||
const items = timelineItems([
|
||||
{ __typename: "IssueComment" },
|
||||
{ __typename: "MentionedEvent" },
|
||||
{ __typename: "SubscribedEvent" },
|
||||
{ __typename: "AutoMergeEnabledEvent" },
|
||||
]);
|
||||
expect(items.map((i) => i.__typename)).toEqual(["IssueComment", "Unknown"]);
|
||||
expect(items[1]).toEqual({ __typename: "Unknown", type: "AutoMergeEnabledEvent" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* GitHub API access through gh (`commands.ghApi`, which runs `gh api` in the project root
|
||||
* with gh's authentication), like IntelliJ's `GithubApiRequestExecutor`:
|
||||
*
|
||||
* const data = await graphql<{ repository: … }>(findRepository, { repoOwner, repoName });
|
||||
* const page = await rest<GHLabelDto[]>("GET", `${repoPath()}/labels?per_page=100`);
|
||||
*
|
||||
* Documents are sent with the fragments they spread (`withFragments`). Requests address
|
||||
* the connected repository explicitly (`repoPath()`, `repoVariables()`), not gh's
|
||||
* `{owner}/{repo}` placeholders, since a project can have several GitHub remotes.
|
||||
*/
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { withFragments } from "./document";
|
||||
import type { GHRepositoryCoordinates } from "./types";
|
||||
|
||||
export type GHApiMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||
|
||||
/** A GraphQL response with `errors` (IntelliJ's `GithubConfusingException`). */
|
||||
export class GHGraphQLError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly errors: ReadonlyArray<{ message: string; type?: string; path?: unknown[] }>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GHGraphQLError";
|
||||
}
|
||||
}
|
||||
|
||||
export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/** `data` of a GraphQL response body; throws on `errors` (gh usually fails the call first). */
|
||||
export function graphqlData<T>(text: string): T {
|
||||
const json = JSON.parse(text) as { data?: T; errors?: Array<{ message: string; type?: string; path?: unknown[] }> };
|
||||
if (json.errors?.length) throw new GHGraphQLError(json.errors.map((e) => e.message).join("\n"), json.errors);
|
||||
if (json.data === undefined) throw new GHGraphQLError("GitHub returned no data", []);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
/** Runs a document from `queries.ts` with `variables` (undefined values are dropped). */
|
||||
export async function graphql<T>(doc: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||||
const body = JSON.stringify({ query: withFragments(doc), variables: definedOnly(variables) });
|
||||
const text = await call(commands.ghApi({ method: "POST", endpoint: "graphql", body, paginate: false }));
|
||||
return graphqlData<T>(text);
|
||||
}
|
||||
|
||||
function definedOnly(v: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* A REST call (`endpoint` relative to the API root, e.g. `repos/o/r/pulls/1/files`).
|
||||
* `paginate` follows every `Link` page and concatenates array pages. Empty bodies
|
||||
* (`204 No Content`) come back as `null`.
|
||||
*/
|
||||
export async function rest<T>(
|
||||
method: GHApiMethod,
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
opts: { paginate?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const text = await call(
|
||||
commands.ghApi({
|
||||
method,
|
||||
endpoint,
|
||||
body: body === undefined ? null : JSON.stringify(body),
|
||||
paginate: opts.paginate ?? false,
|
||||
}),
|
||||
);
|
||||
return parseRest<T>(text, opts.paginate ?? false);
|
||||
}
|
||||
|
||||
/** Parses a REST body; `--slurp` pages (an array of pages) are flattened when they are arrays. */
|
||||
export function parseRest<T>(text: string, paginated: boolean): T {
|
||||
if (!text.trim()) return null as T;
|
||||
const json = JSON.parse(text) as unknown;
|
||||
if (paginated && Array.isArray(json) && json.every(Array.isArray)) return (json as unknown[][]).flat() as T;
|
||||
return json as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The connected repository
|
||||
|
||||
let current: (GHRepositoryCoordinates & { id: string | null }) | null = null;
|
||||
|
||||
/** Set by `data/repository.ts` when the tool window connects (null: disconnected). */
|
||||
export function setCurrentRepository(repo: (GHRepositoryCoordinates & { id: string | null }) | null) {
|
||||
current = repo;
|
||||
}
|
||||
|
||||
/** The connected repository: `owner`, `name` and its GraphQL node `id` (once loaded). */
|
||||
export function currentRepository(): GHRepositoryCoordinates & { id: string | null } {
|
||||
if (!current) throw new Error("Not connected to a GitHub repository");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** `repos/owner/name`, the prefix of the connected repository's REST endpoints. */
|
||||
export function repoPath(repo: GHRepositoryCoordinates = currentRepository()): string {
|
||||
return `repos/${repo.owner}/${repo.name}`;
|
||||
}
|
||||
|
||||
/** The `$repoOwner` / `$repoName` variables of the repository documents. */
|
||||
export function repoVariables(repo: GHRepositoryCoordinates = currentRepository()) {
|
||||
return { repoOwner: repo.owner, repoName: repo.name };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* GraphQL document assembly, like IntelliJ's `CachingGraphQLQueryLoader`: a document is
|
||||
* sent with every fragment it spreads (`...name`, where the fragment is named after its
|
||||
* file), transitively. Fragments the document defines itself are not added again.
|
||||
*/
|
||||
import { FRAGMENTS } from "./fragments";
|
||||
|
||||
interface Block {
|
||||
body: string;
|
||||
/** Fragments spread in the block and not defined in it. */
|
||||
dependencies: Set<string>;
|
||||
/** Fragments the block defines itself. */
|
||||
defines: Set<string>;
|
||||
}
|
||||
|
||||
const FRAGMENT_DEFINITION = /^fragment\s+(\S+)\s+on\s+.*\{$/;
|
||||
const SPREAD = /^\.\.\.([A-Za-z_]\w*)/;
|
||||
|
||||
function readBlock(text: string): Block {
|
||||
const dependencies = new Set<string>();
|
||||
const defines = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
lines.push(line);
|
||||
const def = FRAGMENT_DEFINITION.exec(line);
|
||||
if (def) defines.add(def[1]!);
|
||||
// `... on User {` is an inline fragment, not a spread.
|
||||
const spread = SPREAD.exec(line);
|
||||
if (spread) dependencies.add(spread[1]!);
|
||||
}
|
||||
for (const d of defines) dependencies.delete(d);
|
||||
return { body: lines.join("\n").trim(), dependencies, defines };
|
||||
}
|
||||
|
||||
const fragmentBlocks = new Map<string, Block>();
|
||||
const documents = new Map<string, { text: string; fragments: string[] }>();
|
||||
|
||||
function fragmentBlock(name: string): Block {
|
||||
let block = fragmentBlocks.get(name);
|
||||
if (!block) {
|
||||
const text = FRAGMENTS[name];
|
||||
if (text === undefined) throw new Error(`Couldn't find file for fragment ${name}`);
|
||||
block = readBlock(text);
|
||||
fragmentBlocks.set(name, block);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
function resolve(doc: string): { text: string; fragments: string[] } {
|
||||
const cached = documents.get(doc);
|
||||
if (cached) return cached;
|
||||
const { body, dependencies, defines } = readBlock(doc);
|
||||
const found = new Map<string, Block>();
|
||||
const visit = (names: Iterable<string>) => {
|
||||
for (const name of names) {
|
||||
if (found.has(name) || defines.has(name)) continue;
|
||||
const block = fragmentBlock(name);
|
||||
found.set(name, block);
|
||||
visit(block.dependencies);
|
||||
}
|
||||
};
|
||||
visit(dependencies);
|
||||
const blocks = [...found.values()].reverse();
|
||||
const resolved = { text: [...blocks.map((f) => f.body), body].join("\n"), fragments: [...found.keys()] };
|
||||
documents.set(doc, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** `doc` preceded by the fragments it needs, ready to send (cached per document). */
|
||||
export function withFragments(doc: string): string {
|
||||
return resolve(doc).text;
|
||||
}
|
||||
|
||||
/** Names of the fragments `withFragments(doc)` adds, in discovery order. */
|
||||
export function fragmentsOf(doc: string): string[] {
|
||||
return resolve(doc).fragments;
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* GraphQL fragments of IntelliJ's GitHub plugin (`resources/graphql/fragment`, plus
|
||||
* collaboration-tools' `pageInfo`), verbatim. Each one is named after its file, the
|
||||
* name it is spread with (`...name`); `withFragments` adds the ones a document needs.
|
||||
*/
|
||||
export const FRAGMENTS: Readonly<Record<string, string>> = {
|
||||
actor: `
|
||||
fragment actor on Actor {
|
||||
...actorInfo
|
||||
|
||||
... on User {
|
||||
...userInfo
|
||||
}
|
||||
... on EnterpriseUserAccount {
|
||||
...enterpriseUserAccountInfo
|
||||
}
|
||||
... on Organization {
|
||||
...organizationInfo
|
||||
}
|
||||
}
|
||||
`,
|
||||
actorInfo: `
|
||||
# Just the data that should be present for any type of Actor.
|
||||
fragment actorInfo on Actor {
|
||||
__typename
|
||||
...nodeInfo
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
`,
|
||||
checkRun: `
|
||||
fragment checkRun on CheckRun {
|
||||
name
|
||||
conclusion
|
||||
isRequired(pullRequestNumber: $number) # See: findPullRequestMergeability
|
||||
detailsUrl
|
||||
url
|
||||
}
|
||||
`,
|
||||
checkSuite: `
|
||||
fragment checkSuite on CheckSuite {
|
||||
checkRuns(first: 100) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...checkRun
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
comment: `
|
||||
fragment comment on Comment {
|
||||
...nodeInfo
|
||||
author {
|
||||
...actor
|
||||
}
|
||||
body
|
||||
createdAt
|
||||
...reactions
|
||||
}
|
||||
`,
|
||||
commit: `
|
||||
fragment commit on Commit {
|
||||
...commitShort
|
||||
committer {
|
||||
...gitActorInfo
|
||||
}
|
||||
messageBody
|
||||
committedDate
|
||||
parents(first: 100) {
|
||||
nodes {
|
||||
...commitHash
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
commitHash: `
|
||||
fragment commitHash on Commit {
|
||||
...nodeInfo
|
||||
oid
|
||||
abbreviatedOid
|
||||
}
|
||||
`,
|
||||
commitShort: `
|
||||
fragment commitShort on Commit {
|
||||
...commitHash
|
||||
url
|
||||
messageHeadline
|
||||
author {
|
||||
...gitActorInfo
|
||||
}
|
||||
}
|
||||
`,
|
||||
commitStatusContext: `
|
||||
fragment commitStatusContext on StatusContext {
|
||||
context
|
||||
state
|
||||
isRequired(pullRequestNumber: $number) # See: findPullRequestMergeability
|
||||
targetUrl
|
||||
}
|
||||
`,
|
||||
commitStatusRollup: `
|
||||
fragment commitStatusRollup on StatusCheckRollup {
|
||||
contexts(first: $pageSize, after: $cursor) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
__typename
|
||||
... on CheckRun {
|
||||
checkSuite {
|
||||
workflowRun {
|
||||
workflow {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
name
|
||||
title
|
||||
status
|
||||
conclusion
|
||||
url
|
||||
}
|
||||
... on StatusContext {
|
||||
context
|
||||
description
|
||||
state
|
||||
targetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
commitStatusRollupShort: `
|
||||
fragment commitStatusRollupShort on StatusCheckRollup {
|
||||
state
|
||||
}
|
||||
`,
|
||||
commitWithCheckStatuses: `
|
||||
fragment commitWithCheckStatuses on Commit {
|
||||
...commitHash
|
||||
status {
|
||||
contexts {
|
||||
...commitStatusContext
|
||||
}
|
||||
}
|
||||
|
||||
checkSuites(first: 100) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...checkSuite
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
enterpriseUserAccountInfo: `
|
||||
fragment enterpriseUserAccountInfo on EnterpriseUserAccount {
|
||||
name
|
||||
}
|
||||
`,
|
||||
gitActorInfo: `
|
||||
fragment gitActorInfo on GitActor {
|
||||
name
|
||||
email
|
||||
avatarUrl
|
||||
date
|
||||
user {
|
||||
...user
|
||||
}
|
||||
}
|
||||
`,
|
||||
gitRefName: `
|
||||
fragment gitRefName on Ref {
|
||||
name
|
||||
}
|
||||
`,
|
||||
labelInfo: `
|
||||
fragment labelInfo on Label {
|
||||
...nodeInfo
|
||||
url
|
||||
name
|
||||
color
|
||||
}
|
||||
`,
|
||||
nodeInfo: `
|
||||
fragment nodeInfo on Node {
|
||||
id
|
||||
}
|
||||
`,
|
||||
organizationInfo: `
|
||||
fragment organizationInfo on Organization {
|
||||
name
|
||||
}
|
||||
`,
|
||||
pageInfo: `
|
||||
fragment pageInfo on PageInfo {
|
||||
startCursor
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
`,
|
||||
pullRequestBranchMatch: `
|
||||
fragment pullRequestBranchMatch on PullRequest {
|
||||
...nodeInfo
|
||||
number
|
||||
title
|
||||
headRefName
|
||||
}
|
||||
`,
|
||||
pullRequestCommitWithCheckStatuses: `
|
||||
fragment pullRequestCommitWithCheckStatuses on PullRequestCommit {
|
||||
...nodeInfo
|
||||
commit {
|
||||
...commitWithCheckStatuses
|
||||
}
|
||||
}
|
||||
`,
|
||||
pullRequestInfo: `
|
||||
fragment pullRequestInfo on PullRequest {
|
||||
...pullRequestInfoShort
|
||||
|
||||
body
|
||||
|
||||
author {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
|
||||
reviewDecision
|
||||
|
||||
baseRefName
|
||||
baseRefOid
|
||||
baseRepository {
|
||||
...repository
|
||||
}
|
||||
|
||||
headRef {
|
||||
id
|
||||
}
|
||||
headRefName
|
||||
headRefOid
|
||||
headRepository {
|
||||
...repository
|
||||
}
|
||||
|
||||
viewerCanDeleteHeadRef
|
||||
}
|
||||
`,
|
||||
pullRequestInfoShort: `
|
||||
fragment pullRequestInfoShort on PullRequest {
|
||||
...nodeInfo
|
||||
url
|
||||
number
|
||||
|
||||
title
|
||||
state
|
||||
isDraft
|
||||
|
||||
author {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
|
||||
isReadByViewer
|
||||
|
||||
assignees(first: 100) {
|
||||
nodes {
|
||||
...user
|
||||
}
|
||||
}
|
||||
labels(first: 100) {
|
||||
nodes {
|
||||
...labelInfo
|
||||
}
|
||||
}
|
||||
reviewRequests(first: 100) {
|
||||
nodes {
|
||||
... on ReviewRequest {
|
||||
requestedReviewer {
|
||||
...pullRequestReviewer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reviewThreads(last: 100) {
|
||||
nodes {
|
||||
isResolved
|
||||
isOutdated
|
||||
}
|
||||
}
|
||||
reviews(last: 100) {
|
||||
nodes {
|
||||
...pullRequestReview
|
||||
}
|
||||
}
|
||||
...reactions
|
||||
|
||||
mergeable
|
||||
|
||||
viewerCanUpdate
|
||||
viewerCanReact
|
||||
viewerDidAuthor
|
||||
}
|
||||
`,
|
||||
pullRequestMergeability: `
|
||||
fragment pullRequestMergeability on PullRequest {
|
||||
mergeable
|
||||
canBeRebased
|
||||
mergeStateStatus
|
||||
baseRef {
|
||||
refUpdateRule {
|
||||
...refUpdateRule
|
||||
}
|
||||
}
|
||||
|
||||
commits(last: 1) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...pullRequestCommitWithCheckStatuses
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
pullRequestPendingReview: `
|
||||
fragment pullRequestPendingReview on PullRequestReview {
|
||||
...nodeInfo
|
||||
state
|
||||
comments(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`,
|
||||
pullRequestReview: `
|
||||
fragment pullRequestReview on PullRequestReview {
|
||||
__typename
|
||||
...nodeInfo
|
||||
url
|
||||
author {
|
||||
...actor
|
||||
}
|
||||
body
|
||||
state
|
||||
createdAt
|
||||
viewerCanUpdate
|
||||
}
|
||||
`,
|
||||
pullRequestReviewComment: `
|
||||
fragment pullRequestReviewComment on PullRequestReviewComment {
|
||||
...comment
|
||||
url
|
||||
state
|
||||
commit {
|
||||
...commitHash
|
||||
}
|
||||
originalCommit {
|
||||
...commitHash
|
||||
}
|
||||
diffHunk
|
||||
pullRequestReview {
|
||||
...nodeInfo
|
||||
}
|
||||
viewerCanDelete
|
||||
viewerCanUpdate
|
||||
viewerCanReact
|
||||
}
|
||||
`,
|
||||
pullRequestReviewer: `
|
||||
fragment pullRequestReviewer on RequestedReviewer {
|
||||
... on Actor {
|
||||
...actorInfo
|
||||
}
|
||||
... on User {
|
||||
...userInfo
|
||||
}
|
||||
... on Team {
|
||||
...teamInfo
|
||||
}
|
||||
|
||||
# For future enthusiasts: be careful with adding ... on Bot, as it's been added only in GHE 3.11
|
||||
}
|
||||
`,
|
||||
pullRequestReviewThread: `
|
||||
fragment pullRequestReviewThread on PullRequestReviewThread {
|
||||
...nodeInfo
|
||||
isResolved
|
||||
isOutdated
|
||||
path
|
||||
subjectType
|
||||
diffSide
|
||||
line
|
||||
originalLine
|
||||
startDiffSide
|
||||
startLine
|
||||
originalStartLine
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
...pullRequestReviewComment
|
||||
}
|
||||
}
|
||||
viewerCanReply
|
||||
viewerCanResolve
|
||||
viewerCanUnresolve
|
||||
}
|
||||
`,
|
||||
pullRequestTemplate: `
|
||||
fragment pullRequestTemplate on PullRequestTemplate {
|
||||
body
|
||||
}
|
||||
`,
|
||||
rates: `
|
||||
fragment rates on RateLimit {
|
||||
cost
|
||||
}
|
||||
`,
|
||||
reaction: `
|
||||
fragment reaction on Reaction {
|
||||
content
|
||||
user {
|
||||
...user
|
||||
}
|
||||
}
|
||||
`,
|
||||
reactions: `
|
||||
fragment reactions on Reactable {
|
||||
reactions(first: 25) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...reaction
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
refUpdateRule: `
|
||||
fragment refUpdateRule on RefUpdateRule {
|
||||
allowsDeletions
|
||||
allowsForcePushes
|
||||
pattern
|
||||
requiredApprovingReviewCount
|
||||
requiredStatusCheckContexts
|
||||
requiresLinearHistory
|
||||
requiresSignatures
|
||||
viewerCanPush
|
||||
}
|
||||
`,
|
||||
repository: `
|
||||
fragment repository on Repository {
|
||||
...nodeInfo
|
||||
owner {
|
||||
...repositoryOwnerName
|
||||
}
|
||||
nameWithOwner
|
||||
viewerPermission
|
||||
|
||||
mergeCommitAllowed
|
||||
squashMergeAllowed
|
||||
rebaseMergeAllowed
|
||||
|
||||
defaultBranchRef {
|
||||
...gitRefName
|
||||
}
|
||||
isFork
|
||||
url
|
||||
sshUrl
|
||||
}
|
||||
`,
|
||||
repositoryOwnerName: `
|
||||
fragment repositoryOwnerName on RepositoryOwner {
|
||||
__typename
|
||||
...nodeInfo
|
||||
login
|
||||
}
|
||||
`,
|
||||
teamInfo: `
|
||||
fragment teamInfo on Team {
|
||||
__typename
|
||||
...nodeInfo
|
||||
slug
|
||||
url
|
||||
teamAvatarUrl: avatarUrl
|
||||
teamName: name
|
||||
combinedSlug
|
||||
}
|
||||
`,
|
||||
user: `
|
||||
fragment user on User {
|
||||
...actorInfo
|
||||
...userInfo
|
||||
}
|
||||
`,
|
||||
userInfo: `
|
||||
fragment userInfo on User {
|
||||
name
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,852 @@
|
||||
/**
|
||||
* GraphQL documents of IntelliJ's GitHub plugin (`resources/graphql/query`), verbatim and
|
||||
* named after their files. They spread fragments from `fragments.ts`, which
|
||||
* `withFragments` (in `document.ts`) appends before a document is sent.
|
||||
*/
|
||||
|
||||
export const addPullRequestReviewThread = `
|
||||
mutation ($body: String!, $line: Int!, $path: String!, $pullRequestReviewId: ID, $side: DiffSide, $startSide: DiffSide, $startLine: Int) {
|
||||
addPullRequestReviewThread(input: {body: $body, line: $line, path: $path, pullRequestReviewId:$pullRequestReviewId, side: $side, startSide: $startSide, startLine: $startLine}) {
|
||||
thread {
|
||||
...pullRequestReviewThread
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const addReaction = `
|
||||
mutation ($id: ID!, $reaction: ReactionContent!) {
|
||||
addReaction(input: {
|
||||
subjectId: $id,
|
||||
content: $reaction
|
||||
}) {
|
||||
reaction {
|
||||
...reaction
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const addReviewComment = `
|
||||
mutation ($reviewId: ID!, $body: String!, $commit: GitObjectID, $file: String, $position: Int, $inReplyTo: ID) {
|
||||
addPullRequestReviewComment(input: {pullRequestReviewId: $reviewId, body: $body, commitOID: $commit, path: $file, position: $position, inReplyTo: $inReplyTo}) {
|
||||
comment {
|
||||
...pullRequestReviewComment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const createPullRequest = `
|
||||
mutation ($repositoryId: ID!, $baseRefName: String!, $headRefName: String!, $title: String!, $body: String, $draft: Boolean = false) {
|
||||
createPullRequest(input: {
|
||||
repositoryId: $repositoryId,
|
||||
baseRefName: $baseRefName,
|
||||
headRefName: $headRefName,
|
||||
title: $title,
|
||||
body: $body,
|
||||
draft: $draft
|
||||
}) {
|
||||
pullRequest {
|
||||
...pullRequestInfoShort
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const createReview = `
|
||||
mutation ($pullRequestId: ID!, $event: PullRequestReviewEvent, $body: String, $commitOid: GitObjectID, $threads: [DraftPullRequestReviewThread]) {
|
||||
addPullRequestReview(input: {pullRequestId: $pullRequestId, event: $event, body: $body, commitOID: $commitOid, threads: $threads}) {
|
||||
pullRequestReview {
|
||||
...pullRequestPendingReview
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const deleteIssueComment = `
|
||||
mutation ($id: ID!) {
|
||||
deleteIssueComment(input: {id: $id}) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const deleteRef = `
|
||||
mutation ($refId: ID!) {
|
||||
deleteRef(input: {refId: $refId}) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const deleteReview = `
|
||||
mutation ($reviewId: ID!){
|
||||
deletePullRequestReview(input: {pullRequestReviewId: $reviewId}) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const deleteReviewComment = `
|
||||
mutation ($id: ID!) {
|
||||
deletePullRequestReviewComment(input: {id: $id}) {
|
||||
pullRequestReview {
|
||||
...pullRequestPendingReview
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findMentionableUsers = `
|
||||
query($repoOwner: String!, $repoName: String!, $pageSize: Int = 100, $cursor: String) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
mentionableUsers(first: $pageSize, after: $cursor) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findOrganizationTeams = `
|
||||
query($organization: String!, $pageSize: Int = 100, $cursor: String) {
|
||||
organization(login: $organization) {
|
||||
teams(first: $pageSize, after: $cursor) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...teamInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findPendingReview = `
|
||||
query($pullRequestId: ID!) {
|
||||
node(id: $pullRequestId) {
|
||||
... on PullRequest {
|
||||
reviews(states: [PENDING], first: 1) {
|
||||
nodes {
|
||||
...pullRequestPendingReview
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findPullRequest = `
|
||||
query($repoOwner: String!, $repoName: String!, $number: Int!) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequest(number: $number) {
|
||||
...pullRequestInfo
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findPullRequestId = `
|
||||
query($repoOwner: String!, $repoName: String!, $number: Int!) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequest(number: $number) {
|
||||
...nodeInfo
|
||||
number
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findPullRequestMergeability = `
|
||||
query($repoOwner: String!, $repoName: String!, $number: Int!) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequest(number: $number) {
|
||||
...pullRequestMergeability
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findPullRequestsByHeadBranch = `
|
||||
query findPullRequestsByHeadBranch($query: String!, $pageSize: Int = 100, $cursor: String) {
|
||||
search(first: $pageSize, after: $cursor, type: ISSUE_ADVANCED, query: $query) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...pullRequestBranchMatch
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findRepository = `
|
||||
query($repoOwner: String!, $repoName: String!) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
...repository
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const findUser = `
|
||||
query($login: String!) {
|
||||
user(login: $login) {
|
||||
__typename
|
||||
...user
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const getProtectionRules = `
|
||||
query($repoOwner: String!, $repoName: String!, $pageSize: Int = 100, $cursor: String) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
branchProtectionRules(first: $pageSize, after: $cursor) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...nodeInfo
|
||||
pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const getPullRequestTemplates = `
|
||||
query($repoOwner: String!, $repoName: String!) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequestTemplates {
|
||||
...pullRequestTemplate
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const getRepositoryCommitStatusContexts = `
|
||||
query($repoOwner: String!, $repoName: String!, $oid: GitObjectID!, $pageSize: Int, $cursor: String) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
object(oid: $oid) {
|
||||
... on Commit {
|
||||
statusCheckRollup {
|
||||
...commitStatusRollup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const getRepositoryCommitStatusShort = `
|
||||
query($repoOwner: String!, $repoName: String!, $oid: GitObjectID!) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
object(oid: $oid) {
|
||||
... on Commit {
|
||||
statusCheckRollup {
|
||||
...commitStatusRollupShort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// `ISSUE_ADVANCED` rather than IntelliJ's `ISSUE`: only the advanced search understands
|
||||
// `OR`, `AND` and parentheses in the typed query.
|
||||
export const issueSearch = `
|
||||
query issueSearch($query: String!, $pageSize: Int = 100, $cursor: String) {
|
||||
search(first: $pageSize, after: $cursor, type: ISSUE_ADVANCED, query: $query) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...pullRequestInfoShort
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const markFileAsViewed = `
|
||||
mutation ($pullRequestId: ID!, $path: String!) {
|
||||
markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const markPullRequestReadyForReview = `
|
||||
mutation ($pullRequestId: ID!) {
|
||||
markPullRequestReadyForReview(input: {pullRequestId: $pullRequestId}) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const metrics = `
|
||||
query metrics(
|
||||
$allPRCountQuery: String!,
|
||||
$openPRCountQuery: String!,
|
||||
$openAuthoredPRCountQuery: String!,
|
||||
$openAssigneePRCountQuery: String!,
|
||||
$openReviewedPRCountQuery: String!,
|
||||
$openReviewAssignedPRCountQuery: String!,
|
||||
) {
|
||||
allPRCount: search(type: ISSUE, query: $allPRCountQuery) {
|
||||
issueCount
|
||||
}
|
||||
|
||||
openPRCount: search(type: ISSUE, query: $openPRCountQuery) {
|
||||
issueCount
|
||||
}
|
||||
|
||||
openAuthoredPRCount: search(type: ISSUE, query: $openAuthoredPRCountQuery) {
|
||||
issueCount
|
||||
}
|
||||
|
||||
openAssigneePRCount: search(type: ISSUE, query: $openAssigneePRCountQuery) {
|
||||
issueCount
|
||||
}
|
||||
|
||||
openReviewedPRCount: search(type: ISSUE, query: $openReviewedPRCountQuery) {
|
||||
issueCount
|
||||
}
|
||||
|
||||
openReviewAssignedPRCount: search(type: ISSUE, query: $openReviewAssignedPRCountQuery) {
|
||||
issueCount
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const pullRequestCommits = `
|
||||
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequest(number: $number) {
|
||||
commits(first: $pageSize, after: $cursor) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...nodeInfo
|
||||
commit {
|
||||
...commit
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const pullRequestFiles = `
|
||||
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequest(number: $number) {
|
||||
files(first: $pageSize, after: $cursor) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
path
|
||||
viewerViewedState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const pullRequestParticipants = `
|
||||
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequest(number: $number) {
|
||||
participants(first: $pageSize, after: $cursor) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...user
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const pullRequestReviewThreads = `
|
||||
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(first: $pageSize, after: $cursor) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
...pullRequestReviewThread
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const pullRequestTimeline = `
|
||||
fragment issueCommentInfo on IssueComment {
|
||||
...comment
|
||||
viewerCanUpdate
|
||||
viewerCanDelete
|
||||
viewerCanReact
|
||||
}
|
||||
|
||||
fragment prCommitInfo on PullRequestCommit {
|
||||
...nodeInfo
|
||||
commit {
|
||||
...commitShort
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
fragment reviewDismissedEvent on ReviewDismissedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
dismissalMessageHTML
|
||||
review {
|
||||
author {
|
||||
...actor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment readyForReviewEvent on ReadyForReviewEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
|
||||
fragment convertToDraftEvent on ConvertToDraftEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
|
||||
fragment renamedEvent on RenamedTitleEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
previousTitle
|
||||
currentTitle
|
||||
createdAt
|
||||
}
|
||||
|
||||
fragment closedEvent on ClosedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
|
||||
fragment reopenedEvent on ReopenedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
|
||||
fragment mergedEvent on MergedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
mergedEventCommit: commit {
|
||||
...commitShort
|
||||
}
|
||||
mergeRefName
|
||||
}
|
||||
|
||||
fragment assignedEvent on AssignedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
user {
|
||||
...user
|
||||
}
|
||||
}
|
||||
|
||||
fragment unassignedEvent on UnassignedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
user {
|
||||
...user
|
||||
}
|
||||
}
|
||||
|
||||
fragment labeledEvent on LabeledEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
label {
|
||||
...labelInfo
|
||||
}
|
||||
}
|
||||
|
||||
fragment unlabeledEvent on UnlabeledEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
label {
|
||||
...labelInfo
|
||||
}
|
||||
}
|
||||
|
||||
fragment reviewRequestedEvent on ReviewRequestedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
requestedReviewer {
|
||||
...pullRequestReviewer
|
||||
}
|
||||
}
|
||||
|
||||
fragment reviewRequestRemovedEvent on ReviewRequestRemovedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
requestedReviewer {
|
||||
...pullRequestReviewer
|
||||
}
|
||||
}
|
||||
|
||||
fragment gitRefName on Ref {
|
||||
name
|
||||
}
|
||||
|
||||
fragment baseRefForcePushedEvent on BaseRefForcePushedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
ref {
|
||||
...gitRefName
|
||||
}
|
||||
}
|
||||
|
||||
fragment headRefForcePushedEvent on HeadRefForcePushedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
ref {
|
||||
...gitRefName
|
||||
}
|
||||
beforeCommit {
|
||||
...commitHash
|
||||
}
|
||||
afterCommit {
|
||||
...commitHash
|
||||
}
|
||||
}
|
||||
|
||||
fragment headRefDeletedEvent on HeadRefDeletedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
headRefName
|
||||
}
|
||||
|
||||
fragment headRefRestoredEvent on HeadRefRestoredEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
|
||||
fragment baseRefChangedEvent on BaseRefChangedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
|
||||
fragment referenceSubject on ReferencedSubject {
|
||||
__typename
|
||||
... on PullRequest {
|
||||
title
|
||||
number
|
||||
prState: state
|
||||
isDraft
|
||||
url
|
||||
}
|
||||
... on Issue {
|
||||
title
|
||||
number
|
||||
issueState: state
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
fragment crossReferencedEvent on CrossReferencedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
source {
|
||||
...referenceSubject
|
||||
}
|
||||
}
|
||||
|
||||
fragment connectedEvent on ConnectedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
subject {
|
||||
...referenceSubject
|
||||
}
|
||||
}
|
||||
|
||||
fragment disconnectedEvent on DisconnectedEvent {
|
||||
actor {
|
||||
...actor
|
||||
}
|
||||
createdAt
|
||||
subject {
|
||||
...referenceSubject
|
||||
}
|
||||
}
|
||||
|
||||
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String, $since: DateTime) {
|
||||
repository(owner: $repoOwner, name: $repoName) {
|
||||
pullRequest(number: $number) {
|
||||
timelineItems(first: $pageSize, after: $cursor, since: $since) {
|
||||
pageInfo {
|
||||
...pageInfo
|
||||
}
|
||||
nodes {
|
||||
__typename
|
||||
...prCommitInfo
|
||||
...pullRequestReview
|
||||
...issueCommentInfo
|
||||
|
||||
...renamedEvent
|
||||
|
||||
...closedEvent
|
||||
...reopenedEvent
|
||||
...mergedEvent
|
||||
|
||||
...assignedEvent
|
||||
...unassignedEvent
|
||||
|
||||
...labeledEvent
|
||||
...unlabeledEvent
|
||||
|
||||
...reviewRequestedEvent
|
||||
...reviewRequestRemovedEvent
|
||||
|
||||
...reviewDismissedEvent
|
||||
|
||||
...readyForReviewEvent
|
||||
...convertToDraftEvent
|
||||
|
||||
...baseRefChangedEvent
|
||||
...baseRefForcePushedEvent
|
||||
|
||||
...headRefForcePushedEvent
|
||||
...headRefDeletedEvent
|
||||
...headRefRestoredEvent
|
||||
|
||||
...crossReferencedEvent
|
||||
...connectedEvent
|
||||
...disconnectedEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit {
|
||||
...rates
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const removeReaction = `
|
||||
mutation ($id: ID!, $reaction: ReactionContent!) {
|
||||
removeReaction(input: {
|
||||
subjectId: $id,
|
||||
content: $reaction
|
||||
}) {
|
||||
reaction {
|
||||
...reaction
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const resolveReviewThread = `
|
||||
mutation ($threadId: ID!) {
|
||||
resolveReviewThread(input: {threadId: $threadId}) {
|
||||
thread {
|
||||
...pullRequestReviewThread
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const submitReview = `
|
||||
mutation ($reviewId: ID!, $event: PullRequestReviewEvent!, $body: String){
|
||||
submitPullRequestReview(input: {pullRequestReviewId: $reviewId, event: $event, body: $body}) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const unmarkFileAsViewed = `
|
||||
mutation ($pullRequestId: ID!, $path: String!) {
|
||||
unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const unresolveReviewThread = `
|
||||
mutation ($threadId: ID!) {
|
||||
unresolveReviewThread(input: {threadId: $threadId}) {
|
||||
thread {
|
||||
...pullRequestReviewThread
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const updateIssueComment = `
|
||||
mutation ($id: ID!, $body: String!) {
|
||||
updateIssueComment(input: {id: $id, body: $body}) {
|
||||
issueComment {
|
||||
...comment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const updatePullRequest = `
|
||||
mutation ($pullRequestId: ID!, $title: String, $body: String) {
|
||||
updatePullRequest(input: {pullRequestId: $pullRequestId, title: $title, body: $body}) {
|
||||
pullRequest {
|
||||
...pullRequestInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const updateReview = `
|
||||
mutation ($reviewId: ID!, $body: String!){
|
||||
updatePullRequestReview(input: {pullRequestReviewId: $reviewId, body: $body}) {
|
||||
pullRequestReview {
|
||||
...pullRequestReview
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const updateReviewComment = `
|
||||
mutation ($id: ID!, $body: String!) {
|
||||
updatePullRequestReviewComment(input: {pullRequestReviewCommentId: $id, body: $body}) {
|
||||
pullRequestReviewComment {
|
||||
...pullRequestReviewComment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Typed GitHub requests, one per IntelliJ `GHGQLRequests` / `GithubApiRequests` call the
|
||||
* plugin makes. Everything addresses the connected repository (`client.ts`) unless a
|
||||
* repository is passed. Mutations only send; `data/operations.ts` refreshes what they
|
||||
* change.
|
||||
*/
|
||||
import { graphql, repoPath, repoVariables, rest } from "./client";
|
||||
import * as Q from "./queries";
|
||||
import type {
|
||||
GHCollaborator,
|
||||
GHCommitFile,
|
||||
GHCommitStatusRollupContext,
|
||||
GHConnection,
|
||||
GHDiffSide,
|
||||
GHComment,
|
||||
GHLabel,
|
||||
GHPRTimelineItem,
|
||||
GHPullRequest,
|
||||
GHPullRequestBranchMatch,
|
||||
GHPullRequestChangedFile,
|
||||
GHPullRequestCommit,
|
||||
GHPullRequestDraftReviewThread,
|
||||
GHPullRequestMergeabilityData,
|
||||
GHPullRequestPendingReview,
|
||||
GHPullRequestReview,
|
||||
GHPullRequestReviewComment,
|
||||
GHPullRequestReviewEvent,
|
||||
GHPullRequestReviewThread,
|
||||
GHPullRequestShort,
|
||||
GHReaction,
|
||||
GHReactionContent,
|
||||
GHRepository,
|
||||
GHRepositoryCoordinates,
|
||||
GHRepositoryPullRequestTemplate,
|
||||
GHTeam,
|
||||
GHUser,
|
||||
} from "./types";
|
||||
|
||||
/** IntelliJ's `PR_PAGE_SIZE`: list rows per `issueSearch` page. */
|
||||
export const PR_PAGE_SIZE = 50;
|
||||
|
||||
type Pr = { repository: { pullRequest: Record<string, unknown> | null } | null };
|
||||
|
||||
/** `repository.pullRequest.<field>` of a pull request document, or null when the PR is gone. */
|
||||
async function prField<T>(doc: string, number: number, field: string, vars: Record<string, unknown> = {}) {
|
||||
const data = await graphql<Pr>(doc, { ...repoVariables(), number, ...vars });
|
||||
return (data.repository?.pullRequest?.[field] as T | undefined) ?? null;
|
||||
}
|
||||
|
||||
/** Every page of a cursor-paged connection, in order. */
|
||||
export async function allPages<T>(page: (cursor: string | null) => Promise<GHConnection<T> | null>): Promise<T[]> {
|
||||
const out: T[] = [];
|
||||
let cursor: string | null = null;
|
||||
for (;;) {
|
||||
const c: GHConnection<T> | null = await page(cursor);
|
||||
if (!c) return out;
|
||||
out.push(...c.nodes);
|
||||
if (!c.pageInfo.hasNextPage || !c.pageInfo.endCursor) return out;
|
||||
cursor = c.pageInfo.endCursor;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository and users
|
||||
|
||||
export async function findRepository(repo: GHRepositoryCoordinates): Promise<GHRepository | null> {
|
||||
const data = await graphql<{ repository: GHRepository | null }>(Q.findRepository, repoVariables(repo));
|
||||
return data.repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* A user by login (`findUser`).
|
||||
* @public
|
||||
*/
|
||||
export async function findUser(login: string): Promise<GHUser | null> {
|
||||
return (await graphql<{ user: GHUser | null }>(Q.findUser, { login })).user;
|
||||
}
|
||||
|
||||
interface RestUser {
|
||||
login: string;
|
||||
node_id: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
name?: string | null;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/** A REST user as the GraphQL `user` fragment shape. */
|
||||
export function restUser(u: RestUser): GHUser {
|
||||
return {
|
||||
__typename: "User",
|
||||
id: u.node_id,
|
||||
login: u.login,
|
||||
url: u.html_url,
|
||||
avatarUrl: u.avatar_url,
|
||||
name: u.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in user (REST `user`, IntelliJ's `CurrentUser.get`). */
|
||||
export async function currentUser(): Promise<GHUser> {
|
||||
return restUser(await rest<RestUser>("GET", "user"));
|
||||
}
|
||||
|
||||
/** Page `page` (1-based) of a REST list; `next` is null on the last page. */
|
||||
async function restPage<T, R>(endpoint: string, page: number, map: (r: R) => T) {
|
||||
const sep = endpoint.includes("?") ? "&" : "?";
|
||||
const items = (await rest<R[] | null>("GET", `${endpoint}${sep}per_page=100&page=${page}`)) ?? [];
|
||||
return { items: items.map(map), next: items.length === 100 ? page + 1 : null };
|
||||
}
|
||||
|
||||
export function collaboratorsPage(page: number) {
|
||||
return restPage<GHCollaborator, RestUser & { permissions?: GHCollaborator["permissions"] }>(
|
||||
`${repoPath()}/collaborators`,
|
||||
page,
|
||||
(u) => ({
|
||||
...restUser(u),
|
||||
permissions: u.permissions ?? { admin: false, maintain: false, push: false, triage: false, pull: true },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function contributorsPage(page: number) {
|
||||
return restPage<GHUser, RestUser>(`${repoPath()}/contributors`, page, restUser);
|
||||
}
|
||||
|
||||
/** Users issues and pull requests can be assigned to. */
|
||||
export function assigneesPage(page: number) {
|
||||
return restPage<GHUser, RestUser>(`${repoPath()}/assignees`, page, restUser);
|
||||
}
|
||||
|
||||
interface RestLabel {
|
||||
node_id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const restLabel = (l: RestLabel): GHLabel => ({ id: l.node_id, url: l.url, name: l.name, color: l.color });
|
||||
|
||||
export function labelsPage(page: number) {
|
||||
return restPage<GHLabel, RestLabel>(`${repoPath()}/labels`, page, restLabel);
|
||||
}
|
||||
|
||||
export async function mentionableUsersPage(cursor: string | null): Promise<GHConnection<GHUser> | null> {
|
||||
const data = await graphql<{ repository: { mentionableUsers: GHConnection<GHUser> } | null }>(
|
||||
Q.findMentionableUsers,
|
||||
{ ...repoVariables(), cursor },
|
||||
);
|
||||
return data.repository?.mentionableUsers ?? null;
|
||||
}
|
||||
|
||||
export async function organizationTeamsPage(organization: string, cursor: string | null) {
|
||||
const data = await graphql<{ organization: { teams: GHConnection<GHTeam> } | null }>(Q.findOrganizationTeams, {
|
||||
organization,
|
||||
cursor,
|
||||
});
|
||||
return data.organization?.teams ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of the branch protection rules (`getProtectionRules`).
|
||||
* @public
|
||||
*/
|
||||
export async function protectionRulesPage(cursor: string | null) {
|
||||
const data = await graphql<{
|
||||
repository: { branchProtectionRules: GHConnection<{ id: string; pattern: string }> } | null;
|
||||
}>(Q.getProtectionRules, { ...repoVariables(), cursor });
|
||||
return data.repository?.branchProtectionRules ?? null;
|
||||
}
|
||||
|
||||
export async function pullRequestTemplates(): Promise<GHRepositoryPullRequestTemplate[]> {
|
||||
const data = await graphql<{ repository: { pullRequestTemplates: GHRepositoryPullRequestTemplate[] | null } | null }>(
|
||||
Q.getPullRequestTemplates,
|
||||
repoVariables(),
|
||||
);
|
||||
return data.repository?.pullRequestTemplates ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of the commit's status check rollup (`getRepositoryCommitStatusContexts`).
|
||||
* @public
|
||||
*/
|
||||
export async function commitStatusContextsPage(oid: string, cursor: string | null, pageSize = 100) {
|
||||
const data = await graphql<{
|
||||
repository: {
|
||||
object: { statusCheckRollup: { contexts: GHConnection<GHCommitStatusRollupContext> } | null } | null;
|
||||
};
|
||||
}>(Q.getRepositoryCommitStatusContexts, { ...repoVariables(), oid, pageSize, cursor });
|
||||
return data.repository.object?.statusCheckRollup?.contexts ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The commit's overall status check state (`getRepositoryCommitStatusShort`).
|
||||
* @public
|
||||
*/
|
||||
export async function commitStatusShort(oid: string): Promise<string | null> {
|
||||
const data = await graphql<{ repository: { object: { statusCheckRollup: { state: string } | null } | null } }>(
|
||||
Q.getRepositoryCommitStatusShort,
|
||||
{ ...repoVariables(), oid },
|
||||
);
|
||||
return data.repository.object?.statusCheckRollup?.state ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The counts of IntelliJ's usage metrics, by query (each a full search string).
|
||||
* @public
|
||||
*/
|
||||
export async function metrics(queries: {
|
||||
allPRCountQuery: string;
|
||||
openPRCountQuery: string;
|
||||
openAuthoredPRCountQuery: string;
|
||||
openAssigneePRCountQuery: string;
|
||||
openReviewedPRCountQuery: string;
|
||||
openReviewAssignedPRCountQuery: string;
|
||||
}): Promise<Record<string, number>> {
|
||||
const data = await graphql<Record<string, { issueCount: number }>>(Q.metrics, queries);
|
||||
return Object.fromEntries(
|
||||
Object.entries(data)
|
||||
.filter(([k]) => k !== "rateLimit")
|
||||
.map(([k, v]) => [k, v.issueCount]),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pull request queries
|
||||
|
||||
/** One `issueSearch` page: `query` is the full search string (`type:pr repo:o/r …`). */
|
||||
export async function searchPullRequests(query: string, cursor: string | null, pageSize = PR_PAGE_SIZE) {
|
||||
const data = await graphql<{ search: GHConnection<GHPullRequestShort | Record<string, never>> }>(Q.issueSearch, {
|
||||
query,
|
||||
pageSize,
|
||||
cursor,
|
||||
});
|
||||
// `type: ISSUE` may return issues (no fields selected for them: empty objects).
|
||||
const nodes = data.search.nodes.filter((n): n is GHPullRequestShort => "number" in n);
|
||||
return { pageInfo: data.search.pageInfo, nodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open pull requests whose head matches the `head:` terms of `query` (`findPullRequestsByHeadBranch`).
|
||||
* @public
|
||||
*/
|
||||
export async function searchPullRequestsByHeadBranches(query: string, pageSize = 100) {
|
||||
const data = await graphql<{ search: GHConnection<GHPullRequestBranchMatch> }>(Q.findPullRequestsByHeadBranch, {
|
||||
query,
|
||||
pageSize,
|
||||
});
|
||||
return data.search.nodes.filter((n) => "number" in n);
|
||||
}
|
||||
|
||||
export async function findPullRequest(number: number): Promise<GHPullRequest | null> {
|
||||
const data = await graphql<{ repository: { pullRequest: GHPullRequest | null } | null }>(Q.findPullRequest, {
|
||||
...repoVariables(),
|
||||
number,
|
||||
});
|
||||
return data.repository?.pullRequest ?? null;
|
||||
}
|
||||
|
||||
export async function findPullRequestId(number: number): Promise<string | null> {
|
||||
return prField<string>(Q.findPullRequestId, number, "id");
|
||||
}
|
||||
|
||||
export async function findPullRequestMergeability(number: number): Promise<GHPullRequestMergeabilityData | null> {
|
||||
const data = await graphql<{ repository: { pullRequest: GHPullRequestMergeabilityData | null } | null }>(
|
||||
Q.findPullRequestMergeability,
|
||||
{ ...repoVariables(), number },
|
||||
);
|
||||
return data.repository?.pullRequest ?? null;
|
||||
}
|
||||
|
||||
export function pullRequestCommitsPage(number: number, cursor: string | null) {
|
||||
return prField<GHConnection<GHPullRequestCommit>>(Q.pullRequestCommits, number, "commits", { cursor });
|
||||
}
|
||||
|
||||
export function pullRequestFilesPage(number: number, cursor: string | null) {
|
||||
return prField<GHConnection<GHPullRequestChangedFile>>(Q.pullRequestFiles, number, "files", { cursor });
|
||||
}
|
||||
|
||||
export function pullRequestParticipantsPage(number: number, cursor: string | null) {
|
||||
return prField<GHConnection<GHUser>>(Q.pullRequestParticipants, number, "participants", { cursor });
|
||||
}
|
||||
|
||||
export function pullRequestReviewThreadsPage(number: number, cursor: string | null) {
|
||||
return prField<GHConnection<GHPullRequestReviewThread>>(Q.pullRequestReviewThreads, number, "reviewThreads", {
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
|
||||
/** Timeline item types IntelliJ drops (`GHPRTimelineItem.IGNORED_TYPES`). */
|
||||
const IGNORED_TIMELINE_TYPES = new Set([
|
||||
"ReferencedEvent",
|
||||
"MentionedEvent",
|
||||
"CommentDeletedEvent",
|
||||
"LockedEvent",
|
||||
"UnlockedEvent",
|
||||
"SubscribedEvent",
|
||||
"UnsubscribedEvent",
|
||||
]);
|
||||
|
||||
/** Item types the timeline document selects fields for. */
|
||||
const TIMELINE_TYPES = new Set([
|
||||
"PullRequestCommit",
|
||||
"PullRequestReview",
|
||||
"IssueComment",
|
||||
"RenamedTitleEvent",
|
||||
"ClosedEvent",
|
||||
"ReopenedEvent",
|
||||
"MergedEvent",
|
||||
"AssignedEvent",
|
||||
"UnassignedEvent",
|
||||
"LabeledEvent",
|
||||
"UnlabeledEvent",
|
||||
"ReviewRequestedEvent",
|
||||
"ReviewRequestRemovedEvent",
|
||||
"ReviewDismissedEvent",
|
||||
"ReadyForReviewEvent",
|
||||
"ConvertToDraftEvent",
|
||||
"BaseRefChangedEvent",
|
||||
"BaseRefForcePushedEvent",
|
||||
"HeadRefForcePushedEvent",
|
||||
"HeadRefDeletedEvent",
|
||||
"HeadRefRestoredEvent",
|
||||
"CrossReferencedEvent",
|
||||
"ConnectedEvent",
|
||||
"DisconnectedEvent",
|
||||
]);
|
||||
|
||||
/** Timeline nodes as items: ignored types dropped, others IntelliJ can't show marked `Unknown`. */
|
||||
export function timelineItems(nodes: ReadonlyArray<{ __typename: string }>): GHPRTimelineItem[] {
|
||||
return nodes.flatMap((n): GHPRTimelineItem[] => {
|
||||
if (IGNORED_TIMELINE_TYPES.has(n.__typename)) return [];
|
||||
if (TIMELINE_TYPES.has(n.__typename)) return [n as GHPRTimelineItem];
|
||||
return [{ __typename: "Unknown", type: n.__typename }];
|
||||
});
|
||||
}
|
||||
|
||||
/** One page (100) of timeline items, after `cursor`. */
|
||||
export async function pullRequestTimelinePage(number: number, cursor: string | null, since?: string) {
|
||||
const c = await prField<GHConnection<{ __typename: string }>>(Q.pullRequestTimeline, number, "timelineItems", {
|
||||
cursor,
|
||||
since,
|
||||
});
|
||||
return c && { pageInfo: c.pageInfo, nodes: timelineItems(c.nodes) };
|
||||
}
|
||||
|
||||
interface PendingReviewNode {
|
||||
id: string;
|
||||
state: GHPullRequestPendingReview["state"];
|
||||
comments: { totalCount: number };
|
||||
}
|
||||
|
||||
const pendingReview = (r: PendingReviewNode): GHPullRequestPendingReview => ({
|
||||
id: r.id,
|
||||
state: r.state,
|
||||
commentsCount: r.comments.totalCount,
|
||||
});
|
||||
|
||||
/** The viewer's pending (not yet submitted) review of the pull request with node id `pullRequestId`. */
|
||||
export async function findPendingReview(pullRequestId: string): Promise<GHPullRequestPendingReview | null> {
|
||||
const data = await graphql<{ node: { reviews?: { nodes: PendingReviewNode[] } } | null }>(Q.findPendingReview, {
|
||||
pullRequestId,
|
||||
});
|
||||
const r = data.node?.reviews?.nodes[0];
|
||||
return r ? pendingReview(r) : null;
|
||||
}
|
||||
|
||||
interface RestCommitFile {
|
||||
sha: string | null;
|
||||
filename: string;
|
||||
status: GHCommitFile["status"];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changes: number;
|
||||
patch?: string;
|
||||
previous_filename?: string;
|
||||
}
|
||||
|
||||
const commitFile = (f: RestCommitFile): GHCommitFile => ({
|
||||
sha: f.sha,
|
||||
filename: f.filename,
|
||||
status: f.status,
|
||||
additions: f.additions,
|
||||
deletions: f.deletions,
|
||||
changes: f.changes,
|
||||
patch: f.patch ?? null,
|
||||
previousFilename: f.previous_filename ?? null,
|
||||
});
|
||||
|
||||
/** The pull request's changed files (REST `pulls/{n}/files`, every page). */
|
||||
export async function pullRequestChangedFiles(number: number): Promise<GHCommitFile[]> {
|
||||
const files = await rest<RestCommitFile[]>("GET", `${repoPath()}/pulls/${number}/files?per_page=100`, undefined, {
|
||||
paginate: true,
|
||||
});
|
||||
return (files ?? []).map(commitFile);
|
||||
}
|
||||
|
||||
/** The files a commit changes (REST `commits/{sha}`). */
|
||||
export async function commitChangedFiles(sha: string): Promise<GHCommitFile[]> {
|
||||
const commit = await rest<{ files?: RestCommitFile[] }>("GET", `${repoPath()}/commits/${sha}`);
|
||||
return (commit?.files ?? []).map(commitFile);
|
||||
}
|
||||
|
||||
/** The files changed from the merge base of `base` and `head` to `head` (REST `compare/{base}...{head}`). */
|
||||
export async function compareChangedFiles(base: string, head: string): Promise<GHCommitFile[]> {
|
||||
const r = await rest<{ files?: RestCommitFile[] }>("GET", `${repoPath()}/compare/${base}...${head}`);
|
||||
return (r?.files ?? []).map(commitFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open pull requests from `head` (`owner:branch`) into `base`, REST `Repos.PullRequests.find`.
|
||||
*/
|
||||
export async function findOpenPullRequestsByHead(
|
||||
head: string,
|
||||
base?: string,
|
||||
): Promise<Array<{ number: number; html_url: string }>> {
|
||||
const q = new URLSearchParams({ state: "open", head, ...(base ? { base } : {}), per_page: "1" });
|
||||
return (await rest<Array<{ number: number; html_url: string }>>("GET", `${repoPath()}/pulls?${q}`)) ?? [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pull request mutations (GraphQL)
|
||||
|
||||
export async function createPullRequest(input: {
|
||||
repositoryId: string;
|
||||
baseRefName: string;
|
||||
/** `owner:branch` for a branch of a fork. */
|
||||
headRefName: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
draft: boolean;
|
||||
}): Promise<GHPullRequestShort> {
|
||||
const data = await graphql<{ createPullRequest: { pullRequest: GHPullRequestShort } }>(Q.createPullRequest, input);
|
||||
return data.createPullRequest.pullRequest;
|
||||
}
|
||||
|
||||
export async function updatePullRequest(pullRequestId: string, patch: { title?: string; body?: string }) {
|
||||
const data = await graphql<{ updatePullRequest: { pullRequest: GHPullRequest } }>(Q.updatePullRequest, {
|
||||
pullRequestId,
|
||||
...patch,
|
||||
});
|
||||
return data.updatePullRequest.pullRequest;
|
||||
}
|
||||
|
||||
export async function markPullRequestReadyForReview(pullRequestId: string): Promise<void> {
|
||||
await graphql(Q.markPullRequestReadyForReview, { pullRequestId });
|
||||
}
|
||||
|
||||
export async function markFileAsViewed(pullRequestId: string, path: string): Promise<void> {
|
||||
await graphql(Q.markFileAsViewed, { pullRequestId, path });
|
||||
}
|
||||
|
||||
export async function unmarkFileAsViewed(pullRequestId: string, path: string): Promise<void> {
|
||||
await graphql(Q.unmarkFileAsViewed, { pullRequestId, path });
|
||||
}
|
||||
|
||||
export async function deleteRef(refId: string): Promise<void> {
|
||||
await graphql(Q.deleteRef, { refId });
|
||||
}
|
||||
|
||||
/** `addPullRequestReview`: a pending review without `event`, else a submitted one. */
|
||||
export async function createReview(input: {
|
||||
pullRequestId: string;
|
||||
event?: GHPullRequestReviewEvent | null;
|
||||
body?: string | null;
|
||||
commitOid?: string | null;
|
||||
threads?: GHPullRequestDraftReviewThread[] | null;
|
||||
}): Promise<GHPullRequestPendingReview> {
|
||||
const data = await graphql<{ addPullRequestReview: { pullRequestReview: PendingReviewNode } }>(Q.createReview, input);
|
||||
return pendingReview(data.addPullRequestReview.pullRequestReview);
|
||||
}
|
||||
|
||||
export async function submitReview(reviewId: string, event: GHPullRequestReviewEvent, body?: string | null) {
|
||||
await graphql(Q.submitReview, { reviewId, event, body });
|
||||
}
|
||||
|
||||
export async function deleteReview(reviewId: string): Promise<void> {
|
||||
await graphql(Q.deleteReview, { reviewId });
|
||||
}
|
||||
|
||||
export async function updateReview(reviewId: string, body: string): Promise<GHPullRequestReview> {
|
||||
const data = await graphql<{ updatePullRequestReview: { pullRequestReview: GHPullRequestReview } }>(Q.updateReview, {
|
||||
reviewId,
|
||||
body,
|
||||
});
|
||||
return data.updatePullRequestReview.pullRequestReview;
|
||||
}
|
||||
|
||||
/** `addPullRequestReviewComment`: a reply (`inReplyTo`), or a comment on a commit position. */
|
||||
export async function addReviewComment(input: {
|
||||
reviewId: string;
|
||||
body: string;
|
||||
commit?: string | null;
|
||||
file?: string | null;
|
||||
position?: number | null;
|
||||
inReplyTo?: string | null;
|
||||
}): Promise<GHPullRequestReviewComment> {
|
||||
const data = await graphql<{ addPullRequestReviewComment: { comment: GHPullRequestReviewComment } }>(
|
||||
Q.addReviewComment,
|
||||
input,
|
||||
);
|
||||
return data.addPullRequestReviewComment.comment;
|
||||
}
|
||||
|
||||
export async function updateReviewComment(id: string, body: string): Promise<GHPullRequestReviewComment> {
|
||||
const data = await graphql<{
|
||||
updatePullRequestReviewComment: { pullRequestReviewComment: GHPullRequestReviewComment };
|
||||
}>(Q.updateReviewComment, { id, body });
|
||||
return data.updatePullRequestReviewComment.pullRequestReviewComment;
|
||||
}
|
||||
|
||||
/** Deletes a review comment; returns its (pending) review, when it still exists. */
|
||||
export async function deleteReviewComment(id: string): Promise<GHPullRequestPendingReview | null> {
|
||||
const data = await graphql<{ deletePullRequestReviewComment: { pullRequestReview: PendingReviewNode | null } }>(
|
||||
Q.deleteReviewComment,
|
||||
{ id },
|
||||
);
|
||||
const r = data.deletePullRequestReviewComment.pullRequestReview;
|
||||
return r ? pendingReview(r) : null;
|
||||
}
|
||||
|
||||
export async function addReviewThread(input: {
|
||||
pullRequestReviewId: string;
|
||||
body: string;
|
||||
path: string;
|
||||
line: number;
|
||||
side: GHDiffSide;
|
||||
startLine?: number | null;
|
||||
startSide?: GHDiffSide | null;
|
||||
}): Promise<GHPullRequestReviewThread> {
|
||||
const data = await graphql<{ addPullRequestReviewThread: { thread: GHPullRequestReviewThread } }>(
|
||||
Q.addPullRequestReviewThread,
|
||||
input,
|
||||
);
|
||||
return data.addPullRequestReviewThread.thread;
|
||||
}
|
||||
|
||||
export async function resolveReviewThread(threadId: string): Promise<GHPullRequestReviewThread> {
|
||||
const data = await graphql<{ resolveReviewThread: { thread: GHPullRequestReviewThread } }>(Q.resolveReviewThread, {
|
||||
threadId,
|
||||
});
|
||||
return data.resolveReviewThread.thread;
|
||||
}
|
||||
|
||||
export async function unresolveReviewThread(threadId: string): Promise<GHPullRequestReviewThread> {
|
||||
const data = await graphql<{ unresolveReviewThread: { thread: GHPullRequestReviewThread } }>(
|
||||
Q.unresolveReviewThread,
|
||||
{ threadId },
|
||||
);
|
||||
return data.unresolveReviewThread.thread;
|
||||
}
|
||||
|
||||
export async function updateIssueComment(id: string, body: string): Promise<GHComment> {
|
||||
const data = await graphql<{ updateIssueComment: { issueComment: GHComment } }>(Q.updateIssueComment, { id, body });
|
||||
return data.updateIssueComment.issueComment;
|
||||
}
|
||||
|
||||
export async function deleteIssueComment(id: string): Promise<void> {
|
||||
await graphql(Q.deleteIssueComment, { id });
|
||||
}
|
||||
|
||||
export async function addReaction(id: string, reaction: GHReactionContent): Promise<GHReaction> {
|
||||
const data = await graphql<{ addReaction: { reaction: GHReaction } }>(Q.addReaction, { id, reaction });
|
||||
return data.addReaction.reaction;
|
||||
}
|
||||
|
||||
export async function removeReaction(id: string, reaction: GHReactionContent): Promise<GHReaction> {
|
||||
const data = await graphql<{ removeReaction: { reaction: GHReaction } }>(Q.removeReaction, { id, reaction });
|
||||
return data.removeReaction.reaction;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pull request mutations (REST)
|
||||
|
||||
/** Adds (`POST`) or removes (`DELETE`) requested reviewers: user logins and team slugs. */
|
||||
export async function changeRequestedReviewers(
|
||||
number: number,
|
||||
method: "POST" | "DELETE",
|
||||
reviewers: { logins: string[]; teamSlugs: string[] },
|
||||
): Promise<void> {
|
||||
await rest(method, `${repoPath()}/pulls/${number}/requested_reviewers`, {
|
||||
reviewers: reviewers.logins,
|
||||
team_reviewers: reviewers.teamSlugs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Closes or reopens (`PullRequests.update(state)`). */
|
||||
export async function updatePullRequestState(number: number, state: "open" | "closed"): Promise<void> {
|
||||
await rest("PATCH", `${repoPath()}/pulls/${number}`, { state });
|
||||
}
|
||||
|
||||
/** `PUT pulls/{n}/merge` at head `sha` (rebase takes no message). */
|
||||
export async function mergePullRequest(
|
||||
number: number,
|
||||
method: "merge" | "squash" | "rebase",
|
||||
sha: string,
|
||||
message?: { title: string; body: string },
|
||||
): Promise<void> {
|
||||
await rest("PUT", `${repoPath()}/pulls/${number}/merge`, {
|
||||
merge_method: method,
|
||||
sha,
|
||||
...(message ? { commit_title: message.title, commit_message: message.body } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Adds a conversation comment (`Issues.Comments.create`). */
|
||||
export async function createIssueComment(number: number, body: string): Promise<void> {
|
||||
await rest("POST", `${repoPath()}/issues/${number}/comments`, { body });
|
||||
}
|
||||
|
||||
/** Replaces the assignees (`Issues.updateAssignees`). */
|
||||
export async function updateAssignees(number: number, logins: string[]): Promise<void> {
|
||||
await rest("PATCH", `${repoPath()}/issues/${number}`, { assignees: logins });
|
||||
}
|
||||
|
||||
/** Replaces the labels (`Issues.Labels.replace`). */
|
||||
export async function replaceLabels(number: number, names: string[]): Promise<void> {
|
||||
await rest("PUT", `${repoPath()}/issues/${number}/labels`, { labels: names });
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Shapes of the GitHub data the documents in `queries.ts` return, named after IntelliJ's
|
||||
* DTOs (`GHPullRequestShort`, `GHPullRequest`, …). GraphQL connections keep their
|
||||
* `nodes`; `data/model.ts` derives what the UI shows (reviewer states, unread, counters).
|
||||
* REST results are mapped to the same shapes (`GHUser`, `GHLabel`) where IntelliJ does.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Common
|
||||
|
||||
/** `pageInfo` fragment. */
|
||||
export interface GHPageInfo {
|
||||
startCursor: string | null;
|
||||
hasPreviousPage: boolean;
|
||||
endCursor: string | null;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface GHConnection<T> {
|
||||
pageInfo: GHPageInfo;
|
||||
nodes: T[];
|
||||
}
|
||||
|
||||
export interface GHNodes<T> {
|
||||
nodes: T[];
|
||||
}
|
||||
|
||||
/** `actorInfo`: what every actor has. `name` comes with users, organizations and enterprise accounts. */
|
||||
export interface GHActor {
|
||||
__typename: "User" | "Bot" | "Organization" | "EnterpriseUserAccount" | "Mannequin" | (string & {});
|
||||
id: string;
|
||||
login: string;
|
||||
url: string;
|
||||
avatarUrl: string;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
/** `user` fragment. */
|
||||
export interface GHUser extends GHActor {
|
||||
__typename: "User";
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/** `teamInfo` fragment (aliased `teamAvatarUrl`, `teamName`). */
|
||||
export interface GHTeam {
|
||||
__typename: "Team";
|
||||
id: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
teamAvatarUrl: string | null;
|
||||
teamName: string;
|
||||
/** `org/slug`. */
|
||||
combinedSlug: string;
|
||||
}
|
||||
|
||||
/** `pullRequestReviewer`: a user, a team or (GitHub Apps) another actor. */
|
||||
export type GHPullRequestRequestedReviewer = GHUser | GHTeam | GHActor;
|
||||
|
||||
/** `labelInfo` fragment. `color` is hex without `#`. */
|
||||
export interface GHLabel {
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type GHReactionContent =
|
||||
| "THUMBS_UP"
|
||||
| "THUMBS_DOWN"
|
||||
| "LAUGH"
|
||||
| "HOORAY"
|
||||
| "CONFUSED"
|
||||
| "HEART"
|
||||
| "ROCKET"
|
||||
| "EYES";
|
||||
|
||||
export interface GHReaction {
|
||||
content: GHReactionContent;
|
||||
user: GHUser | null;
|
||||
}
|
||||
|
||||
/** `reactions` fragment (first 25). */
|
||||
export interface GHReactable {
|
||||
reactions: GHConnection<GHReaction>;
|
||||
}
|
||||
|
||||
/** `gitActorInfo`: commit author / committer. */
|
||||
export interface GHGitActor {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string;
|
||||
date: string | null;
|
||||
user: GHUser | null;
|
||||
}
|
||||
|
||||
/** `commitHash` fragment. */
|
||||
export interface GHCommitHash {
|
||||
id: string;
|
||||
oid: string;
|
||||
abbreviatedOid: string;
|
||||
}
|
||||
|
||||
/** `commitShort` fragment. */
|
||||
export interface GHCommitShort extends GHCommitHash {
|
||||
url: string;
|
||||
messageHeadline: string;
|
||||
author: GHGitActor | null;
|
||||
}
|
||||
|
||||
/** `commit` fragment. */
|
||||
export interface GHCommit extends GHCommitShort {
|
||||
committer: GHGitActor | null;
|
||||
messageBody: string;
|
||||
committedDate: string;
|
||||
parents: GHNodes<GHCommitHash>;
|
||||
}
|
||||
|
||||
/** `pullRequestCommits` node. */
|
||||
export interface GHPullRequestCommit {
|
||||
id: string;
|
||||
commit: GHCommit;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository
|
||||
|
||||
export type GHRepositoryPermissionLevel = "READ" | "TRIAGE" | "WRITE" | "MAINTAIN" | "ADMIN";
|
||||
|
||||
/** `repository` fragment. */
|
||||
export interface GHRepository {
|
||||
id: string;
|
||||
owner: { __typename: "User" | "Organization" | (string & {}); id: string; login: string };
|
||||
nameWithOwner: string;
|
||||
viewerPermission: GHRepositoryPermissionLevel | null;
|
||||
mergeCommitAllowed: boolean;
|
||||
squashMergeAllowed: boolean;
|
||||
rebaseMergeAllowed: boolean;
|
||||
defaultBranchRef: { name: string } | null;
|
||||
isFork: boolean;
|
||||
url: string;
|
||||
sshUrl: string;
|
||||
}
|
||||
|
||||
/** Where a request goes: `owner/name` on github.com. */
|
||||
export interface GHRepositoryCoordinates {
|
||||
owner: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** REST collaborator (`repos/{o}/{r}/collaborators`) mapped to a user, with its permissions. */
|
||||
export interface GHCollaborator extends GHUser {
|
||||
permissions: { admin: boolean; maintain: boolean; push: boolean; triage: boolean; pull: boolean };
|
||||
}
|
||||
|
||||
/** `pullRequestTemplate` fragment. */
|
||||
export interface GHRepositoryPullRequestTemplate {
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pull requests
|
||||
|
||||
export type GHPullRequestState = "OPEN" | "CLOSED" | "MERGED";
|
||||
export type GHPullRequestMergeableState = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
||||
export type GHPullRequestReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED";
|
||||
export type GHPullRequestReviewState = "PENDING" | "COMMENTED" | "APPROVED" | "CHANGES_REQUESTED" | "DISMISSED";
|
||||
export type GHPullRequestReviewEvent = "COMMENT" | "APPROVE" | "REQUEST_CHANGES" | "DISMISS";
|
||||
export type GHPullRequestMergeStateStatus =
|
||||
| "BEHIND"
|
||||
| "BLOCKED"
|
||||
| "CLEAN"
|
||||
| "DIRTY"
|
||||
| "DRAFT"
|
||||
| "HAS_HOOKS"
|
||||
| "UNKNOWN"
|
||||
| "UNSTABLE";
|
||||
|
||||
/** `pullRequestReview` fragment. */
|
||||
export interface GHPullRequestReview {
|
||||
__typename: "PullRequestReview";
|
||||
id: string;
|
||||
url: string;
|
||||
author: GHActor | null;
|
||||
body: string;
|
||||
state: GHPullRequestReviewState;
|
||||
createdAt: string;
|
||||
viewerCanUpdate: boolean;
|
||||
}
|
||||
|
||||
/** `pullRequestInfoShort` fragment: a list row (`issueSearch`). */
|
||||
export interface GHPullRequestShort extends GHReactable {
|
||||
id: string;
|
||||
url: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: GHPullRequestState;
|
||||
isDraft: boolean;
|
||||
author: GHActor | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isReadByViewer: boolean;
|
||||
assignees: GHNodes<GHUser>;
|
||||
labels: GHNodes<GHLabel>;
|
||||
reviewRequests: GHNodes<{ requestedReviewer: GHPullRequestRequestedReviewer | null }>;
|
||||
reviewThreads: GHNodes<{ isResolved: boolean; isOutdated: boolean }>;
|
||||
reviews: GHNodes<GHPullRequestReview>;
|
||||
mergeable: GHPullRequestMergeableState;
|
||||
viewerCanUpdate: boolean;
|
||||
viewerCanReact: boolean;
|
||||
viewerDidAuthor: boolean;
|
||||
}
|
||||
|
||||
/** `pullRequestInfo` fragment: the details (`findPullRequest`). */
|
||||
export interface GHPullRequest extends GHPullRequestShort {
|
||||
body: string;
|
||||
reviewDecision: GHPullRequestReviewDecision | null;
|
||||
baseRefName: string;
|
||||
baseRefOid: string;
|
||||
baseRepository: GHRepository | null;
|
||||
/** Null once the head branch is deleted. */
|
||||
headRef: { id: string } | null;
|
||||
headRefName: string;
|
||||
headRefOid: string;
|
||||
headRepository: GHRepository | null;
|
||||
viewerCanDeleteHeadRef: boolean;
|
||||
}
|
||||
|
||||
/** `pullRequestBranchMatch` fragment. */
|
||||
export interface GHPullRequestBranchMatch {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
headRefName: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mergeability (`findPullRequestMergeability`)
|
||||
|
||||
/** `refUpdateRule` fragment. */
|
||||
export interface GHRefUpdateRule {
|
||||
allowsDeletions: boolean;
|
||||
allowsForcePushes: boolean;
|
||||
pattern: string;
|
||||
requiredApprovingReviewCount: number | null;
|
||||
requiredStatusCheckContexts: Array<string | null>;
|
||||
requiresLinearHistory: boolean;
|
||||
requiresSignatures: boolean;
|
||||
viewerCanPush: boolean;
|
||||
}
|
||||
|
||||
export type GHCommitStatusContextState = "ERROR" | "EXPECTED" | "FAILURE" | "PENDING" | "SUCCESS";
|
||||
export type GHCommitCheckSuiteConclusion =
|
||||
| "ACTION_REQUIRED"
|
||||
| "CANCELLED"
|
||||
| "FAILURE"
|
||||
| "NEUTRAL"
|
||||
| "SKIPPED"
|
||||
| "STALE"
|
||||
| "STARTUP_FAILURE"
|
||||
| "SUCCESS"
|
||||
| "TIMED_OUT";
|
||||
|
||||
/** `commitStatusContext` fragment. */
|
||||
export interface GHCommitStatusContext {
|
||||
context: string;
|
||||
state: GHCommitStatusContextState;
|
||||
isRequired: boolean;
|
||||
targetUrl: string | null;
|
||||
}
|
||||
|
||||
/** `checkRun` fragment. */
|
||||
export interface GHCheckRun {
|
||||
name: string;
|
||||
conclusion: GHCommitCheckSuiteConclusion | null;
|
||||
isRequired: boolean;
|
||||
detailsUrl: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** `commitWithCheckStatuses` fragment. */
|
||||
export interface GHCommitWithCheckStatuses extends GHCommitHash {
|
||||
status: { contexts: GHCommitStatusContext[] } | null;
|
||||
checkSuites: GHConnection<{ checkRuns: GHConnection<GHCheckRun> | null }> | null;
|
||||
}
|
||||
|
||||
/** `pullRequestMergeability` fragment. */
|
||||
export interface GHPullRequestMergeabilityData {
|
||||
mergeable: GHPullRequestMergeableState;
|
||||
canBeRebased: boolean;
|
||||
mergeStateStatus: GHPullRequestMergeStateStatus;
|
||||
baseRef: { refUpdateRule: GHRefUpdateRule | null } | null;
|
||||
commits: GHConnection<{ id: string; commit: GHCommitWithCheckStatuses }>;
|
||||
}
|
||||
|
||||
/** `statusCheckRollup` (`getRepositoryCommitStatusContexts`). */
|
||||
export type GHCommitStatusRollupContext =
|
||||
| {
|
||||
__typename: "CheckRun";
|
||||
checkSuite: { workflowRun: { workflow: { name: string } } | null } | null;
|
||||
name: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
conclusion: GHCommitCheckSuiteConclusion | null;
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
__typename: "StatusContext";
|
||||
context: string;
|
||||
description: string | null;
|
||||
state: GHCommitStatusContextState;
|
||||
targetUrl: string | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Files, reviews, threads
|
||||
|
||||
export type GHPullRequestFileViewedState = "VIEWED" | "UNVIEWED" | "DISMISSED";
|
||||
|
||||
/** `pullRequestFiles` node. */
|
||||
export interface GHPullRequestChangedFile {
|
||||
path: string;
|
||||
viewerViewedState: GHPullRequestFileViewedState;
|
||||
}
|
||||
|
||||
/** REST `pulls/{n}/files` / `commits/{sha}` entry (IntelliJ's `GHCommitFile`), camel-cased. */
|
||||
export interface GHCommitFile {
|
||||
sha: string | null;
|
||||
filename: string;
|
||||
status: "added" | "removed" | "modified" | "renamed" | "copied" | "changed" | "unchanged";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changes: number;
|
||||
/** Absent for binary and very large files. */
|
||||
patch: string | null;
|
||||
previousFilename: string | null;
|
||||
}
|
||||
|
||||
/** `pullRequestPendingReview` fragment, flattened like IntelliJ's `GHPullRequestPendingReview`. */
|
||||
export interface GHPullRequestPendingReview {
|
||||
id: string;
|
||||
state: GHPullRequestReviewState;
|
||||
commentsCount: number;
|
||||
}
|
||||
|
||||
/** `comment` fragment. */
|
||||
export interface GHComment extends GHReactable {
|
||||
id: string;
|
||||
author: GHActor | null;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type GHPullRequestReviewCommentState = "PENDING" | "SUBMITTED";
|
||||
|
||||
/** `pullRequestReviewComment` fragment. */
|
||||
export interface GHPullRequestReviewComment extends GHComment {
|
||||
url: string;
|
||||
state: GHPullRequestReviewCommentState;
|
||||
commit: GHCommitHash | null;
|
||||
originalCommit: GHCommitHash | null;
|
||||
diffHunk: string;
|
||||
pullRequestReview: { id: string } | null;
|
||||
viewerCanDelete: boolean;
|
||||
viewerCanUpdate: boolean;
|
||||
viewerCanReact: boolean;
|
||||
}
|
||||
|
||||
export type GHDiffSide = "LEFT" | "RIGHT";
|
||||
|
||||
/** `pullRequestReviewThread` fragment. Lines are 1-based; `line` is null when outdated. */
|
||||
export interface GHPullRequestReviewThread {
|
||||
id: string;
|
||||
isResolved: boolean;
|
||||
isOutdated: boolean;
|
||||
path: string;
|
||||
subjectType: "LINE" | "FILE";
|
||||
diffSide: GHDiffSide;
|
||||
line: number | null;
|
||||
originalLine: number | null;
|
||||
startDiffSide: GHDiffSide | null;
|
||||
startLine: number | null;
|
||||
originalStartLine: number | null;
|
||||
comments: GHNodes<GHPullRequestReviewComment>;
|
||||
viewerCanReply: boolean;
|
||||
viewerCanResolve: boolean;
|
||||
viewerCanUnresolve: boolean;
|
||||
}
|
||||
|
||||
/** `DraftPullRequestReviewThread` input of `createReview`. */
|
||||
export interface GHPullRequestDraftReviewThread {
|
||||
body: string;
|
||||
path: string;
|
||||
line: number;
|
||||
side: GHDiffSide;
|
||||
startLine?: number | null;
|
||||
startSide?: GHDiffSide | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline (`pullRequestTimeline`)
|
||||
|
||||
interface GHTimelineEvent {
|
||||
actor: GHActor | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** `issueCommentInfo`. */
|
||||
export interface GHIssueComment extends GHComment {
|
||||
__typename: "IssueComment";
|
||||
viewerCanUpdate: boolean;
|
||||
viewerCanDelete: boolean;
|
||||
viewerCanReact: boolean;
|
||||
}
|
||||
|
||||
/** `referenceSubject`. */
|
||||
export type GHReferencedSubject =
|
||||
| {
|
||||
__typename: "PullRequest";
|
||||
title: string;
|
||||
number: number;
|
||||
prState: GHPullRequestState;
|
||||
isDraft: boolean;
|
||||
url: string;
|
||||
}
|
||||
| { __typename: "Issue"; title: string; number: number; issueState: "OPEN" | "CLOSED"; url: string };
|
||||
|
||||
export type GHPRTimelineItem =
|
||||
| { __typename: "PullRequestCommit"; id: string; commit: GHCommitShort; url: string }
|
||||
| GHPullRequestReview
|
||||
| GHIssueComment
|
||||
| ({ __typename: "RenamedTitleEvent"; previousTitle: string; currentTitle: string } & GHTimelineEvent)
|
||||
| ({ __typename: "ClosedEvent" } & GHTimelineEvent)
|
||||
| ({ __typename: "ReopenedEvent" } & GHTimelineEvent)
|
||||
| ({ __typename: "MergedEvent"; mergedEventCommit: GHCommitShort | null; mergeRefName: string } & GHTimelineEvent)
|
||||
| ({ __typename: "AssignedEvent"; user: GHUser | null } & GHTimelineEvent)
|
||||
| ({ __typename: "UnassignedEvent"; user: GHUser | null } & GHTimelineEvent)
|
||||
| ({ __typename: "LabeledEvent"; label: GHLabel } & GHTimelineEvent)
|
||||
| ({ __typename: "UnlabeledEvent"; label: GHLabel } & GHTimelineEvent)
|
||||
| ({ __typename: "ReviewRequestedEvent"; requestedReviewer: GHPullRequestRequestedReviewer | null } & GHTimelineEvent)
|
||||
| ({
|
||||
__typename: "ReviewRequestRemovedEvent";
|
||||
requestedReviewer: GHPullRequestRequestedReviewer | null;
|
||||
} & GHTimelineEvent)
|
||||
| ({
|
||||
__typename: "ReviewDismissedEvent";
|
||||
dismissalMessageHTML: string | null;
|
||||
review: { author: GHActor | null } | null;
|
||||
} & GHTimelineEvent)
|
||||
| ({ __typename: "ReadyForReviewEvent" } & GHTimelineEvent)
|
||||
| ({ __typename: "ConvertToDraftEvent" } & GHTimelineEvent)
|
||||
| ({ __typename: "BaseRefChangedEvent" } & GHTimelineEvent)
|
||||
| ({ __typename: "BaseRefForcePushedEvent"; ref: { name: string } | null } & GHTimelineEvent)
|
||||
| ({
|
||||
__typename: "HeadRefForcePushedEvent";
|
||||
ref: { name: string } | null;
|
||||
beforeCommit: GHCommitHash | null;
|
||||
afterCommit: GHCommitHash | null;
|
||||
} & GHTimelineEvent)
|
||||
| ({ __typename: "HeadRefDeletedEvent"; headRefName: string } & GHTimelineEvent)
|
||||
| ({ __typename: "HeadRefRestoredEvent" } & GHTimelineEvent)
|
||||
| ({ __typename: "CrossReferencedEvent"; source: GHReferencedSubject } & GHTimelineEvent)
|
||||
| ({ __typename: "ConnectedEvent"; subject: GHReferencedSubject } & GHTimelineEvent)
|
||||
| ({ __typename: "DisconnectedEvent"; subject: GHReferencedSubject } & GHTimelineEvent)
|
||||
/** Types the document does not select fields for (ReferencedEvent, MentionedEvent…). */
|
||||
| { __typename: "Unknown"; type: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived state (`data/model.ts`)
|
||||
|
||||
/** IntelliJ's `GHPRReviewerState`. */
|
||||
export type GHPRReviewerState = "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "NEEDS_REVIEW" | "RE_REQUESTED";
|
||||
|
||||
export type CodeReviewCIJobState = "FAILED" | "PENDING" | "SKIPPED" | "SUCCESS";
|
||||
|
||||
/** IntelliJ's `CodeReviewCIJob`. */
|
||||
export interface CodeReviewCIJob {
|
||||
name: string;
|
||||
status: CodeReviewCIJobState;
|
||||
isRequired: boolean;
|
||||
detailsUrl: string | null;
|
||||
}
|
||||
|
||||
/** IntelliJ's `GHPRMergeabilityState`, plus the raw GitHub values it was built from. */
|
||||
export interface GHPRMergeabilityState {
|
||||
/** Null while GitHub is still checking (mergeable UNKNOWN). */
|
||||
hasConflicts: boolean | null;
|
||||
ciJobs: CodeReviewCIJob[];
|
||||
canBeMerged: boolean;
|
||||
canBeRebased: boolean;
|
||||
/** The viewer may not push to the base branch. */
|
||||
isRestricted: boolean;
|
||||
/** Approvals still required by branch protection (0 when nothing blocks on reviews). */
|
||||
requiredApprovingReviewsCount: number;
|
||||
/** `BLOCKING_BEHIND` / `BLOCKING_FAILING` when the merge box blocks on them, else null. */
|
||||
checksState: "BLOCKING_BEHIND" | "BLOCKING_FAILING" | null;
|
||||
mergeable: GHPullRequestMergeableState;
|
||||
mergeStateStatus: GHPullRequestMergeStateStatus;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* The commits and changes of the new pull request (`GHPRCreateComponentFactory.changesPanel`):
|
||||
* "Changes" / "Changes from" with the commit chooser ("{n} commits", "All commits (n)"),
|
||||
* the selected commit's details (Show / Hide details) and the changes tree, whose
|
||||
* Show Diff opens the files in the diff viewer as one chain.
|
||||
*/
|
||||
import { useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { buildFileTree, dirKeys, type FileTreeNode } from "@/features/vcs/fileTree";
|
||||
import { openDiff, withChain } from "@/features/vcs/diffInput";
|
||||
import { KIND_COLOR, baseName, fileCount, type EffectiveKind } from "@/features/vcs/model";
|
||||
import type { Commit } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Icon, IconButton, Spinner, T, Tree, fileIconName, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { ChooserPopup } from "../ui/ChooserPopup";
|
||||
import { prettyDateTime } from "../ui/prettyDate";
|
||||
import { commitDescription } from "./model";
|
||||
import { selectCommit, useCreatePr, type CreateChange } from "./store";
|
||||
import styles from "./Create.module.css";
|
||||
|
||||
function Centered({ children, error }: { children: ReactNode; error?: boolean }) {
|
||||
return <div className={error ? `${styles.centered} ${styles.errorText}` : styles.centered}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ChangesPanel() {
|
||||
const commits = useCreatePr((s) => s.commits);
|
||||
if (!commits)
|
||||
return (
|
||||
<Centered>
|
||||
<T>Select pull request base and head to view changes</T>
|
||||
</Centered>
|
||||
);
|
||||
if (commits.state === "loading")
|
||||
return (
|
||||
<Centered>
|
||||
<Spinner size={14} />
|
||||
</Centered>
|
||||
);
|
||||
if (commits.state === "error")
|
||||
return (
|
||||
<Centered error>
|
||||
<div>
|
||||
<T>Failed to load commits</T>
|
||||
</div>
|
||||
<div className={styles.errorDetail}>{commits.error}</div>
|
||||
</Centered>
|
||||
);
|
||||
if (!commits.value.length)
|
||||
return (
|
||||
<Centered>
|
||||
<span className={styles.hint}>
|
||||
<T>Pull request does not contain any commits</T>
|
||||
</span>
|
||||
</Centered>
|
||||
);
|
||||
return <CommitsAndChanges commits={commits.value} />;
|
||||
}
|
||||
|
||||
function CommitsAndChanges({ commits }: { commits: Commit[] }) {
|
||||
const selectedId = useCreatePr((s) => s.selectedCommit);
|
||||
const index = commits.findIndex((c) => c.id === selectedId);
|
||||
const selected = index >= 0 ? commits[index]! : null;
|
||||
return (
|
||||
<div className={styles.changes}>
|
||||
<div className={styles.commits}>
|
||||
<div className={styles.commitsHeader}>
|
||||
<span className={styles.commitsTitle}>{commits.length === 1 ? tx("Changes") : tx("Changes from")}</span>
|
||||
<CommitChooser commits={commits} selected={selected} />
|
||||
{commits.length > 1 && selected && (
|
||||
<>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
size="small"
|
||||
title="Next Commit"
|
||||
disabled={index === commits.length - 1}
|
||||
onClick={() => selectCommit(commits[index + 1]!.id)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
size="small"
|
||||
title="Previous Commit"
|
||||
disabled={index === 0}
|
||||
onClick={() => selectCommit(commits[index - 1]!.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{selected && <CommitDetails key={selected.id} commit={selected} />}
|
||||
</div>
|
||||
<ChangesTree />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommitChooser({ commits, selected }: { commits: Commit[]; selected: Commit | null }) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const items = useMemo<Array<Commit | null>>(() => [null, ...commits], [commits]);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={styles.link}
|
||||
disabled={commits.length <= 1}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className={selected ? styles.hash : undefined}>
|
||||
{selected ? selected.shortId : commits.length === 1 ? tx("1 commit") : tx`${commits.length} commits`}
|
||||
</span>
|
||||
{commits.length > 1 && <Icon name="chevron-down" size={12} />}
|
||||
</button>
|
||||
{open && (
|
||||
<ChooserPopup<Commit | null>
|
||||
mode="single"
|
||||
anchor={ref}
|
||||
items={items}
|
||||
getKey={(c) => c?.id ?? "all"}
|
||||
present={(c) =>
|
||||
c
|
||||
? {
|
||||
text: c.summary,
|
||||
detail: `${c.author}, ${c.timeMs ? prettyDateTime(c.timeMs) : ""}`,
|
||||
icon: selected?.id === c.id ? "check" : "commit",
|
||||
}
|
||||
: { text: tx`All commits (${commits.length})`, icon: selected ? "commit" : "check" }
|
||||
}
|
||||
placeholder="Search commits"
|
||||
aria-label="Commits"
|
||||
width={360}
|
||||
onChoose={(c) => selectCommit(c?.id ?? null)}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `CodeReviewDetailsCommitInfoComponentFactory`: the subject, and with the details the body, author and date. */
|
||||
function CommitDetails({ commit }: { commit: Commit }) {
|
||||
const [details, setDetails] = useState(false);
|
||||
const description = commitDescription(commit.message);
|
||||
return (
|
||||
<div className={styles.commitInfo}>
|
||||
<div className={styles.commitTop}>
|
||||
<span className={details ? styles.commitSubjectFull : styles.commitSubject}>{commit.summary}</span>
|
||||
<button type="button" className={styles.textLink} onClick={() => setDetails((d) => !d)}>
|
||||
{details ? tx("Hide details") : tx("Show details")}
|
||||
</button>
|
||||
</div>
|
||||
{details && description && <div className={styles.commitBody}>{description}</div>}
|
||||
{details && (
|
||||
<div className={styles.commitMeta}>
|
||||
{commit.author}, {commit.timeMs ? prettyDateTime(commit.timeMs) : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const kindOf = (c: CreateChange): EffectiveKind => (c.kind === "untracked" ? "added" : c.kind);
|
||||
|
||||
/** Show Diff: `first` with every file of the tree in the chain (F7 moves along). */
|
||||
function showDiff(all: readonly CreateChange[], first: CreateChange) {
|
||||
void openDiff(
|
||||
withChain(
|
||||
all.map((c) => c.diff),
|
||||
Math.max(0, all.indexOf(first)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesTree() {
|
||||
const changes = useCreatePr((s) => s.changes);
|
||||
const files = changes?.state === "ok" ? changes.value : null;
|
||||
const roots = useMemo(() => (files ? buildFileTree(files, (f) => f.path) : []), [files]);
|
||||
const expanded = useMemo(() => dirKeys(roots), [roots]);
|
||||
const ordered = useMemo(() => {
|
||||
const out: CreateChange[] = [];
|
||||
const walk = (ns: readonly FileTreeNode<CreateChange>[]) =>
|
||||
ns.forEach((n) => (n.file ? out.push(n.file) : walk(n.children)));
|
||||
walk(roots);
|
||||
return out;
|
||||
}, [roots]);
|
||||
const [selection, setSelection] = useState<ReadonlySet<string>>(new Set());
|
||||
if (!changes || changes.state === "loading")
|
||||
return (
|
||||
<Centered>
|
||||
<Spinner size={14} />
|
||||
</Centered>
|
||||
);
|
||||
if (changes.state === "error")
|
||||
return (
|
||||
<Centered error>
|
||||
<div>
|
||||
<T>Failed to load changes</T>
|
||||
</div>
|
||||
<div className={styles.errorDetail}>{changes.error}</div>
|
||||
</Centered>
|
||||
);
|
||||
const selectedFiles = (keys: ReadonlySet<string>) => ordered.filter((f) => keys.has(`f:${f.path}`));
|
||||
const menu = (node: FileTreeNode<CreateChange> | null, keys: Set<string>, e: MouseEvent) => {
|
||||
const picked = selectedFiles(keys);
|
||||
const first = picked[0] ?? node?.file ?? null;
|
||||
const items: MenuItem[] = [
|
||||
{ label: "Show Diff", icon: "diff", disabled: !first, run: () => first && showDiff(ordered, first) },
|
||||
];
|
||||
showContextMenu(e, items);
|
||||
};
|
||||
return (
|
||||
<Tree<FileTreeNode<CreateChange>>
|
||||
key={changes.value.length ? "files" : "empty"}
|
||||
className={styles.tree}
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.name}
|
||||
getChildren={(n) => n.children}
|
||||
isExpandable={(n) => !n.file}
|
||||
defaultExpanded={expanded}
|
||||
selected={selection}
|
||||
onSelectionChange={setSelection}
|
||||
multiSelect
|
||||
rowHeight={22}
|
||||
aria-label="Changes"
|
||||
speedSearch
|
||||
onActivate={(n) => n.file && showDiff(ordered, n.file)}
|
||||
onContextMenu={menu}
|
||||
empty={
|
||||
<Centered>
|
||||
<span className={styles.hint}>
|
||||
<T>Commit does not contain any changes</T>
|
||||
</span>
|
||||
</Centered>
|
||||
}
|
||||
renderNode={(n) => <ChangeRow node={n} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeRow({ node }: { node: FileTreeNode<CreateChange> }) {
|
||||
if (!node.file)
|
||||
return (
|
||||
<span className={styles.fileRow}>
|
||||
<Icon name="folder" size={16} />
|
||||
<span className={styles.fileName}>{node.name}</span>
|
||||
<span className={styles.fileDetail}>{fileCount(node.count)}</span>
|
||||
</span>
|
||||
);
|
||||
const f = node.file;
|
||||
return (
|
||||
<span className={styles.fileRow} title={f.oldPath ? `${f.oldPath} → ${f.path}` : f.path}>
|
||||
<Icon name={fileIconName(node.name)} size={16} />
|
||||
<span className={styles.fileName} style={{ color: KIND_COLOR[kindOf(f)] }}>
|
||||
{node.name}
|
||||
</span>
|
||||
{f.oldPath && <span className={styles.fileDetail}>{tx`renamed from ${baseName(f.oldPath)}`}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* The New Pull Request tab. Gaps are IntelliJ's: 12px sides, 8px between the parts,
|
||||
* 10px around the status line and the button.
|
||||
*/
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.top,
|
||||
.bottom {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.hint {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.errorText {
|
||||
color: var(--danger);
|
||||
}
|
||||
.errorDetail {
|
||||
max-width: 100%;
|
||||
color: var(--fg-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Link-style buttons: the direction dropdowns, the commit chooser, Show details, View. */
|
||||
.link,
|
||||
.textLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover:not(:disabled) .linkText,
|
||||
.textLink:hover {
|
||||
color: var(--link-hover, var(--link));
|
||||
text-decoration: underline;
|
||||
}
|
||||
.link:disabled {
|
||||
color: var(--fg);
|
||||
cursor: default;
|
||||
}
|
||||
.linkText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Direction */
|
||||
.direction {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
/* Base and head share the width; each shows its whole text when it fits. */
|
||||
.direction > .link {
|
||||
flex: 0 1 auto;
|
||||
max-width: calc(50% - 12px);
|
||||
}
|
||||
.arrow {
|
||||
flex: none;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.headPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
height: 340px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.headRepo {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 8px 0;
|
||||
}
|
||||
.headRepo > :last-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.headRepoLabel {
|
||||
flex: none;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.headSearch {
|
||||
flex: none;
|
||||
padding: 6px 8px 4px;
|
||||
}
|
||||
.headList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.headSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.headRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.headRow[data-active] {
|
||||
background: var(--bg-list-selected);
|
||||
border-radius: var(--radius-selection);
|
||||
}
|
||||
|
||||
/* Commits and changes */
|
||||
.changesBox {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.changes {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.commits {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 50%;
|
||||
padding: 2px 12px 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
.commitsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.commitsTitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
.hash {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.commitInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.commitTop {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.commitTop > .textLink {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.commitSubject {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.commitSubjectFull {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.commitBody {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.commitMeta {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fileRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fileName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fileDetail {
|
||||
margin-left: 4px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
/* Title and description */
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 60px;
|
||||
}
|
||||
.titleInput {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
border-radius: 0 !important;
|
||||
background: var(--bg-editor, var(--bg-input)) !important;
|
||||
padding: 8px 12px !important;
|
||||
height: auto !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.descriptionBox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.description {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: var(--bg-editor, var(--bg-input));
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
resize: none;
|
||||
}
|
||||
.templateSpinner {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* Reviewers, assignees, labels */
|
||||
.metadata {
|
||||
display: grid;
|
||||
flex: none;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: start;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-editor, var(--bg-input));
|
||||
}
|
||||
.metaLabel {
|
||||
padding: 6px 5px 6px 0;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.metaList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
.metaItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 3px;
|
||||
}
|
||||
.userItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Status and the option button */
|
||||
.footer {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.statusText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.errorPopup {
|
||||
width: 300px;
|
||||
max-height: 400px;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
resize: both;
|
||||
}
|
||||
.errorPopupTitle {
|
||||
margin-bottom: 6px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.errorPopupText {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.optionButton {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.optionButton > :first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.optionArrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
border-left: 1px solid color-mix(in srgb, var(--fg-button-primary) 35%, transparent);
|
||||
border-radius: 0 var(--radius-control, 4px) var(--radius-control, 4px) 0;
|
||||
background: var(--bg-button-primary);
|
||||
color: var(--fg-button-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.optionArrow:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* New Remote Branch for Pull Request */
|
||||
.dialogForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.dialogLabel {
|
||||
color: var(--fg);
|
||||
}
|
||||
.dialogComment {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* The "New Pull Request at {repo}" tab (IntelliJ's `GHPRCreateComponentFactory`): a
|
||||
* vertical splitter with the merge direction and the commits and changes above, and the
|
||||
* title and description, reviewers / assignees / labels, the status line and the
|
||||
* "Create Pull Request" option button (with "Create Draft Pull Request") below.
|
||||
*
|
||||
* Contract: `CreatePanel()` takes no props and renders inside the New Pull Request tab
|
||||
* while the tool window is connected. Its state (`store.ts`) lives as long as the tab.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useVcs } from "@/features/vcs/store";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
Popup,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Spinner,
|
||||
T,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { openPullRequest } from "../toolwindow/open";
|
||||
import { ChangesPanel } from "./ChangesPanel";
|
||||
import { DirectionSelector } from "./DirectionSelector";
|
||||
import { MetadataGrid } from "./MetadataGrid";
|
||||
import {
|
||||
canCreate,
|
||||
createFromForm,
|
||||
initCreate,
|
||||
onRepositoryChanged,
|
||||
refreshBranchesState,
|
||||
resetCreate,
|
||||
setDescription,
|
||||
setTitle,
|
||||
useCreatePr,
|
||||
} from "./store";
|
||||
import styles from "./Create.module.css";
|
||||
|
||||
export function CreatePanel() {
|
||||
const ready = useCreatePr((s) => s.ready);
|
||||
const initError = useCreatePr((s) => s.initError);
|
||||
useEffect(() => {
|
||||
void initCreate();
|
||||
}, []);
|
||||
// Commits follow the repository (a new commit, a push, a fetch).
|
||||
useEffect(
|
||||
() =>
|
||||
useVcs.subscribe((s, prev) => {
|
||||
if (s.version !== prev.version) void onRepositoryChanged();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
if (initError)
|
||||
return (
|
||||
<div className={styles.centered}>
|
||||
<div className={styles.errorText}>{initError}</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
resetCreate();
|
||||
void initCreate();
|
||||
}}
|
||||
>
|
||||
<T>Retry</T>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
if (!ready)
|
||||
return (
|
||||
<div className={styles.centered}>
|
||||
<Spinner size={14} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<SplitGroup orientation="vertical" id="github-create-pull-request" className={styles.root}>
|
||||
<SplitPanel id="top" defaultSize="50%" minSize="90px">
|
||||
<div className={styles.top}>
|
||||
<DirectionSelector />
|
||||
<div className={styles.changesBox}>
|
||||
<ChangesPanel />
|
||||
</div>
|
||||
</div>
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="bottom" defaultSize="50%" minSize="160px">
|
||||
<div className={styles.bottom}>
|
||||
<TextPanel />
|
||||
<MetadataGrid />
|
||||
<div className={styles.footer}>
|
||||
<StatusLine />
|
||||
<CreateButton />
|
||||
</div>
|
||||
</div>
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
);
|
||||
}
|
||||
|
||||
/** `CodeReviewTitleDescriptionComponentFactory`: the title and description editors. */
|
||||
function TextPanel() {
|
||||
const title = useCreatePr((s) => s.title);
|
||||
const description = useCreatePr((s) => s.description);
|
||||
const templateLoading = useCreatePr((s) => s.templateLoading);
|
||||
return (
|
||||
<div className={styles.text}>
|
||||
<Input
|
||||
className={styles.titleInput}
|
||||
aria-label="Title"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<div className={styles.descriptionBox}>
|
||||
<textarea
|
||||
className={styles.description}
|
||||
aria-label={tx("Description")}
|
||||
placeholder={tx("Description")}
|
||||
value={description}
|
||||
disabled={templateLoading}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
{templateLoading && (
|
||||
<span className={styles.templateSpinner}>
|
||||
<Spinner size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Details": the error in a popup above the link. */
|
||||
function ErrorDetails({ title, error }: { title: string; error: string }) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} type="button" className={styles.textLink} onClick={() => setOpen(true)}>
|
||||
<T>Details</T>
|
||||
</button>
|
||||
{open && (
|
||||
<Popup
|
||||
open
|
||||
anchor={ref}
|
||||
placement="top-start"
|
||||
offset={4}
|
||||
onClose={() => setOpen(false)}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
className={styles.errorPopup}
|
||||
>
|
||||
<div className={styles.errorPopupTitle}>{title}</div>
|
||||
<div className={styles.errorPopupText}>{error}</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorLabel({ text }: { text: string }) {
|
||||
return (
|
||||
<span className={styles.statusText} title={text}>
|
||||
<Icon name="error" size={16} />
|
||||
<span className={styles.ellipsis}>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingLabel({ text }: { text: string }) {
|
||||
return (
|
||||
<span className={styles.statusText}>
|
||||
<Spinner size={14} />
|
||||
<span className={styles.ellipsis}>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The branches check, then the creation progress (`statusPanel`). */
|
||||
function StatusLine() {
|
||||
const check = useCreatePr((s) => s.check);
|
||||
const creation = useCreatePr((s) => s.creation);
|
||||
let checkLine = null;
|
||||
if (check?.state === "loading") checkLine = <LoadingLabel text={tx("Checking pull request branches")} />;
|
||||
else if (check?.state === "error")
|
||||
checkLine = (
|
||||
<>
|
||||
<ErrorLabel text={tx("Failed to check pull request branches")} />
|
||||
<ErrorDetails title={tx("Failed to check pull request branches")} error={check.error} />
|
||||
<IconButton icon="refresh" size="small" title="Refresh" onClick={() => void refreshBranchesState()} />
|
||||
</>
|
||||
);
|
||||
else if (check?.state === "ok" && check.value.kind === "alreadyExists") {
|
||||
const n = check.value.number;
|
||||
checkLine = (
|
||||
<>
|
||||
<ErrorLabel text={tx("Pull request already exists")} />
|
||||
<button type="button" className={styles.textLink} onClick={() => void openPullRequest(n)}>
|
||||
<T>View</T>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
} else if (check?.state === "ok" && check.value.kind === "noChanges")
|
||||
checkLine = <ErrorLabel text={tx`No changes between '${check.value.base}' and '${check.value.head}'`} />;
|
||||
|
||||
let progressLine = null;
|
||||
if (creation?.kind === "pushing") progressLine = <LoadingLabel text={tx("Pushing a pull request branch")} />;
|
||||
else if (creation?.kind === "creating") progressLine = <LoadingLabel text={tx("Creating a pull request")} />;
|
||||
else if (creation?.kind === "metadata") progressLine = <LoadingLabel text={tx("Updating pull request fields")} />;
|
||||
else if (creation?.kind === "error")
|
||||
progressLine = (
|
||||
<>
|
||||
<ErrorLabel text={tx("Failed to create a pull request")} />
|
||||
<ErrorDetails title={tx("Failed to create a pull request")} error={creation.error} />
|
||||
</>
|
||||
);
|
||||
if (!checkLine && !progressLine) return null;
|
||||
return (
|
||||
<div className={styles.status} role="status">
|
||||
{checkLine && <div className={styles.statusRow}>{checkLine}</div>}
|
||||
{progressLine && <div className={styles.statusRow}>{progressLine}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `JBOptionButton`: "Create Pull Request" and, behind the arrow, "Create Draft Pull Request". */
|
||||
function CreateButton() {
|
||||
const enabled = useCreatePr(canCreate);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const options: MenuItem[] = [
|
||||
{ label: "Create Draft Pull Request", disabled: !enabled, run: () => void createFromForm(true) },
|
||||
];
|
||||
return (
|
||||
<div ref={ref} className={styles.optionButton}>
|
||||
<Button variant="primary" disabled={!enabled} onClick={() => void createFromForm(false)}>
|
||||
<T>Create Pull Request</T>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optionArrow}
|
||||
aria-label={tx("More create options")}
|
||||
aria-haspopup="menu"
|
||||
disabled={!enabled}
|
||||
onClick={() => setMenu(true)}
|
||||
>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
<Menu
|
||||
open={menu}
|
||||
onClose={() => setMenu(false)}
|
||||
anchor={ref}
|
||||
items={options}
|
||||
placement="top-start"
|
||||
aria-label="Create options"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* The merge direction (git4idea's `MergeDirectionComponentFactory`): two link-style
|
||||
* dropdowns, the base and the head, joined by "←". The base lists the branches of the
|
||||
* connected repository's remote; the head lists the local branches and the branches of
|
||||
* the chosen repository (the project's GitHub remotes and forks).
|
||||
*/
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { HighlightedText, Icon, List, Popup, SearchField, Select, T, fuzzyMatch, listNavigate } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { nameWithOwner, sameRepository, useGhRepository, type GHRemote } from "../data/repository";
|
||||
import { ChooserPopup } from "../ui/ChooserPopup";
|
||||
import {
|
||||
baseText,
|
||||
headBranchName,
|
||||
headText,
|
||||
localBranchesOf,
|
||||
remoteBranchesOf,
|
||||
sameHead,
|
||||
type HeadBranch,
|
||||
type RemoteBranchRef,
|
||||
} from "./model";
|
||||
import { setBaseBranch, setHead, useCreatePr } from "./store";
|
||||
import styles from "./Create.module.css";
|
||||
|
||||
function DropDownLink(props: { text: string | null; label: string; onClick: (el: HTMLElement) => void }) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
aria-label={tx(props.label)}
|
||||
title={props.text ?? undefined}
|
||||
onClick={(e) => props.onClick(e.currentTarget)}
|
||||
>
|
||||
<span className={styles.linkText}>{props.text ?? tx("Select…")}</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectionSelector() {
|
||||
const direction = useCreatePr((s) => s.direction);
|
||||
const branches = useCreatePr((s) => s.branches);
|
||||
const [open, setOpen] = useState<{ which: "base" | "head"; anchor: HTMLElement } | null>(null);
|
||||
const baseBranches = useMemo(
|
||||
() => (direction ? remoteBranchesOf(branches, direction.baseRepo.remote) : []),
|
||||
[branches, direction],
|
||||
);
|
||||
if (!direction) return null;
|
||||
return (
|
||||
<div className={styles.direction}>
|
||||
<DropDownLink
|
||||
text={baseText(direction)}
|
||||
label="Base branch"
|
||||
onClick={(anchor) => setOpen({ which: "base", anchor })}
|
||||
/>
|
||||
<span className={styles.arrow} aria-hidden>
|
||||
←
|
||||
</span>
|
||||
<DropDownLink
|
||||
text={headText(direction)}
|
||||
label="Head branch"
|
||||
onClick={(anchor) => setOpen({ which: "head", anchor })}
|
||||
/>
|
||||
{open?.which === "base" && (
|
||||
<ChooserPopup<RemoteBranchRef>
|
||||
mode="single"
|
||||
anchor={open.anchor}
|
||||
items={baseBranches}
|
||||
getKey={(b) => b.name}
|
||||
present={(b) => ({ text: b.name, icon: "branch-label" })}
|
||||
placeholder="Search branches"
|
||||
aria-label="Base branch"
|
||||
onChoose={setBaseBranch}
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
)}
|
||||
{open?.which === "head" && (
|
||||
<HeadPopup
|
||||
anchor={open.anchor}
|
||||
initialRepo={direction.headRepo ?? direction.baseRepo}
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HeadRow = { section: string } | { branch: HeadBranch; text: string; match: number[] | null };
|
||||
|
||||
/** The head: the repository (several GitHub remotes only), then its branches, local first. */
|
||||
function HeadPopup(props: { anchor: HTMLElement; initialRepo: GHRemote; onClose: () => void }) {
|
||||
const tx = useTx();
|
||||
const remotes = useGhRepository((s) => s.remotes);
|
||||
const branches = useCreatePr((s) => s.branches);
|
||||
const current = useCreatePr((s) => s.direction?.headBranch ?? null);
|
||||
const [repo, setRepo] = useState(props.initialRepo);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [active, setActive] = useState(1);
|
||||
const rows = useMemo(() => {
|
||||
const q = filter.trim();
|
||||
const section = (title: string, list: HeadBranch[]): HeadRow[] => {
|
||||
const items = list.flatMap((branch) => {
|
||||
const text = headBranchName(branch);
|
||||
const match = q ? fuzzyMatch(text, q) : null;
|
||||
return q && !match ? [] : [{ branch, text, match }];
|
||||
});
|
||||
return items.length ? [{ section: title }, ...items] : [];
|
||||
};
|
||||
const remote = remoteBranchesOf(branches, repo.remote).map((b): HeadBranch => ({ kind: "remote", ...b }));
|
||||
return [...section(tx("Local"), localBranchesOf(branches)), ...section(tx("Remote"), remote)];
|
||||
}, [branches, repo, filter, tx]);
|
||||
const isSection = (r: HeadRow) => "section" in r;
|
||||
const index = Math.min(Math.max(active, 0), rows.length - 1);
|
||||
const choose = (r: HeadRow | undefined) => {
|
||||
if (!r || "section" in r) return;
|
||||
setHead(repo, r.branch);
|
||||
props.onClose();
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const next = listNavigate(e.key, { items: rows, active: index, wrap: true, pageSize: 7, isSection });
|
||||
if (next !== null && e.key !== "Home" && e.key !== "End") {
|
||||
e.preventDefault();
|
||||
setActive(next);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
choose(rows[index]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
onClose={props.onClose}
|
||||
role="dialog"
|
||||
aria-label={tx("Head branch")}
|
||||
className={styles.headPopup}
|
||||
>
|
||||
{remotes.length > 1 && (
|
||||
<div className={styles.headRepo}>
|
||||
<span className={styles.headRepoLabel}>
|
||||
<T>Repository:</T>
|
||||
</span>
|
||||
<Select
|
||||
value={nameWithOwner(repo)}
|
||||
options={remotes.map((r) => ({ value: nameWithOwner(r), label: `${nameWithOwner(r)} (${r.remote})` }))}
|
||||
onChange={(v) => {
|
||||
const next = remotes.find((r) => nameWithOwner(r) === v);
|
||||
if (next && !sameRepository(next, repo)) setRepo(next);
|
||||
}}
|
||||
aria-label="Head repository"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.headSearch}>
|
||||
<SearchField
|
||||
value={filter}
|
||||
onChange={(v) => {
|
||||
setFilter(v);
|
||||
setActive(1);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={tx("Search branches")}
|
||||
aria-label="Search branches"
|
||||
data-autofocus
|
||||
/>
|
||||
</div>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(r) => ("section" in r ? `s:${r.section}` : `${r.branch.kind}:${r.text}`)}
|
||||
isSection={isSection}
|
||||
renderSection={(r) => <div className={styles.headSection}>{"section" in r ? r.section : null}</div>}
|
||||
sectionHeight={22}
|
||||
activeIndex={index}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(r) => choose(r)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={24}
|
||||
className={styles.headList}
|
||||
aria-label="Branches"
|
||||
empty={<div className={styles.hint}>{tx("Nothing to show")}</div>}
|
||||
renderItem={(r, state) =>
|
||||
"section" in r ? null : (
|
||||
<div className={styles.headRow} data-active={state.active || undefined}>
|
||||
<Icon name={sameHead(r.branch, current) ? "current-branch-label" : "branch-label"} size={16} />
|
||||
<HighlightedText text={r.text} indices={r.match} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Reviewers, assignees and labels of the new pull request (`LabeledListComponentsFactory`
|
||||
* grid): a grey label ("Reviewers:", or "No reviewers" while empty), the chosen items
|
||||
* with avatars or label colours and an edit button opening a multi-select chooser above.
|
||||
*/
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { IconButton } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { GHLabel, GHPullRequestRequestedReviewer, GHUser } from "../api/types";
|
||||
import { isTeam, reviewerKey } from "../data/model";
|
||||
import { usePotentialReviewers, useGhRepository, useRepositoryList } from "../data/repository";
|
||||
import { Avatar, AVATAR_SIZE } from "../ui/Avatar";
|
||||
import { ChooserPopup, type ChooserItemPresentation } from "../ui/ChooserPopup";
|
||||
import { LabelChip, LabelSwatch } from "../ui/chips";
|
||||
import { selectableReviewers, setAssignees, setLabels, setReviewers, useCreatePr } from "./store";
|
||||
import styles from "./Create.module.css";
|
||||
|
||||
interface ListRowProps<T> {
|
||||
label: string;
|
||||
emptyLabel: string;
|
||||
items: readonly T[];
|
||||
getKey: (item: T) => string;
|
||||
renderItem: (item: T) => ReactNode;
|
||||
chooser: (anchor: HTMLElement, close: () => void) => ReactNode;
|
||||
}
|
||||
|
||||
function ListRow<T>(props: ListRowProps<T>) {
|
||||
const tx = useTx();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const label = props.items.length ? props.label : props.emptyLabel;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.metaLabel}>{tx(label)}</div>
|
||||
<div className={styles.metaList}>
|
||||
{props.items.map((item) => (
|
||||
<span key={props.getKey(item)} className={styles.metaItem}>
|
||||
{props.renderItem(item)}
|
||||
</span>
|
||||
))}
|
||||
<IconButton icon="edit" size="small" title="Edit" onClick={(e) => setAnchor(e.currentTarget)} />
|
||||
{anchor && props.chooser(anchor, () => setAnchor(null))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const userPresentation = (u: GHUser): ChooserItemPresentation => ({
|
||||
text: u.login,
|
||||
detail: u.name,
|
||||
avatarUrl: u.avatarUrl,
|
||||
});
|
||||
|
||||
function reviewerPresentation(r: GHPullRequestRequestedReviewer): ChooserItemPresentation {
|
||||
if (isTeam(r)) return { text: r.slug, detail: r.teamName, avatarUrl: r.teamAvatarUrl };
|
||||
return { text: r.login, detail: r.name ?? null, avatarUrl: r.avatarUrl };
|
||||
}
|
||||
|
||||
function UserItem({ text, avatarUrl }: { text: string; avatarUrl?: string | null }) {
|
||||
return (
|
||||
<span className={styles.userItem}>
|
||||
<Avatar url={avatarUrl} name={text} size={AVATAR_SIZE.BASE} />
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetadataGrid() {
|
||||
const reviewers = useCreatePr((s) => s.reviewers);
|
||||
const assignees = useCreatePr((s) => s.assignees);
|
||||
const labels = useCreatePr((s) => s.labels);
|
||||
const me = useGhRepository((s) => s.connection?.currentUser);
|
||||
const potentialReviewers = usePotentialReviewers();
|
||||
const potentialAssignees = useRepositoryList("assignees");
|
||||
const repoLabels = useRepositoryList("labels");
|
||||
return (
|
||||
<div className={styles.metadata}>
|
||||
<ListRow
|
||||
label="Reviewers:"
|
||||
emptyLabel="No reviewers"
|
||||
items={reviewers}
|
||||
getKey={reviewerKey}
|
||||
renderItem={(r) => <UserItem {...reviewerPresentation(r)} />}
|
||||
chooser={(anchor, close) => (
|
||||
<ChooserPopup<GHPullRequestRequestedReviewer>
|
||||
mode="multi"
|
||||
anchor={anchor}
|
||||
placement="top-start"
|
||||
items={selectableReviewers(potentialReviewers.items, me)}
|
||||
loading={potentialReviewers.loading}
|
||||
error={potentialReviewers.error}
|
||||
chosen={reviewers}
|
||||
getKey={reviewerKey}
|
||||
present={reviewerPresentation}
|
||||
aria-label="Reviewers"
|
||||
onClose={(chosen) => {
|
||||
setReviewers(chosen);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ListRow
|
||||
label="Assignees:"
|
||||
emptyLabel="Unassigned"
|
||||
items={assignees}
|
||||
getKey={(u) => u.login}
|
||||
renderItem={(u) => <UserItem text={u.login} avatarUrl={u.avatarUrl} />}
|
||||
chooser={(anchor, close) => (
|
||||
<ChooserPopup<GHUser>
|
||||
mode="multi"
|
||||
anchor={anchor}
|
||||
placement="top-start"
|
||||
items={potentialAssignees.items}
|
||||
loading={potentialAssignees.loading}
|
||||
error={potentialAssignees.error}
|
||||
chosen={assignees}
|
||||
getKey={(u) => u.login}
|
||||
present={userPresentation}
|
||||
aria-label="Assignees"
|
||||
onClose={(chosen) => {
|
||||
setAssignees(chosen);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ListRow
|
||||
label="Labels:"
|
||||
emptyLabel="No labels"
|
||||
items={labels}
|
||||
getKey={(l) => l.name}
|
||||
renderItem={(l) => <LabelChip label={l} />}
|
||||
chooser={(anchor, close) => (
|
||||
<ChooserPopup<GHLabel>
|
||||
mode="multi"
|
||||
anchor={anchor}
|
||||
placement="top-start"
|
||||
items={repoLabels.items}
|
||||
loading={repoLabels.loading}
|
||||
error={repoLabels.error}
|
||||
chosen={labels}
|
||||
getKey={(l) => l.name}
|
||||
present={(l) => ({ text: l.name, leading: <LabelSwatch label={l} /> })}
|
||||
aria-label="Labels"
|
||||
onClose={(chosen) => {
|
||||
setLabels(chosen);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** "New Remote Branch for Pull Request": the name to push a local head without a push target to. */
|
||||
import { useState } from "react";
|
||||
import { Dialog, Input, T } from "@/ui";
|
||||
import { openDialog } from "../ui/dialogs";
|
||||
import styles from "./Create.module.css";
|
||||
|
||||
/** Asks for the remote branch name (suggesting `suggested`); null when cancelled. */
|
||||
export function remoteBranchNameDialog(suggested: string): Promise<string | null> {
|
||||
return openDialog<string>((done) => <RemoteBranchDialog suggested={suggested} onDone={done} />);
|
||||
}
|
||||
|
||||
function RemoteBranchDialog(props: { suggested: string; onDone: (name: string | null) => void }) {
|
||||
const [name, setName] = useState(props.suggested);
|
||||
const valid = !!name.trim();
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="New Remote Branch for Pull Request"
|
||||
onClose={() => props.onDone(null)}
|
||||
width={420}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", disabled: !valid, onClick: () => props.onDone(name.trim()) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<form
|
||||
className={styles.dialogForm}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (valid) props.onDone(name.trim());
|
||||
}}
|
||||
>
|
||||
<label className={styles.dialogLabel}>
|
||||
<T>Remote branch name:</T>
|
||||
</label>
|
||||
<Input data-autofocus aria-label="Remote branch name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<div className={styles.dialogComment}>
|
||||
<T>Your changes will be pushed to the remote repository to create the pull request</T>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Branch } from "@/platform/ipc";
|
||||
import type { GHRemote } from "../data/repository";
|
||||
import {
|
||||
baseText,
|
||||
checkBranches,
|
||||
cleanUpBranchName,
|
||||
commitDescription,
|
||||
commitsRange,
|
||||
defaultBaseBranch,
|
||||
defaultDirection,
|
||||
generateTitle,
|
||||
headRefName,
|
||||
headText,
|
||||
needsPush,
|
||||
pushTarget,
|
||||
remoteBranchesOf,
|
||||
searchHead,
|
||||
splitRemoteBranch,
|
||||
trackedHead,
|
||||
type Direction,
|
||||
} from "./model";
|
||||
import { canCreate, selectableReviewers } from "./store";
|
||||
|
||||
const branch = (name: string, extra: Partial<Branch> = {}): Branch => ({
|
||||
name,
|
||||
remote: false,
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
lastCommitMs: null,
|
||||
...extra,
|
||||
});
|
||||
const remote = (name: string, commit?: string) => branch(name, { remote: true, commit });
|
||||
|
||||
const origin: GHRemote = { owner: "l0ng-ai", name: "openidea", remote: "origin", url: "" };
|
||||
const fork: GHRemote = { owner: "me", name: "openidea", remote: "fork", url: "" };
|
||||
|
||||
const branches = [
|
||||
branch("feature", { current: true, upstream: "fork/feature", commit: "a1" }),
|
||||
branch("local-only", { commit: "b1" }),
|
||||
branch("tracking-main", { upstream: "origin/main", commit: "c1" }),
|
||||
remote("origin/HEAD"),
|
||||
remote("origin/main", "c1"),
|
||||
remote("origin/master", "m1"),
|
||||
remote("fork/feature", "a0"),
|
||||
];
|
||||
|
||||
describe("merge direction", () => {
|
||||
it("splits remote branches by the longest remote name", () => {
|
||||
expect(splitRemoteBranch("origin/feat/x", ["origin"])).toEqual({ remote: "origin", name: "feat/x" });
|
||||
expect(splitRemoteBranch("my/fork/x", ["my", "my/fork"])).toEqual({ remote: "my/fork", name: "x" });
|
||||
expect(splitRemoteBranch("upstream/x", ["origin"])).toBeNull();
|
||||
});
|
||||
|
||||
it("lists a remote's branches without HEAD", () => {
|
||||
expect(remoteBranchesOf(branches, "origin").map((b) => b.name)).toEqual(["main", "master"]);
|
||||
});
|
||||
|
||||
it("defaults the base to the repository default, then master, then main", () => {
|
||||
expect(defaultBaseBranch(branches, "origin", "main")).toEqual({ remote: "origin", name: "main" });
|
||||
expect(defaultBaseBranch(branches, "origin", "develop")).toEqual({ remote: "origin", name: "master" });
|
||||
expect(defaultBaseBranch([remote("origin/main")], "origin", null)).toEqual({ remote: "origin", name: "main" });
|
||||
expect(defaultBaseBranch([], "origin", "main")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults the head to the current branch in its tracked remote's repository", () => {
|
||||
const d = defaultDirection({
|
||||
baseRepo: origin,
|
||||
remotes: [origin, fork],
|
||||
branches,
|
||||
currentBranch: "feature",
|
||||
defaultBranch: "main",
|
||||
});
|
||||
expect(d.headRepo).toBe(fork);
|
||||
expect(d.headBranch).toEqual({ kind: "local", name: "feature" });
|
||||
expect(d.baseBranch).toEqual({ remote: "origin", name: "main" });
|
||||
const untracked = defaultDirection({
|
||||
baseRepo: origin,
|
||||
remotes: [origin, fork],
|
||||
branches,
|
||||
currentBranch: "local-only",
|
||||
defaultBranch: "main",
|
||||
});
|
||||
expect(untracked.headRepo).toBe(origin);
|
||||
});
|
||||
|
||||
it("shows the owner only when the repositories differ", () => {
|
||||
const same: Direction = {
|
||||
baseRepo: origin,
|
||||
baseBranch: { remote: "origin", name: "main" },
|
||||
headRepo: origin,
|
||||
headBranch: { kind: "local", name: "feature" },
|
||||
};
|
||||
expect(baseText(same)).toBe("openidea:main");
|
||||
expect(headText(same)).toBe("openidea:feature");
|
||||
const forked: Direction = { ...same, headRepo: fork, headBranch: { kind: "remote", remote: "fork", name: "x" } };
|
||||
expect(baseText(forked)).toBe("l0ng-ai/openidea:main");
|
||||
expect(headText(forked)).toBe("me/openidea:fork/x");
|
||||
expect(baseText({ ...same, baseBranch: null })).toBeNull();
|
||||
expect(headText({ ...same, headRepo: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the log range and the API head names", () => {
|
||||
expect(commitsRange({ remote: "origin", name: "main" }, { kind: "local", name: "f" })).toBe("origin/main..f");
|
||||
expect(commitsRange({ remote: "origin", name: "main" }, { kind: "remote", remote: "fork", name: "f" })).toBe(
|
||||
"origin/main..fork/f",
|
||||
);
|
||||
expect(headRefName(origin, origin, "f")).toBe("f");
|
||||
expect(headRefName(origin, fork, "f")).toBe("me:f");
|
||||
expect(searchHead(origin, "f")).toBe("l0ng-ai:f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branches check", () => {
|
||||
const base = { remote: "origin", name: "main" };
|
||||
const head = { kind: "local" as const, name: "tracking-main" };
|
||||
|
||||
it("finds the tracked branch of the head", () => {
|
||||
expect(trackedHead(head, branches, ["origin", "fork"])).toEqual(base);
|
||||
expect(trackedHead({ kind: "local", name: "local-only" }, branches, ["origin"])).toBeNull();
|
||||
});
|
||||
|
||||
it("reports no changes, an existing pull request, or OK", () => {
|
||||
expect(checkBranches({ base, head, tracked: base, commits: [1], existing: null })).toEqual({
|
||||
kind: "noChanges",
|
||||
base: "main",
|
||||
head: "tracking-main",
|
||||
});
|
||||
expect(checkBranches({ base, head, tracked: null, commits: [], existing: 3 }).kind).toBe("noChanges");
|
||||
expect(checkBranches({ base, head, tracked: null, commits: [1], existing: 3 })).toEqual({
|
||||
kind: "alreadyExists",
|
||||
number: 3,
|
||||
});
|
||||
expect(checkBranches({ base, head, tracked: null, commits: [1], existing: null })).toEqual({ kind: "ok" });
|
||||
});
|
||||
|
||||
it("enables Create only for OK while nothing runs or after a failure", () => {
|
||||
const okCheck = { state: "ok" as const, value: { kind: "ok" as const } };
|
||||
expect(canCreate({ check: okCheck, creation: null })).toBe(true);
|
||||
expect(canCreate({ check: okCheck, creation: { kind: "error", error: "x" } })).toBe(true);
|
||||
expect(canCreate({ check: okCheck, creation: { kind: "pushing" } })).toBe(false);
|
||||
expect(canCreate({ check: { state: "loading" }, creation: null })).toBe(false);
|
||||
expect(canCreate({ check: { state: "ok", value: { kind: "alreadyExists", number: 1 } }, creation: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("push target", () => {
|
||||
it("is the tracked branch on the head's remote", () => {
|
||||
expect(pushTarget(branches, "feature", "fork", ["origin", "fork"])).toEqual({ remote: "fork", name: "feature" });
|
||||
expect(pushTarget(branches, "feature", "origin", ["origin", "fork"])).toBeNull();
|
||||
expect(pushTarget(branches, "local-only", "origin", ["origin", "fork"])).toBeNull();
|
||||
});
|
||||
|
||||
it("needs a push when the tips differ or the remote branch is missing", () => {
|
||||
expect(needsPush(branches, "feature", { remote: "fork", name: "feature" })).toBe(true);
|
||||
expect(needsPush(branches, "tracking-main", { remote: "origin", name: "main" })).toBe(false);
|
||||
expect(needsPush(branches, "local-only", { remote: "origin", name: "local-only" })).toBe(true);
|
||||
});
|
||||
|
||||
it("cleans up branch names like GitRefNameValidator", () => {
|
||||
expect(cleanUpBranchName(" my feature: v2? ")).toBe("my_feature__v2_");
|
||||
expect(cleanUpBranchName("a..b~c^d")).toBe("a_b_c_d");
|
||||
expect(cleanUpBranchName("/x.lock")).toBe("x_lock");
|
||||
});
|
||||
});
|
||||
|
||||
describe("text", () => {
|
||||
it("titles with the only commit's subject, else the branch", () => {
|
||||
const head = { kind: "local" as const, name: "feature" };
|
||||
expect(generateTitle([{ summary: "Fix it" }], head)).toBe("Fix it");
|
||||
expect(generateTitle([{ summary: "a" }, { summary: "b" }], head)).toBe("feature");
|
||||
expect(generateTitle([], { kind: "remote", remote: "fork", name: "x" })).toBe("fork/x");
|
||||
});
|
||||
|
||||
it("takes the description paragraph of a commit message", () => {
|
||||
expect(commitDescription("Subject\n\nBody line\nmore\n\nTrailer: x")).toBe("Body line\nmore");
|
||||
expect(commitDescription("Subject")).toBe("");
|
||||
});
|
||||
|
||||
it("leaves the current user out of the reviewers", () => {
|
||||
const me = { __typename: "User" as const, id: "1", login: "me", url: "", avatarUrl: "", name: null };
|
||||
const other = { ...me, id: "2", login: "other" };
|
||||
const team = {
|
||||
__typename: "Team" as const,
|
||||
id: "1",
|
||||
slug: "core",
|
||||
url: "",
|
||||
teamAvatarUrl: null,
|
||||
teamName: "Core",
|
||||
combinedSlug: "org/core",
|
||||
};
|
||||
expect(selectableReviewers([me, other, team], me)).toEqual([other, team]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Pure logic of the New Pull Request tab (IntelliJ's `GHPRCreateViewModel` and
|
||||
* `GHPRCreateMergeDirectionModel`): the merge direction (base and head repository and
|
||||
* branch) and how it is shown, the default branches, the branches check, the generated
|
||||
* title and the push target of a local head.
|
||||
*/
|
||||
import type { Branch, Commit } from "@/platform/ipc";
|
||||
import type { GHRepositoryCoordinates } from "../api/types";
|
||||
import type { GHRemote } from "../data/repository";
|
||||
|
||||
/** A branch of a remote (`origin/main` is `{ remote: "origin", name: "main" }`). */
|
||||
export interface RemoteBranchRef {
|
||||
remote: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** The head: a local branch or a remote one (`GitLocalBranch` / `GitRemoteBranch`). */
|
||||
export type HeadBranch = { kind: "local"; name: string } | ({ kind: "remote" } & RemoteBranchRef);
|
||||
|
||||
/** `BranchesState`: the base is always a branch of the connected repository. */
|
||||
export interface Direction {
|
||||
baseRepo: GHRemote;
|
||||
baseBranch: RemoteBranchRef | null;
|
||||
headRepo: GHRemote | null;
|
||||
headBranch: HeadBranch | null;
|
||||
}
|
||||
|
||||
const sameCoordinates = (a: GHRepositoryCoordinates, b: GHRepositoryCoordinates) =>
|
||||
a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
|
||||
|
||||
/** `origin/main` → `{ remote: "origin", name: "main" }`, the longest matching remote name winning. */
|
||||
export function splitRemoteBranch(full: string, remotes: readonly string[]): RemoteBranchRef | null {
|
||||
const remote = [...remotes].filter((r) => r && full.startsWith(`${r}/`)).sort((a, b) => b.length - a.length)[0];
|
||||
return remote ? { remote, name: full.slice(remote.length + 1) } : null;
|
||||
}
|
||||
|
||||
export const remoteBranchFullName = (b: RemoteBranchRef) => `${b.remote}/${b.name}`;
|
||||
|
||||
/** What git calls the head (`feature`, `origin/feature`). */
|
||||
export const headBranchName = (b: HeadBranch) => (b.kind === "local" ? b.name : remoteBranchFullName(b));
|
||||
|
||||
export const sameRemoteBranch = (a: RemoteBranchRef | null | undefined, b: RemoteBranchRef | null | undefined) =>
|
||||
!!a && !!b && a.remote === b.remote && a.name === b.name;
|
||||
|
||||
export function sameHead(a: HeadBranch | null, b: HeadBranch | null): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
if (a.kind === "local" || b.kind === "local") return a.kind === b.kind && a.name === b.name;
|
||||
return sameRemoteBranch(a, b);
|
||||
}
|
||||
|
||||
/** The branches of `remote` (its `HEAD` pointer left out), sorted by name. */
|
||||
export function remoteBranchesOf(branches: readonly Branch[], remote: string): RemoteBranchRef[] {
|
||||
return branches
|
||||
.filter((b) => b.remote)
|
||||
.flatMap((b) => {
|
||||
const ref = splitRemoteBranch(b.name, [remote]);
|
||||
return ref && ref.name !== "HEAD" ? [ref] : [];
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Local branches, sorted by name. */
|
||||
export function localBranchesOf(branches: readonly Branch[]): HeadBranch[] {
|
||||
return branches
|
||||
.filter((b) => !b.remote)
|
||||
.map((b): HeadBranch => ({ kind: "local", name: b.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* `getDefaultRemoteBranch`: the repository's default branch on the base remote, else
|
||||
* `master`, else `main`.
|
||||
*/
|
||||
export function defaultBaseBranch(
|
||||
branches: readonly Branch[],
|
||||
remote: string,
|
||||
defaultBranch: string | null | undefined,
|
||||
): RemoteBranchRef | null {
|
||||
const names = new Set(remoteBranchesOf(branches, remote).map((b) => b.name));
|
||||
const name = [defaultBranch, "master", "main"].find((n): n is string => !!n && names.has(n));
|
||||
return name ? { remote, name } : null;
|
||||
}
|
||||
|
||||
/** The remote branch a local branch tracks (`upstream`), split by the known remotes. */
|
||||
export function upstreamOf(
|
||||
branches: readonly Branch[],
|
||||
local: string,
|
||||
remotes: readonly string[],
|
||||
): RemoteBranchRef | null {
|
||||
const upstream = branches.find((b) => !b.remote && b.name === local)?.upstream;
|
||||
return upstream ? splitRemoteBranch(upstream, remotes) : null;
|
||||
}
|
||||
|
||||
/** The remote branch the head is or tracks (none for an untracked local branch). */
|
||||
export function trackedHead(
|
||||
head: HeadBranch,
|
||||
branches: readonly Branch[],
|
||||
remotes: readonly string[],
|
||||
): RemoteBranchRef | null {
|
||||
return head.kind === "remote" ? head : upstreamOf(branches, head.name, remotes);
|
||||
}
|
||||
|
||||
/**
|
||||
* The default direction: base the default branch of the connected repository's remote,
|
||||
* head the current branch in the repository of its tracked remote (else the base one).
|
||||
*/
|
||||
export function defaultDirection(opts: {
|
||||
baseRepo: GHRemote;
|
||||
remotes: readonly GHRemote[];
|
||||
branches: readonly Branch[];
|
||||
currentBranch: string | null;
|
||||
defaultBranch: string | null | undefined;
|
||||
}): Direction {
|
||||
const { baseRepo, remotes, branches, currentBranch } = opts;
|
||||
const names = remotes.map((r) => r.remote);
|
||||
const tracked = currentBranch ? upstreamOf(branches, currentBranch, names) : null;
|
||||
const headRepo = (tracked && remotes.find((r) => r.remote === tracked.remote)) || baseRepo;
|
||||
return {
|
||||
baseRepo,
|
||||
baseBranch: defaultBaseBranch(branches, baseRepo.remote, opts.defaultBranch),
|
||||
headRepo,
|
||||
headBranch: currentBranch ? { kind: "local", name: currentBranch } : null,
|
||||
};
|
||||
}
|
||||
|
||||
const repoText = (r: GHRepositoryCoordinates, showOwner: boolean) => (showOwner ? `${r.owner}/${r.name}` : r.name);
|
||||
|
||||
/** The base as the direction selector shows it: `repo:branch`, `owner/repo:branch` when the head's repository differs. */
|
||||
export function baseText(d: Direction): string | null {
|
||||
if (!d.baseBranch) return null;
|
||||
const showOwner = !!d.headRepo && !sameCoordinates(d.headRepo, d.baseRepo);
|
||||
return `${repoText(d.baseRepo, showOwner)}:${d.baseBranch.name}`;
|
||||
}
|
||||
|
||||
/** The head as the direction selector shows it: `repo:branch` (`origin/branch` for a remote branch). */
|
||||
export function headText(d: Direction): string | null {
|
||||
if (!d.headBranch || !d.headRepo) return null;
|
||||
const showOwner = !sameCoordinates(d.headRepo, d.baseRepo);
|
||||
return `${repoText(d.headRepo, showOwner)}:${headBranchName(d.headBranch)}`;
|
||||
}
|
||||
|
||||
/** `git log base..head`. */
|
||||
export const commitsRange = (base: RemoteBranchRef, head: HeadBranch) =>
|
||||
`${remoteBranchFullName(base)}..${headBranchName(head)}`;
|
||||
|
||||
/** `generateTitle`: the subject of the only commit, else the branch name. */
|
||||
export function generateTitle(commits: readonly Pick<Commit, "summary">[], head: HeadBranch): string {
|
||||
return commits.length === 1 ? commits[0]!.summary : headBranchName(head);
|
||||
}
|
||||
|
||||
/** `headRefName` of `createPullRequest`: `owner:branch` for a branch of another repository (a fork). */
|
||||
export function headRefName(baseRepo: GHRepositoryCoordinates, headRepo: GHRepositoryCoordinates, branch: string) {
|
||||
return sameCoordinates(baseRepo, headRepo) ? branch : `${headRepo.owner}:${branch}`;
|
||||
}
|
||||
|
||||
/** `head` of the REST pull request search: always `owner:branch`. */
|
||||
export const searchHead = (headRepo: GHRepositoryCoordinates, branch: string) => `${headRepo.owner}:${branch}`;
|
||||
|
||||
/** `GitPushUtil.findPushTarget`: the tracked branch when it is on the head's remote. */
|
||||
export function pushTarget(
|
||||
branches: readonly Branch[],
|
||||
local: string,
|
||||
headRemote: string,
|
||||
remotes: readonly string[],
|
||||
): RemoteBranchRef | null {
|
||||
const upstream = upstreamOf(branches, local, remotes);
|
||||
return upstream?.remote === headRemote ? upstream : null;
|
||||
}
|
||||
|
||||
/** Whether the local branch differs from its push target (or the target does not exist yet). */
|
||||
export function needsPush(branches: readonly Branch[], local: string, target: RemoteBranchRef): boolean {
|
||||
const localTip = branches.find((b) => !b.remote && b.name === local)?.commit;
|
||||
const remoteTip = branches.find((b) => b.remote && b.name === remoteBranchFullName(target))?.commit;
|
||||
return !localTip || !remoteTip || localTip !== remoteTip;
|
||||
}
|
||||
|
||||
const ILLEGAL_REF_CHARS = /[\u0000- \u007f~^:?*[\\]|\.\.|@\{/g; // eslint-disable-line no-control-regex
|
||||
|
||||
/** `GitRefNameValidator.cleanUpBranchName`: characters git refuses become `_`. */
|
||||
export function cleanUpBranchName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.replace(ILLEGAL_REF_CHARS, "_")
|
||||
.replace(/^[./-]+|[./]+$/g, "")
|
||||
.replace(/\.lock$/, "_lock");
|
||||
}
|
||||
|
||||
/** `BranchesCheckResult`. */
|
||||
export type BranchesCheckResult =
|
||||
| { kind: "noChanges"; base: string; head: string }
|
||||
| { kind: "alreadyExists"; number: number }
|
||||
| { kind: "ok" };
|
||||
|
||||
/**
|
||||
* `checkBranches`: no changes when the head tracks the base or has no commits over
|
||||
* it, else an open pull request of the head, else OK.
|
||||
*/
|
||||
export function checkBranches(opts: {
|
||||
base: RemoteBranchRef;
|
||||
head: HeadBranch;
|
||||
tracked: RemoteBranchRef | null;
|
||||
commits: readonly unknown[];
|
||||
existing: number | null;
|
||||
}): BranchesCheckResult {
|
||||
const { base, head } = opts;
|
||||
const noChanges = { kind: "noChanges" as const, base: base.name, head: headBranchName(head) };
|
||||
if (sameRemoteBranch(base, opts.tracked)) return noChanges;
|
||||
if (opts.commits.length === 0) return noChanges;
|
||||
if (opts.existing !== null) return { kind: "alreadyExists", number: opts.existing };
|
||||
return { kind: "ok" };
|
||||
}
|
||||
|
||||
/** The description part of a commit message (`fullMessage.split("\n\n")[1]`). */
|
||||
export function commitDescription(message: string): string {
|
||||
return message.split("\n\n")[1]?.trim() ?? "";
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* State of the New Pull Request tab (IntelliJ's `GHPRCreateViewModel`), kept while the
|
||||
* tab is open (switching tabs keeps the form) and dropped when it closes:
|
||||
*
|
||||
* - the merge direction, its commits (`git log base..head`), the branches check and the
|
||||
* changes of all commits (three-dot diff) or of the selected one, all computed locally;
|
||||
* - the title (the only commit's subject or the branch name until edited), the
|
||||
* description (the repository's template) and the reviewers, assignees and labels;
|
||||
* - creation: push a local head first (asking for the remote branch name when it has
|
||||
* none), `createPullRequest`, then the metadata; on success the pull request opens and
|
||||
* the tab closes.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { commitFileDiffData, type DiffData } from "@/features/vcs/diffInput";
|
||||
import { lastRemoteFailure, runRemote } from "@/features/vcs/ops";
|
||||
import { useVcs } from "@/features/vcs/store";
|
||||
import type { Branch, ChangeKind, Commit } from "@/platform/ipc";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { errorMessage } from "../api/client";
|
||||
import * as api from "../api/requests";
|
||||
import type { GHLabel, GHPullRequestRequestedReviewer, GHUser } from "../api/types";
|
||||
import { isTeam } from "../data/model";
|
||||
import { createPullRequest } from "../data/operations";
|
||||
import { loadPullRequestTemplate, requireConnection, sameRepository, useGhRepository } from "../data/repository";
|
||||
import type { GHRemote } from "../data/repository";
|
||||
import { refreshCurrentBranchPullRequest } from "../reviewMode/state";
|
||||
import { openPullRequest } from "../toolwindow/open";
|
||||
import { closeTab, useGhTabs } from "../toolwindow/store";
|
||||
import {
|
||||
checkBranches,
|
||||
cleanUpBranchName,
|
||||
commitsRange,
|
||||
defaultDirection,
|
||||
generateTitle,
|
||||
headBranchName,
|
||||
headRefName,
|
||||
needsPush,
|
||||
pushTarget,
|
||||
remoteBranchFullName,
|
||||
sameHead,
|
||||
sameRemoteBranch,
|
||||
searchHead,
|
||||
trackedHead,
|
||||
type BranchesCheckResult,
|
||||
type Direction,
|
||||
type HeadBranch,
|
||||
type RemoteBranchRef,
|
||||
} from "./model";
|
||||
import { remoteBranchNameDialog } from "./RemoteBranchDialog";
|
||||
|
||||
export type Loadable<T> = { state: "loading" } | { state: "error"; error: string } | { state: "ok"; value: T };
|
||||
|
||||
/** A changed file of the preview, with its diff. */
|
||||
export interface CreateChange {
|
||||
/** Repository-relative path (after the change). */
|
||||
path: string;
|
||||
oldPath: string | null;
|
||||
kind: ChangeKind;
|
||||
diff: DiffData;
|
||||
}
|
||||
|
||||
/** `CreationState` (CollectingData, Pushing, CallingAPI, SettingMetadata, Created, Error). */
|
||||
export type CreationState =
|
||||
| { kind: "collecting" | "pushing" | "creating" | "metadata" | "created" }
|
||||
| { kind: "error"; error: string };
|
||||
|
||||
interface CreateState {
|
||||
/** Initialized (branches read, direction chosen). */
|
||||
ready: boolean;
|
||||
initError: string | null;
|
||||
/** Local and remote branches of the repository. */
|
||||
branches: Branch[];
|
||||
direction: Direction | null;
|
||||
/** `git log base..head`, newest first; null until base and head are chosen. */
|
||||
commits: Loadable<Commit[]> | null;
|
||||
check: Loadable<BranchesCheckResult> | null;
|
||||
/** The commit whose changes are shown; null: all commits. */
|
||||
selectedCommit: string | null;
|
||||
changes: Loadable<CreateChange[]> | null;
|
||||
title: string;
|
||||
/** The user typed a title: it no longer follows the commits. */
|
||||
titleEdited: boolean;
|
||||
description: string;
|
||||
descriptionEdited: boolean;
|
||||
templateLoading: boolean;
|
||||
reviewers: GHPullRequestRequestedReviewer[];
|
||||
assignees: GHUser[];
|
||||
labels: GHLabel[];
|
||||
creation: CreationState | null;
|
||||
}
|
||||
|
||||
const initial: CreateState = {
|
||||
ready: false,
|
||||
initError: null,
|
||||
branches: [],
|
||||
direction: null,
|
||||
commits: null,
|
||||
check: null,
|
||||
selectedCommit: null,
|
||||
changes: null,
|
||||
title: "",
|
||||
titleEdited: false,
|
||||
description: "",
|
||||
descriptionEdited: false,
|
||||
templateLoading: false,
|
||||
reviewers: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
creation: null,
|
||||
};
|
||||
|
||||
export const useCreatePr = create<CreateState>(() => ({ ...initial }));
|
||||
|
||||
/** Bumped by every reset: late results for a dropped form are ignored. */
|
||||
let generation = 0;
|
||||
/** Bumped by every direction change and refresh: late results of older checks are dropped. */
|
||||
let checkSeq = 0;
|
||||
let changesSeq = 0;
|
||||
|
||||
/** The repository's root, which the preview's repository-relative paths are relative to. */
|
||||
function repoRoot(): string | null {
|
||||
return useVcs.getState().roots[0] ?? useProject.getState().project?.root ?? null;
|
||||
}
|
||||
|
||||
const ok = <T>(value: T): Loadable<T> => ({ state: "ok", value });
|
||||
const failed = (e: unknown): Loadable<never> => ({ state: "error", error: errorMessage(e) });
|
||||
|
||||
const remoteNames = () => useGhRepository.getState().remotes.map((r) => r.remote);
|
||||
|
||||
/** The connected repository's GitHub remote (the base). */
|
||||
function baseRemote(): GHRemote {
|
||||
const { coordinates } = requireConnection();
|
||||
const remotes = useGhRepository.getState().remotes;
|
||||
return remotes.find((r) => sameRepository(r, coordinates)) ?? { ...coordinates, remote: "origin", url: "" };
|
||||
}
|
||||
|
||||
/** Reads the branches and picks the default direction; starts loading the template. */
|
||||
export async function initCreate(): Promise<void> {
|
||||
if (useCreatePr.getState().ready) return;
|
||||
const e = ++generation;
|
||||
try {
|
||||
const { repository } = requireConnection();
|
||||
const [branches, info] = await Promise.all([
|
||||
call(commands.vcsBranches(null)),
|
||||
call(commands.vcsBranchInfo(null)).catch(() => null),
|
||||
]);
|
||||
if (e !== generation) return;
|
||||
const direction = defaultDirection({
|
||||
baseRepo: baseRemote(),
|
||||
remotes: useGhRepository.getState().remotes,
|
||||
branches,
|
||||
currentBranch: info?.head ?? null,
|
||||
defaultBranch: repository.defaultBranchRef?.name,
|
||||
});
|
||||
useCreatePr.setState({ ready: true, initError: null, branches, direction, templateLoading: true });
|
||||
void loadPullRequestTemplate().then((template) => {
|
||||
if (e !== generation) return;
|
||||
const s = useCreatePr.getState();
|
||||
useCreatePr.setState({ templateLoading: false, ...(s.descriptionEdited ? {} : { description: template ?? "" }) });
|
||||
});
|
||||
reloadBranchesState();
|
||||
} catch (err) {
|
||||
if (e === generation) useCreatePr.setState({ initError: errorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the form (the tab closed, another repository). */
|
||||
export function resetCreate() {
|
||||
generation++;
|
||||
checkSeq++;
|
||||
changesSeq++;
|
||||
useCreatePr.setState({ ...initial });
|
||||
}
|
||||
|
||||
export function setBaseBranch(branch: RemoteBranchRef) {
|
||||
const d = useCreatePr.getState().direction;
|
||||
if (!d || sameRemoteBranch(d.baseBranch, branch)) return;
|
||||
useCreatePr.setState({ direction: { ...d, baseBranch: branch } });
|
||||
reloadBranchesState();
|
||||
}
|
||||
|
||||
export function setHead(repo: GHRemote, branch: HeadBranch) {
|
||||
const d = useCreatePr.getState().direction;
|
||||
if (!d || (d.headRepo && sameRepository(d.headRepo, repo) && sameHead(d.headBranch, branch))) return;
|
||||
useCreatePr.setState({ direction: { ...d, headRepo: repo, headBranch: branch } });
|
||||
reloadBranchesState();
|
||||
}
|
||||
|
||||
/** The Refresh of a failed check: reads the branches again and checks them. */
|
||||
export async function refreshBranchesState(): Promise<void> {
|
||||
const branches = await call(commands.vcsBranches(null)).catch(() => null);
|
||||
if (branches) useCreatePr.setState({ branches });
|
||||
reloadBranchesState();
|
||||
}
|
||||
|
||||
const tip = (branches: readonly Branch[], name: string) => branches.find((b) => b.name === name)?.commit;
|
||||
|
||||
/** The repository changed: reloads the commits when the base or head moved (`infoStateIn`). */
|
||||
export async function onRepositoryChanged(): Promise<void> {
|
||||
const s = useCreatePr.getState();
|
||||
if (!s.ready || !s.direction) return;
|
||||
const branches = await call(commands.vcsBranches(null)).catch(() => null);
|
||||
if (!branches) return;
|
||||
const { baseBranch, headBranch } = useCreatePr.getState().direction ?? {};
|
||||
const before = useCreatePr.getState().branches;
|
||||
useCreatePr.setState({ branches });
|
||||
const moved = (name: string) => tip(before, name) !== tip(branches, name);
|
||||
if (
|
||||
(baseBranch && moved(remoteBranchFullName(baseBranch))) ||
|
||||
(headBranch && moved(headBranchName(headBranch))) ||
|
||||
(headBranch?.kind === "local" &&
|
||||
before.find((b) => b.name === headBranch.name)?.upstream !==
|
||||
branches.find((b) => b.name === headBranch.name)?.upstream)
|
||||
)
|
||||
reloadBranchesState();
|
||||
}
|
||||
|
||||
/** `BranchesStateWithProgress`: the commits, the open pull request of the head and the check. */
|
||||
function reloadBranchesState() {
|
||||
const e = ++checkSeq;
|
||||
const { direction: d, branches } = useCreatePr.getState();
|
||||
const base = d?.baseBranch;
|
||||
const head = d?.headBranch;
|
||||
const headRepo = d?.headRepo;
|
||||
if (!base || !head || !headRepo) {
|
||||
useCreatePr.setState({ commits: null, check: null, changes: null, selectedCommit: null });
|
||||
return;
|
||||
}
|
||||
const tracked = trackedHead(head, branches, remoteNames());
|
||||
const trackingBase = sameRemoteBranch(base, tracked);
|
||||
useCreatePr.setState({
|
||||
commits: { state: "loading" },
|
||||
changes: { state: "loading" },
|
||||
selectedCommit: null,
|
||||
check: trackingBase
|
||||
? ok(checkBranches({ base, head, tracked, commits: [], existing: null }))
|
||||
: { state: "loading" },
|
||||
});
|
||||
const existing = tracked
|
||||
? api
|
||||
.findOpenPullRequestsByHead(searchHead(headRepo, tracked.name), base.name)
|
||||
.then((prs) => prs[0]?.number ?? null)
|
||||
: Promise.resolve(null);
|
||||
existing.catch(() => undefined);
|
||||
const commits = call(
|
||||
commands.vcsLog(null, {
|
||||
branch: null,
|
||||
path: null,
|
||||
author: null,
|
||||
text: null,
|
||||
range: commitsRange(base, head),
|
||||
skip: 0,
|
||||
limit: 10_000,
|
||||
}),
|
||||
);
|
||||
void commits.then(
|
||||
(list) => {
|
||||
if (e !== checkSeq) return;
|
||||
const s = useCreatePr.getState();
|
||||
useCreatePr.setState({ commits: ok(list), ...(s.titleEdited ? {} : { title: generateTitle(list, head) }) });
|
||||
void loadChanges();
|
||||
},
|
||||
(err) => {
|
||||
if (e === checkSeq) useCreatePr.setState({ commits: failed(err), changes: null });
|
||||
},
|
||||
);
|
||||
if (trackingBase) return;
|
||||
void Promise.all([commits, existing]).then(
|
||||
([list, number]) => {
|
||||
if (e === checkSeq)
|
||||
useCreatePr.setState({ check: ok(checkBranches({ base, head, tracked, commits: list, existing: number })) });
|
||||
},
|
||||
(err) => {
|
||||
if (e === checkSeq) useCreatePr.setState({ check: failed(err) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Shows the changes of commit `id` (null: all commits). */
|
||||
export function selectCommit(id: string | null) {
|
||||
if (useCreatePr.getState().selectedCommit === id) return;
|
||||
useCreatePr.setState({ selectedCommit: id });
|
||||
void loadChanges();
|
||||
}
|
||||
|
||||
/** Diff of a file of all commits: the merge base ↔ the head. */
|
||||
function rangeDiffData(
|
||||
file: { path: string; oldPath: string | null; kind: ChangeKind },
|
||||
root: string | null,
|
||||
base: { rev: string; label: string },
|
||||
head: { rev: string; label: string },
|
||||
): DiffData {
|
||||
const abs = (rel: string) => (root ? `${root}/${rel}` : rel);
|
||||
return {
|
||||
path: abs(file.path),
|
||||
left:
|
||||
file.kind === "added" || file.kind === "untracked"
|
||||
? { type: "text", text: "", label: tx("(does not exist)") }
|
||||
: { type: "rev", rev: base.rev, path: file.oldPath ? abs(file.oldPath) : undefined, label: base.label },
|
||||
right: file.kind === "deleted" ? { type: "text", text: "", label: tx("(deleted)") } : { type: "rev", ...head },
|
||||
};
|
||||
}
|
||||
|
||||
/** `getDiff`: the three-dot diff of all commits, or the selected commit's changes. */
|
||||
async function loadChanges() {
|
||||
const seq = ++changesSeq;
|
||||
const s = useCreatePr.getState();
|
||||
const d = s.direction;
|
||||
if (s.commits?.state !== "ok" || !d?.baseBranch || !d.headBranch) return;
|
||||
const list = s.commits.value;
|
||||
if (!list.length) {
|
||||
useCreatePr.setState({ changes: null });
|
||||
return;
|
||||
}
|
||||
useCreatePr.setState({ changes: { state: "loading" } });
|
||||
const root = repoRoot();
|
||||
try {
|
||||
let changes: CreateChange[];
|
||||
const commit = s.selectedCommit ? list.find((c) => c.id === s.selectedCommit) : null;
|
||||
if (commit) {
|
||||
const files = await call(commands.vcsCommitFiles(null, commit.id));
|
||||
changes = files.map((f) => ({ ...f, diff: commitFileDiffData({ ...commit, repo: root }, f) }));
|
||||
} else {
|
||||
const head = list[0]!.id;
|
||||
const baseName = remoteBranchFullName(d.baseBranch);
|
||||
const mergeBase = await call(commands.vcsMergeBase(null, baseName, head));
|
||||
if (!mergeBase) throw new Error(tx`Unrelated branches ${baseName} ${headBranchName(d.headBranch)}`);
|
||||
const files = await call(commands.vcsDiffRevisions(null, mergeBase, head));
|
||||
const baseSide = { rev: mergeBase, label: `${d.baseBranch.name} (${mergeBase.slice(0, 8)})` };
|
||||
const headSide = { rev: head, label: `${headBranchName(d.headBranch)} (${head.slice(0, 8)})` };
|
||||
changes = files.map((f) => ({ ...f, diff: rangeDiffData(f, root, baseSide, headSide) }));
|
||||
}
|
||||
if (seq === changesSeq) useCreatePr.setState({ changes: ok(changes) });
|
||||
} catch (err) {
|
||||
if (seq === changesSeq) useCreatePr.setState({ changes: failed(err) });
|
||||
}
|
||||
}
|
||||
|
||||
export function setTitle(title: string) {
|
||||
useCreatePr.setState({ title, titleEdited: true });
|
||||
}
|
||||
|
||||
export function setDescription(description: string) {
|
||||
useCreatePr.setState({ description, descriptionEdited: true });
|
||||
}
|
||||
|
||||
export function setReviewers(reviewers: GHPullRequestRequestedReviewer[]) {
|
||||
useCreatePr.setState({ reviewers });
|
||||
}
|
||||
|
||||
export function setAssignees(assignees: GHUser[]) {
|
||||
useCreatePr.setState({ assignees });
|
||||
}
|
||||
|
||||
export function setLabels(labels: GHLabel[]) {
|
||||
useCreatePr.setState({ labels });
|
||||
}
|
||||
|
||||
/** Create is enabled when the check is OK and nothing runs (or the last attempt failed). */
|
||||
export function canCreate(s: Pick<CreateState, "check" | "creation">): boolean {
|
||||
return s.check?.state === "ok" && s.check.value.kind === "ok" && (s.creation === null || s.creation.kind === "error");
|
||||
}
|
||||
|
||||
/** Pushes a local head when needed; resolves with the remote branch name, null when cancelled. */
|
||||
async function pushHead(head: Extract<HeadBranch, { kind: "local" }>, headRepo: GHRemote): Promise<string | null> {
|
||||
const branches = useCreatePr.getState().branches;
|
||||
let target = pushTarget(branches, head.name, headRepo.remote, remoteNames());
|
||||
if (!target) {
|
||||
const name = await remoteBranchNameDialog(head.name);
|
||||
if (name === null) return null;
|
||||
target = { remote: headRepo.remote, name: cleanUpBranchName(name) };
|
||||
}
|
||||
if (needsPush(branches, head.name, target)) {
|
||||
useCreatePr.setState({ creation: { kind: "pushing" } });
|
||||
const pushed = await runRemote(
|
||||
{
|
||||
op: "pushBranch",
|
||||
branch: head.name,
|
||||
revision: null,
|
||||
force_with_lease: false,
|
||||
target: target.name,
|
||||
remote: target.remote,
|
||||
},
|
||||
`${head.name} → ${remoteBranchFullName(target)}`,
|
||||
[null],
|
||||
);
|
||||
if (!pushed) {
|
||||
const error = lastRemoteFailure();
|
||||
// Cancelled: back to the form.
|
||||
if (!error) return null;
|
||||
throw new Error(tx`Push failed: ${error}`);
|
||||
}
|
||||
const after = await call(commands.vcsBranches(null)).catch(() => null);
|
||||
if (after) useCreatePr.setState({ branches: after });
|
||||
}
|
||||
return target.name;
|
||||
}
|
||||
|
||||
/** "Create Pull Request" / "Create Draft Pull Request". */
|
||||
export async function createFromForm(draft: boolean): Promise<void> {
|
||||
const s = useCreatePr.getState();
|
||||
const d = s.direction;
|
||||
if (!canCreate(s) || !d?.baseBranch || !d.headBranch || !d.headRepo) return;
|
||||
const { baseBranch, headBranch, headRepo } = d;
|
||||
const e = generation;
|
||||
useCreatePr.setState({ creation: { kind: "collecting" } });
|
||||
try {
|
||||
const remoteName = headBranch.kind === "remote" ? headBranch.name : await pushHead(headBranch, headRepo);
|
||||
if (remoteName === null) {
|
||||
useCreatePr.setState({ creation: null });
|
||||
return;
|
||||
}
|
||||
useCreatePr.setState({ creation: { kind: "creating" } });
|
||||
const { coordinates } = requireConnection();
|
||||
const pr = await createPullRequest({
|
||||
base: baseBranch.name,
|
||||
head: headRefName(coordinates, headRepo, remoteName),
|
||||
title: s.title,
|
||||
body: s.description,
|
||||
draft,
|
||||
reviewers: s.reviewers,
|
||||
assignees: s.assignees.map((u) => u.login),
|
||||
labels: s.labels.map((l) => l.name),
|
||||
onSettingMetadata: () => useCreatePr.setState({ creation: { kind: "metadata" } }),
|
||||
});
|
||||
useCreatePr.setState({ creation: { kind: "created" } });
|
||||
void refreshCurrentBranchPullRequest();
|
||||
await openPullRequest(pr.number);
|
||||
closeTab("create");
|
||||
} catch (err) {
|
||||
if (e === generation) useCreatePr.setState({ creation: { kind: "error", error: errorMessage(err) } });
|
||||
}
|
||||
}
|
||||
|
||||
/** The reviewers one can pick: not the current user. */
|
||||
export function selectableReviewers(items: readonly GHPullRequestRequestedReviewer[], me: GHUser | null | undefined) {
|
||||
return me ? items.filter((r) => isTeam(r) || r.id !== me.id) : [...items];
|
||||
}
|
||||
|
||||
// The form lives as long as its tab.
|
||||
useGhTabs.subscribe((t, prev) => {
|
||||
const open = t.tabs.some((x) => x.kind === "create");
|
||||
if (!open && prev.tabs.some((x) => x.kind === "create")) resetCreate();
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Incrementally loaded lists (IntelliJ's `BatchesLoader` / `IncrementallyComputedValue`):
|
||||
* pages are appended as they arrive, so choosers show the first users while the rest load.
|
||||
* A list loads once and stays cached until `reset()`.
|
||||
*/
|
||||
import { create, type StoreApi, type UseBoundStore } from "zustand";
|
||||
import { errorMessage } from "../api/client";
|
||||
|
||||
export interface BatchesState<T> {
|
||||
items: T[];
|
||||
/** A page is being fetched. */
|
||||
loading: boolean;
|
||||
/** Every page arrived. */
|
||||
done: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BatchesLoader<T> {
|
||||
store: UseBoundStore<StoreApi<BatchesState<T>>>;
|
||||
/** Starts loading unless loaded or loading; resolves with every item (rejects on failure). */
|
||||
load(): Promise<T[]>;
|
||||
/** Forgets the items; the next `load()` starts over. */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
const initial = <T>(): BatchesState<T> => ({ items: [], loading: false, done: false, error: null });
|
||||
|
||||
/** A loader fetching `page(cursor)` from `first` until `next` is null. */
|
||||
export function createBatchesLoader<T, C>(
|
||||
first: C,
|
||||
page: (cursor: C) => Promise<{ items: T[]; next: C | null }>,
|
||||
): BatchesLoader<T> {
|
||||
const store = create<BatchesState<T>>(() => initial<T>());
|
||||
let run: Promise<T[]> | null = null;
|
||||
let generation = 0;
|
||||
const load = () => {
|
||||
if (run) return run;
|
||||
const gen = ++generation;
|
||||
const alive = () => gen === generation;
|
||||
store.setState({ ...initial<T>(), loading: true });
|
||||
run = (async () => {
|
||||
let cursor: C | null = first;
|
||||
try {
|
||||
while (cursor !== null) {
|
||||
const r: { items: T[]; next: C | null } = await page(cursor);
|
||||
if (!alive()) return store.getState().items;
|
||||
store.setState((s) => ({ items: [...s.items, ...r.items] }));
|
||||
cursor = r.next;
|
||||
}
|
||||
if (alive()) store.setState({ loading: false, done: true });
|
||||
return store.getState().items;
|
||||
} catch (e) {
|
||||
if (alive()) {
|
||||
store.setState({ loading: false, error: errorMessage(e) });
|
||||
run = null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
return run;
|
||||
};
|
||||
const reset = () => {
|
||||
generation++;
|
||||
run = null;
|
||||
store.setState(initial<T>());
|
||||
};
|
||||
return { store, load, reset };
|
||||
}
|
||||
|
||||
/** Merges several loaders' states (potential reviewers: teams + users), keeping their order. */
|
||||
export function mergeBatches<T>(states: ReadonlyArray<BatchesState<T>>): BatchesState<T> {
|
||||
return {
|
||||
items: states.flatMap((s) => s.items),
|
||||
loading: states.some((s) => s.loading),
|
||||
done: states.every((s) => s.done),
|
||||
error: states.find((s) => s.error)?.error ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
GHActor,
|
||||
GHPullRequestMergeabilityData,
|
||||
GHPullRequestReview,
|
||||
GHPullRequestShort,
|
||||
GHTeam,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import { createBatchesLoader, mergeBatches } from "./batches";
|
||||
import {
|
||||
buildMergeabilityState,
|
||||
filterCount,
|
||||
hasPermission,
|
||||
isSeen,
|
||||
listSearchQuery,
|
||||
mergeCommitMessage,
|
||||
normalizeSearch,
|
||||
parseGitHubRemote,
|
||||
remoteOfRepository,
|
||||
pullRequestPermissions,
|
||||
pullRequestStateIcon,
|
||||
repositoryDisplayName,
|
||||
reviewersStates,
|
||||
reviewerStateOf,
|
||||
searchTerms,
|
||||
sortedReviewersStates,
|
||||
splitCommitMessage,
|
||||
stateTagText,
|
||||
unresolvedThreadsCount,
|
||||
} from "./model";
|
||||
import { githubRemotes } from "./repository";
|
||||
import { withSearch } from "./searchHistory";
|
||||
|
||||
const user = (login: string): GHUser => ({
|
||||
__typename: "User",
|
||||
id: `id-${login}`,
|
||||
login,
|
||||
url: `https://github.com/${login}`,
|
||||
avatarUrl: "",
|
||||
name: null,
|
||||
});
|
||||
const team: GHTeam = {
|
||||
__typename: "Team",
|
||||
id: "T1",
|
||||
slug: "core",
|
||||
url: "",
|
||||
teamAvatarUrl: null,
|
||||
teamName: "Core",
|
||||
combinedSlug: "org/core",
|
||||
};
|
||||
let reviewSeq = 0;
|
||||
const review = (author: GHActor | null, state: GHPullRequestReview["state"], minute: number): GHPullRequestReview => ({
|
||||
__typename: "PullRequestReview",
|
||||
id: `R${++reviewSeq}`,
|
||||
url: "",
|
||||
author,
|
||||
body: "",
|
||||
state,
|
||||
createdAt: `2026-10-01T10:${String(minute).padStart(2, "0")}:00Z`,
|
||||
viewerCanUpdate: false,
|
||||
});
|
||||
|
||||
function pr(over: Partial<GHPullRequestShort> = {}): GHPullRequestShort {
|
||||
return {
|
||||
id: "PR1",
|
||||
url: "https://github.com/o/r/pull/1",
|
||||
number: 1,
|
||||
title: "Fix",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
author: user("ann"),
|
||||
createdAt: "2026-10-01T09:00:00Z",
|
||||
updatedAt: "2026-10-01T12:00:00Z",
|
||||
isReadByViewer: false,
|
||||
assignees: { nodes: [] },
|
||||
labels: { nodes: [] },
|
||||
reviewRequests: { nodes: [] },
|
||||
reviewThreads: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
reactions: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: [],
|
||||
},
|
||||
mergeable: "MERGEABLE",
|
||||
viewerCanUpdate: false,
|
||||
viewerCanReact: true,
|
||||
viewerDidAuthor: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("list search", () => {
|
||||
it("builds IntelliJ's terms in order", () => {
|
||||
expect(
|
||||
searchTerms({
|
||||
searchQuery: "foo bar",
|
||||
state: "MERGED",
|
||||
assignee: "me",
|
||||
reviewState: "NOT_REVIEWED_BY_ME",
|
||||
author: "ann",
|
||||
label: "good first issue",
|
||||
sort: "LEAST_RECENTLY_UPDATED",
|
||||
}),
|
||||
).toBe('(foo bar) is:merged assignee:me -reviewed-by:@me author:ann label:"good first issue" sort:updated-asc');
|
||||
expect(searchTerms({ reviewState: "AWAITING_REVIEW" })).toBe("review-requested:@me");
|
||||
expect(searchTerms({})).toBeNull();
|
||||
});
|
||||
|
||||
it("prefixes the repository for issueSearch", () => {
|
||||
expect(listSearchQuery({ owner: "o", name: "r" }, { state: "OPEN" })).toBe("type:pr repo:o/r is:open");
|
||||
expect(listSearchQuery({ owner: "o", name: "r" }, {})).toBe("type:pr repo:o/r");
|
||||
});
|
||||
|
||||
it("keeps GitHub's boolean syntax inside the repository", () => {
|
||||
const q = "is:pr is:open (review-requested:ann OR assignee:@ann)";
|
||||
expect(listSearchQuery({ owner: "o", name: "r" }, { searchQuery: q })).toBe(`type:pr repo:o/r (${q})`);
|
||||
expect(searchTerms({ searchQuery: " " })).toBeNull();
|
||||
});
|
||||
|
||||
it("counts filters and normalizes stored values", () => {
|
||||
expect(filterCount({ searchQuery: "x", state: "OPEN", sort: "NEWEST" })).toBe(3);
|
||||
expect(normalizeSearch({ state: "OPEN", sort: "SIDEWAYS", label: "", author: "ann", extra: 1 })).toEqual({
|
||||
state: "OPEN",
|
||||
author: "ann",
|
||||
});
|
||||
expect(normalizeSearch("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps searches with filters in the history, newest first, at most 10", () => {
|
||||
let h = withSearch([], { state: "OPEN" });
|
||||
expect(h).toEqual([]); // the default
|
||||
h = withSearch(h, {});
|
||||
expect(h).toEqual([]); // no filters
|
||||
h = withSearch(h, { author: "a" });
|
||||
h = withSearch(h, { author: "b" });
|
||||
h = withSearch(h, { author: "a" });
|
||||
expect(h).toEqual([{ author: "a" }, { author: "b" }]);
|
||||
for (let i = 0; i < 12; i++) h = withSearch(h, { label: `l${i}` });
|
||||
expect(h).toHaveLength(10);
|
||||
expect(h[0]).toEqual({ label: "l11" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewer states", () => {
|
||||
it("lets the newest opinionated review win over later comments", () => {
|
||||
expect(reviewerStateOf([review(user("bo"), "APPROVED", 1), review(user("bo"), "COMMENTED", 2)])).toBe("APPROVED");
|
||||
expect(reviewerStateOf([review(user("bo"), "APPROVED", 1), review(user("bo"), "CHANGES_REQUESTED", 2)])).toBe(
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a dismissed latest review as a comment", () => {
|
||||
expect(reviewerStateOf([review(user("bo"), "APPROVED", 1), review(user("bo"), "DISMISSED", 2)])).toBe("COMMENTED");
|
||||
});
|
||||
|
||||
it("skips reviewers with only pending reviews", () => {
|
||||
expect(reviewerStateOf([review(user("bo"), "PENDING", 1)])).toBeNull();
|
||||
expect(reviewerStateOf([review(user("bo"), "PENDING", 2), review(user("bo"), "COMMENTED", 1)])).toBe("COMMENTED");
|
||||
});
|
||||
|
||||
it("excludes the author and marks requests as re-requested or needing review", () => {
|
||||
const states = reviewersStates(
|
||||
pr({
|
||||
reviews: {
|
||||
nodes: [
|
||||
review(user("ann"), "COMMENTED", 1),
|
||||
review(user("bo"), "APPROVED", 2),
|
||||
review(user("cy"), "COMMENTED", 3),
|
||||
],
|
||||
},
|
||||
reviewRequests: {
|
||||
nodes: [{ requestedReviewer: user("cy") }, { requestedReviewer: team }, { requestedReviewer: null }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
states.map((s) => [s.reviewer.__typename === "Team" ? "team" : (s.reviewer as GHUser).login, s.state]),
|
||||
).toEqual([
|
||||
["bo", "APPROVED"],
|
||||
["cy", "RE_REQUESTED"],
|
||||
["team", "NEEDS_REVIEW"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts reviews of deleted users under the ghost user", () => {
|
||||
const states = reviewersStates(pr({ reviews: { nodes: [review(null, "APPROVED", 1)] } }));
|
||||
expect(states).toHaveLength(1);
|
||||
expect((states[0]!.reviewer as GHUser).login).toBe("ghost");
|
||||
});
|
||||
|
||||
it("orders the row's avatars approved → changes → commented → re-requested → needs review", () => {
|
||||
const states = sortedReviewersStates(
|
||||
pr({
|
||||
reviews: {
|
||||
nodes: [
|
||||
review(user("c"), "COMMENTED", 1),
|
||||
review(user("x"), "CHANGES_REQUESTED", 2),
|
||||
review(user("a"), "APPROVED", 3),
|
||||
],
|
||||
},
|
||||
reviewRequests: { nodes: [{ requestedReviewer: user("n") }] },
|
||||
}),
|
||||
);
|
||||
expect(states.map((s) => s.state)).toEqual(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "NEEDS_REVIEW"]);
|
||||
});
|
||||
|
||||
it("counts unresolved, current threads", () => {
|
||||
expect(
|
||||
unresolvedThreadsCount(
|
||||
pr({
|
||||
reviewThreads: {
|
||||
nodes: [
|
||||
{ isResolved: false, isOutdated: false },
|
||||
{ isResolved: true, isOutdated: false },
|
||||
{ isResolved: false, isOutdated: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unread", () => {
|
||||
const me = { id: "id-me" };
|
||||
const updated = Date.parse("2026-10-01T12:00:00Z");
|
||||
|
||||
it("only applies to pull requests the user wrote or is asked to review", () => {
|
||||
expect(isSeen(pr(), me, null)).toBe(true);
|
||||
expect(isSeen(pr({ author: user("me") }), me, null)).toBe(false);
|
||||
expect(isSeen(pr({ reviewRequests: { nodes: [{ requestedReviewer: user("me") }] } }), me, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares the last seen time, with a 5 s margin, with the update", () => {
|
||||
const mine = pr({ author: user("me") });
|
||||
expect(isSeen(mine, me, updated - 4000)).toBe(true);
|
||||
expect(isSeen(mine, me, updated - 6000)).toBe(false);
|
||||
expect(isSeen(mine, me, updated + 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeability", () => {
|
||||
const data = (over: Partial<GHPullRequestMergeabilityData> = {}): GHPullRequestMergeabilityData => ({
|
||||
mergeable: "MERGEABLE",
|
||||
canBeRebased: true,
|
||||
mergeStateStatus: "CLEAN",
|
||||
baseRef: null,
|
||||
commits: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: [
|
||||
{
|
||||
id: "c",
|
||||
commit: {
|
||||
id: "c",
|
||||
oid: "abc",
|
||||
abbreviatedOid: "abc",
|
||||
status: {
|
||||
contexts: [
|
||||
{ context: "ci/a", state: "SUCCESS", isRequired: true, targetUrl: "u1" },
|
||||
{ context: "ci/b", state: "ERROR", isRequired: false, targetUrl: null },
|
||||
],
|
||||
},
|
||||
checkSuites: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: [
|
||||
{
|
||||
checkRuns: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: [
|
||||
{ name: "build", conclusion: null, isRequired: false, detailsUrl: null, url: "run" },
|
||||
{ name: "lint", conclusion: "SKIPPED", isRequired: false, detailsUrl: "d", url: "run2" },
|
||||
{ name: "test", conclusion: "TIMED_OUT", isRequired: true, detailsUrl: null, url: "run3" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
...over,
|
||||
});
|
||||
const rule = {
|
||||
allowsDeletions: false,
|
||||
allowsForcePushes: false,
|
||||
pattern: "main",
|
||||
requiredApprovingReviewCount: 2,
|
||||
requiredStatusCheckContexts: ["ci/a"],
|
||||
requiresLinearHistory: false,
|
||||
requiresSignatures: false,
|
||||
viewerCanPush: true,
|
||||
};
|
||||
|
||||
it("maps statuses and check runs to CI jobs", () => {
|
||||
const s = buildMergeabilityState(data(), false);
|
||||
expect(s.ciJobs.map((j) => [j.name, j.status, j.detailsUrl])).toEqual([
|
||||
["ci/a", "SUCCESS", "u1"],
|
||||
["ci/b", "FAILED", null],
|
||||
["build", "PENDING", "run"],
|
||||
["lint", "SKIPPED", "d"],
|
||||
["test", "FAILED", "run3"],
|
||||
]);
|
||||
expect(s).toMatchObject({
|
||||
hasConflicts: false,
|
||||
canBeMerged: true,
|
||||
checksState: null,
|
||||
requiredApprovingReviewsCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("follows the merge state and branch protection", () => {
|
||||
expect(buildMergeabilityState(data({ mergeable: "UNKNOWN" }), false).hasConflicts).toBeNull();
|
||||
expect(buildMergeabilityState(data({ mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" }), false)).toMatchObject({
|
||||
hasConflicts: true,
|
||||
canBeMerged: false,
|
||||
});
|
||||
const blocked = data({ mergeStateStatus: "BLOCKED", baseRef: { refUpdateRule: rule } });
|
||||
expect(buildMergeabilityState(blocked, false)).toMatchObject({
|
||||
canBeMerged: false,
|
||||
checksState: null,
|
||||
requiredApprovingReviewsCount: 2,
|
||||
});
|
||||
expect(buildMergeabilityState(blocked, true).canBeMerged).toBe(true);
|
||||
const failing = data({
|
||||
mergeStateStatus: "BLOCKED",
|
||||
baseRef: { refUpdateRule: { ...rule, requiredStatusCheckContexts: ["ci/b"] } },
|
||||
});
|
||||
expect(buildMergeabilityState(failing, false)).toMatchObject({
|
||||
checksState: "BLOCKING_FAILING",
|
||||
requiredApprovingReviewsCount: 0,
|
||||
});
|
||||
expect(buildMergeabilityState(data({ mergeStateStatus: "BEHIND" }), false).checksState).toBe("BLOCKING_BEHIND");
|
||||
const restricted = data({
|
||||
mergeStateStatus: "BLOCKED",
|
||||
baseRef: { refUpdateRule: { ...rule, viewerCanPush: false } },
|
||||
});
|
||||
expect(buildMergeabilityState(restricted, false)).toMatchObject({
|
||||
isRestricted: true,
|
||||
requiredApprovingReviewsCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("decides the merge actions from permission, settings and state", () => {
|
||||
const repo = {
|
||||
viewerPermission: "WRITE" as const,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: true,
|
||||
};
|
||||
const state = buildMergeabilityState(data(), false);
|
||||
expect(pullRequestPermissions({ viewerDidAuthor: false, state: "OPEN" }, repo, state)).toEqual({
|
||||
canManage: true,
|
||||
canMerge: true,
|
||||
canSquashMerge: false,
|
||||
canRebaseMerge: true,
|
||||
});
|
||||
expect(pullRequestPermissions({ viewerDidAuthor: false, state: "OPEN" }, repo, state, true).canMerge).toBe(false);
|
||||
const reader = { ...repo, viewerPermission: "READ" as const };
|
||||
expect(pullRequestPermissions({ viewerDidAuthor: true, state: "OPEN" }, reader, state)).toMatchObject({
|
||||
canManage: true,
|
||||
canMerge: false,
|
||||
});
|
||||
expect(pullRequestPermissions({ viewerDidAuthor: false, state: "OPEN" }, reader, state).canManage).toBe(false);
|
||||
expect(hasPermission("MAINTAIN", "WRITE")).toBe(true);
|
||||
expect(hasPermission(null, "READ")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pull request helpers", () => {
|
||||
it("names states like IntelliJ", () => {
|
||||
expect(stateTagText("OPEN", false)).toBeNull();
|
||||
expect(stateTagText("OPEN", true)).toBe("DRAFT");
|
||||
expect(stateTagText("MERGED", true)).toBe("MERGED");
|
||||
expect(pullRequestStateIcon("OPEN", true)).toBe("github-pr-draft");
|
||||
expect(pullRequestStateIcon("CLOSED", false)).toBe("github-pr-closed");
|
||||
});
|
||||
|
||||
it("prefills and splits merge commit messages", () => {
|
||||
expect(mergeCommitMessage({ number: 7, title: "Fix it" })).toBe("Merge pull request #7\n\nFix it");
|
||||
expect(mergeCommitMessage({ number: 7, title: "x" }, ["a", "b"])).toBe("Merge pull request #7\n\n* a\n\n* b");
|
||||
expect(splitCommitMessage("Subject\n\nBody\nmore")).toEqual({ title: "Subject", body: "Body\nmore" });
|
||||
expect(splitCommitMessage("Only")).toEqual({ title: "Only", body: "" });
|
||||
});
|
||||
|
||||
it("shows the owner only when owners differ", () => {
|
||||
const a = { owner: "me", name: "app" };
|
||||
const b = { owner: "org", name: "app" };
|
||||
expect(repositoryDisplayName([a], a)).toBe("app");
|
||||
expect(repositoryDisplayName([a, b], a)).toBe("me/app");
|
||||
expect(repositoryDisplayName([a, { owner: "me", name: "lib" }], a)).toBe("app");
|
||||
expect(repositoryDisplayName([a], a, true)).toBe("me/app");
|
||||
});
|
||||
|
||||
it("recognizes github.com remotes", () => {
|
||||
for (const url of [
|
||||
"https://github.com/o/r.git",
|
||||
"https://user@github.com/o/r",
|
||||
"git@github.com:o/r.git",
|
||||
"ssh://git@github.com/o/r.git",
|
||||
"ssh://git@github.com:22/o/r.git",
|
||||
"git://github.com/o/r",
|
||||
])
|
||||
expect(parseGitHubRemote(url), url).toEqual({ owner: "o", name: "r" });
|
||||
expect(parseGitHubRemote("https://gitlab.com/o/r.git")).toBeNull();
|
||||
expect(parseGitHubRemote("/local/repo")).toBeNull();
|
||||
expect(
|
||||
githubRemotes([
|
||||
{ name: "origin", url: "git@github.com:me/r.git" },
|
||||
{ name: "upstream", url: "https://github.com/org/r.git" },
|
||||
{ name: "dup", url: "https://github.com/ORG/r" },
|
||||
{ name: "other", url: "https://example.com/x/y" },
|
||||
]).map((r) => `${r.remote}=${r.owner}/${r.name}`),
|
||||
).toEqual(["origin=me/r", "upstream=org/r"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("batches", () => {
|
||||
let pages: Array<{ items: number[]; next: number | null }>;
|
||||
beforeEach(() => {
|
||||
pages = [
|
||||
{ items: [1, 2], next: 1 },
|
||||
{ items: [3], next: null },
|
||||
];
|
||||
});
|
||||
|
||||
it("appends pages as they arrive and loads once", async () => {
|
||||
let calls = 0;
|
||||
const loader = createBatchesLoader<number, number>(0, async (p) => {
|
||||
calls++;
|
||||
return pages[p]!;
|
||||
});
|
||||
const all = await loader.load();
|
||||
expect(all).toEqual([1, 2, 3]);
|
||||
expect(loader.store.getState()).toMatchObject({ items: [1, 2, 3], loading: false, done: true });
|
||||
await loader.load();
|
||||
expect(calls).toBe(2);
|
||||
loader.reset();
|
||||
expect(loader.store.getState().items).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps what loaded before a failure and retries on the next load", async () => {
|
||||
let fail = true;
|
||||
const loader = createBatchesLoader<number, number>(0, async (p) => {
|
||||
if (p === 1 && fail) throw new Error("boom");
|
||||
return pages[p]!;
|
||||
});
|
||||
await expect(loader.load()).rejects.toThrow("boom");
|
||||
expect(loader.store.getState()).toMatchObject({ items: [1, 2], loading: false, done: false, error: "boom" });
|
||||
fail = false;
|
||||
expect(await loader.load()).toEqual([1, 2, 3]);
|
||||
expect(
|
||||
mergeBatches([
|
||||
{ items: [1], loading: false, done: true, error: null },
|
||||
{ items: [2], loading: true, done: false, error: null },
|
||||
]),
|
||||
).toEqual({ items: [1, 2], loading: true, done: false, error: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("remoteOfRepository", () => {
|
||||
const repo = { owner: "Org", name: "app" };
|
||||
it("finds the remote behind an SSH host alias by the URL's path", () => {
|
||||
const alias = { name: "origin", url: "git@work-github:org/app.git" };
|
||||
expect(remoteOfRepository([{ name: "other", url: "git@work-github:org/app2.git" }, alias], repo)).toBe(alias);
|
||||
expect(remoteOfRepository([{ name: "x", url: "ssh://work-github/org/app/" }], repo)?.name).toBe("x");
|
||||
expect(remoteOfRepository([{ name: "x", url: "git@work-github:notorg/app.git" }], repo)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* When each pull request was last seen (IntelliJ's `GHPRPersistentInteractionState`),
|
||||
* stored per project; the list's unread dots compare it with `updatedAt` (`isSeen` in
|
||||
* `model.ts`). Entries of pull requests updated since and older than
|
||||
* `CLEAR_LAST_SEEN_AFTER_DAYS` are dropped.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { GHPullRequestShort, GHUser } from "../api/types";
|
||||
import { CLEAR_LAST_SEEN_AFTER_DAYS, isSeen } from "./model";
|
||||
|
||||
const key = (root: string) => `openidea.github.lastSeen.v1:${root}`;
|
||||
const projectRoot = () => useProject.getState().project?.root ?? null;
|
||||
|
||||
function load(root: string | null): Record<string, number> {
|
||||
if (!root) return {};
|
||||
try {
|
||||
const v = JSON.parse(globalThis.localStorage?.getItem(key(root)) ?? "{}") as unknown;
|
||||
if (!v || typeof v !== "object") return {};
|
||||
return Object.fromEntries(Object.entries(v).filter(([, t]) => typeof t === "number")) as Record<string, number>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function save(root: string | null, lastSeen: Record<string, number>) {
|
||||
if (!root) return;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(key(root), JSON.stringify(lastSeen));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/** Last-seen times (ms) by pull request node id, for the open project. */
|
||||
export const useLastSeen = create<{ root: string | null; lastSeen: Record<string, number> }>(() => ({
|
||||
root: projectRoot(),
|
||||
lastSeen: load(projectRoot()),
|
||||
}));
|
||||
|
||||
function current(): Record<string, number> {
|
||||
const root = projectRoot();
|
||||
if (useLastSeen.getState().root !== root) useLastSeen.setState({ root, lastSeen: load(root) });
|
||||
return useLastSeen.getState().lastSeen;
|
||||
}
|
||||
|
||||
/** Records that the pull request with node id `prId` was seen now. */
|
||||
export function markSeen(prId: string, at = Date.now()) {
|
||||
const lastSeen = { ...current(), [prId]: at };
|
||||
useLastSeen.setState({ lastSeen });
|
||||
save(projectRoot(), lastSeen);
|
||||
}
|
||||
|
||||
/**
|
||||
* `isSeen` with the stored time; forgets entries that are stale (unseen updates, older than 14 days).
|
||||
* @public
|
||||
*/
|
||||
export function isPullRequestSeen(
|
||||
pr: Pick<GHPullRequestShort, "id" | "author" | "reviewRequests" | "updatedAt">,
|
||||
user: Pick<GHUser, "id">,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const all = current();
|
||||
const lastSeen = all[pr.id];
|
||||
const seen = isSeen(pr, user, lastSeen);
|
||||
if (!seen && lastSeen !== undefined && lastSeen < now - CLEAR_LAST_SEEN_AFTER_DAYS * 86_400_000) {
|
||||
const { [pr.id]: _, ...rest } = all;
|
||||
useLastSeen.setState({ lastSeen: rest });
|
||||
save(projectRoot(), rest);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** React hook: whether `pr` is seen; re-renders when the last-seen times change. */
|
||||
export function usePullRequestSeen(
|
||||
pr: Pick<GHPullRequestShort, "id" | "author" | "reviewRequests" | "updatedAt">,
|
||||
user: Pick<GHUser, "id"> | null | undefined,
|
||||
): boolean {
|
||||
const lastSeen = useLastSeen((s) => s.lastSeen[pr.id]);
|
||||
return !user || isSeen(pr, user, lastSeen);
|
||||
}
|
||||
|
||||
useProject.subscribe((s, prev) => {
|
||||
const root = s.project?.root ?? null;
|
||||
if (root !== (prev.project?.root ?? null)) useLastSeen.setState({ root, lastSeen: load(root) });
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* The pull request list (IntelliJ's `GHPRListLoader` over `issueSearch`): pages of 50,
|
||||
* cursor-paged, for the current search value. `loadMore()` fetches the next page (the
|
||||
* list calls it while scrolling past 70%); `reloadList()` starts over, keeping the rows
|
||||
* visible until the first page arrives. Rows are refreshed in place when a pull request's
|
||||
* details load (`updateListItem`).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { errorMessage } from "../api/client";
|
||||
import * as api from "../api/requests";
|
||||
import type { GHPullRequest, GHPullRequestShort } from "../api/types";
|
||||
import { listSearchQuery, sameSearch, type PrSearchValue } from "./model";
|
||||
import { useGhRepository } from "./repository";
|
||||
|
||||
interface ListState {
|
||||
search: PrSearchValue;
|
||||
items: GHPullRequestShort[];
|
||||
cursor: string | null;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** The first page of the current search arrived (`items` are for `search`). */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
export const useGhPrList = create<ListState>(() => ({
|
||||
search: {},
|
||||
items: [],
|
||||
cursor: null,
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
loaded: false,
|
||||
}));
|
||||
|
||||
let seq = 0;
|
||||
|
||||
async function fetchPage(reset: boolean): Promise<void> {
|
||||
const repo = useGhRepository.getState().connection?.coordinates;
|
||||
if (!repo) return;
|
||||
const my = ++seq;
|
||||
const s = useGhPrList.getState();
|
||||
useGhPrList.setState({ loading: true, error: null });
|
||||
try {
|
||||
const page = await api.searchPullRequests(listSearchQuery(repo, s.search), reset ? null : s.cursor);
|
||||
if (my !== seq) return;
|
||||
useGhPrList.setState((cur) => {
|
||||
const items = reset
|
||||
? page.nodes
|
||||
: [...cur.items, ...page.nodes.filter((n) => !cur.items.some((i) => i.id === n.id))];
|
||||
return {
|
||||
items,
|
||||
cursor: page.pageInfo.endCursor,
|
||||
hasMore: page.pageInfo.hasNextPage,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (my !== seq) return;
|
||||
useGhPrList.setState({
|
||||
loading: false,
|
||||
error: errorMessage(e),
|
||||
hasMore: false,
|
||||
loaded: true,
|
||||
...(reset ? { items: [] } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the pull requests matching `search` (from the first page). */
|
||||
export function setListSearch(search: PrSearchValue): Promise<void> {
|
||||
const same = sameSearch(useGhPrList.getState().search, search);
|
||||
useGhPrList.setState({ search, ...(same ? {} : { items: [], loaded: false }), cursor: null, hasMore: true });
|
||||
return fetchPage(true);
|
||||
}
|
||||
|
||||
/** Loads the next page, if there is one and none is loading. */
|
||||
export function loadMore(): Promise<void> {
|
||||
const s = useGhPrList.getState();
|
||||
if (s.loading || !s.hasMore || !s.loaded) return Promise.resolve();
|
||||
return fetchPage(false);
|
||||
}
|
||||
|
||||
/** "Refresh List": the current search from the first page. */
|
||||
export function reloadList(): Promise<void> {
|
||||
useGhPrList.setState({ cursor: null, hasMore: true });
|
||||
return fetchPage(true);
|
||||
}
|
||||
|
||||
/** Replaces the row of `pr` with fresher data (details loaded, a mutation's result). */
|
||||
export function updateListItem(pr: GHPullRequestShort | GHPullRequest) {
|
||||
useGhPrList.setState((s) => {
|
||||
const i = s.items.findIndex((x) => x.id === pr.id);
|
||||
if (i < 0) return s;
|
||||
const items = [...s.items];
|
||||
items[i] = { ...items[i]!, ...pickShort(pr) };
|
||||
return { items };
|
||||
});
|
||||
}
|
||||
|
||||
function pickShort(pr: GHPullRequestShort): GHPullRequestShort {
|
||||
const {
|
||||
id,
|
||||
url,
|
||||
number,
|
||||
title,
|
||||
state,
|
||||
isDraft,
|
||||
author,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
isReadByViewer,
|
||||
assignees,
|
||||
labels,
|
||||
reviewRequests,
|
||||
reviewThreads,
|
||||
reviews,
|
||||
reactions,
|
||||
mergeable,
|
||||
viewerCanUpdate,
|
||||
viewerCanReact,
|
||||
viewerDidAuthor,
|
||||
} = pr;
|
||||
return {
|
||||
id,
|
||||
url,
|
||||
number,
|
||||
title,
|
||||
state,
|
||||
isDraft,
|
||||
author,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
isReadByViewer,
|
||||
assignees,
|
||||
labels,
|
||||
reviewRequests,
|
||||
reviewThreads,
|
||||
reviews,
|
||||
reactions,
|
||||
mergeable,
|
||||
viewerCanUpdate,
|
||||
viewerCanReact,
|
||||
viewerDidAuthor,
|
||||
};
|
||||
}
|
||||
|
||||
/** Forgets the rows (disconnect, another repository). */
|
||||
export function clearList() {
|
||||
seq++;
|
||||
useGhPrList.setState({ items: [], cursor: null, hasMore: true, loading: false, error: null, loaded: false });
|
||||
}
|
||||
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
const a = s.connection?.coordinates;
|
||||
const b = prev.connection?.coordinates;
|
||||
if (a?.owner !== b?.owner || a?.name !== b?.name) clearList();
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Local git work for pull requests: checkout (`gh pr checkout`), fetching what a diff
|
||||
* needs (`ghPrPrepareDiff`, cached per head / base commit) and opening a changed file in
|
||||
* the diff viewer (`vcs.openDiff`; `diff/` shows its review threads). Long operations run
|
||||
* one at a time and are shown in the tool window's banner (`useGhBusy`), cancellable.
|
||||
*/
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { create } from "zustand";
|
||||
import { withChain, type DiffData } from "@/features/vcs/diffInput";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import type { PrDiffRevisions, RequestId } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { errorMessage } from "../api/client";
|
||||
import type { GHCommitFile, GHPullRequest } from "../api/types";
|
||||
import { loadPullRequestData } from "./pullRequest";
|
||||
import { nameWithOwner, requireConnection } from "./repository";
|
||||
|
||||
/** The running operation (checkout, fetch for a diff) with its last output line. */
|
||||
export const useGhBusy = create<{ busy: { title: string; line: string; request: RequestId } | null }>(() => ({
|
||||
busy: null,
|
||||
}));
|
||||
|
||||
/** Runs a cancellable, streaming gh / git operation; null when it failed, was cancelled or another one runs. */
|
||||
export async function withBusy<T>(
|
||||
title: string,
|
||||
run: (request: RequestId, out: Channel<string>) => Promise<T>,
|
||||
): Promise<T | null> {
|
||||
if (useGhBusy.getState().busy) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
title: "Another GitHub operation is still running",
|
||||
key: "github-busy",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const request = nextRequestId();
|
||||
const out = new Channel<string>();
|
||||
out.onmessage = (line) => useGhBusy.setState((s) => (s.busy ? { busy: { ...s.busy, line } } : s));
|
||||
useGhBusy.setState({ busy: { title, line: "", request } });
|
||||
try {
|
||||
return await run(request, out);
|
||||
} catch (e) {
|
||||
if (!isCancelled(e))
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
title: `${title.replace(/…$/, "")} failed`,
|
||||
body: errorMessage(e),
|
||||
key: "github-op",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
useGhBusy.setState({ busy: null });
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelBusy() {
|
||||
const b = useGhBusy.getState().busy;
|
||||
if (b) void commands.cancelRequest(b.request);
|
||||
}
|
||||
|
||||
/** "Checkout Branch": fetches and checks out the pull request's branch (gh's local name). */
|
||||
export async function checkoutPullRequest(number: number): Promise<boolean> {
|
||||
// The command resolves with nothing (`null`), which `withBusy` reports as a failure.
|
||||
const done = await withBusy(`Checking out #${number}…`, async (r, out) => {
|
||||
await call(commands.ghPrCheckout(r, number, out));
|
||||
return true;
|
||||
});
|
||||
if (done === null) return false;
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
title: `Checked out pull request #${number}`,
|
||||
key: "github-checkout",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const diffCache = new Map<string, PrDiffRevisions>();
|
||||
|
||||
/** Fetches what the local repository lacks to diff `pr` (once per head / base commit). */
|
||||
export async function prepareDiff(pr: GHPullRequest): Promise<PrDiffRevisions | null> {
|
||||
const key = `${pr.number}:${pr.headRefOid}:${pr.baseRefOid}`;
|
||||
const hit = diffCache.get(key);
|
||||
if (hit) return hit;
|
||||
const repo = nameWithOwner(requireConnection().coordinates);
|
||||
const revs = await withBusy(`Fetching #${pr.number}…`, (r, out) =>
|
||||
call(commands.ghPrPrepareDiff(r, pr.number, pr.headRefOid, pr.baseRefOid, pr.baseRefName, repo, out)),
|
||||
);
|
||||
if (revs) diffCache.set(key, revs);
|
||||
return revs;
|
||||
}
|
||||
|
||||
export function clearDiffCache() {
|
||||
diffCache.clear();
|
||||
}
|
||||
|
||||
/** The diff context of a pull request's file (`diff/` shows its review there); `commit` for one commit's diff. */
|
||||
export const PULL_REQUEST_DIFF = "github.pullRequest";
|
||||
|
||||
/**
|
||||
* `DiffData` for a changed file of `pr`: merge base ↔ head, its context (`PULL_REQUEST_DIFF`)
|
||||
* naming the pull request and the file.
|
||||
*/
|
||||
export function fileDiffData(
|
||||
pr: Pick<GHPullRequest, "number" | "baseRefName" | "headRefName">,
|
||||
file: Pick<GHCommitFile, "filename" | "status" | "previousFilename">,
|
||||
revs: PrDiffRevisions,
|
||||
): DiffData {
|
||||
const root = revs.root;
|
||||
const changed = revs.files.find((f) => f.path === file.filename);
|
||||
const added = file.status === "added" || changed?.kind === "added";
|
||||
const deleted = file.status === "removed" || changed?.kind === "deleted";
|
||||
const oldPath = file.previousFilename ?? changed?.oldPath ?? null;
|
||||
const n = pr.number;
|
||||
const name = file.filename.slice(file.filename.lastIndexOf("/") + 1);
|
||||
return {
|
||||
path: `${root}/${file.filename}`,
|
||||
left: added
|
||||
? { type: "text", text: "", label: tx("(does not exist)") }
|
||||
: {
|
||||
type: "rev",
|
||||
rev: revs.base,
|
||||
path: oldPath ? `${root}/${oldPath}` : undefined,
|
||||
label: `${pr.baseRefName} (${revs.base.slice(0, 8)})`,
|
||||
},
|
||||
right: deleted
|
||||
? { type: "text", text: "", label: tx("(deleted)") }
|
||||
: { type: "rev", rev: revs.head, label: `#${n} ${pr.headRefName}` },
|
||||
title: `#${n}: ${name}`,
|
||||
context: { kind: PULL_REQUEST_DIFF, number: n, path: file.filename, commit: null },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens `path` of pull request `number` in the diff viewer (fetching what it needs first),
|
||||
* revealing the 0-based `line` of `side` (default right) when given (a review thread's line).
|
||||
* The pull request's other files follow in the chain (F7 past the last change).
|
||||
*/
|
||||
export async function openPullRequestFileDiff(
|
||||
number: number,
|
||||
path: string,
|
||||
opts: { line?: number; side?: "left" | "right" } = {},
|
||||
): Promise<void> {
|
||||
const [pr, files] = await Promise.all([
|
||||
loadPullRequestData(number, "details"),
|
||||
loadPullRequestData(number, "changes"),
|
||||
]);
|
||||
const revs = await prepareDiff(pr);
|
||||
if (!revs) return;
|
||||
const file = files.find((f) => f.filename === path) ?? {
|
||||
filename: path,
|
||||
status: "modified" as const,
|
||||
previousFilename: null,
|
||||
};
|
||||
const all = files.some((f) => f.filename === path) ? files : [...files, file];
|
||||
const data = withChain(
|
||||
all.map((f) => fileDiffData(pr, f, revs)),
|
||||
all.findIndex((f) => f.filename === path),
|
||||
);
|
||||
await executeAction("vcs.openDiff", opts.line == null ? data : { ...data, line: opts.line, lineSide: opts.side });
|
||||
}
|
||||
|
||||
/** "View Pull Request in Browser" and other links. */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
return openExternalUrl(url);
|
||||
}
|
||||
|
||||
/** "Copy Pull Request URL". */
|
||||
export async function copyUrl(url: string): Promise<void> {
|
||||
await navigator.clipboard?.writeText(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Open in Worktree": fetches the pull request's head, adds the worktree
|
||||
* `<repository root>_PR_<n>` on it (detached) and opens it in a new window.
|
||||
*/
|
||||
export async function openInWorktree(number: number): Promise<void> {
|
||||
const pr = await loadPullRequestData(number, "details");
|
||||
const revs = await prepareDiff(pr);
|
||||
if (!revs) return;
|
||||
const path = `${String(revs.root).replace(/[\\/]+$/, "")}_PR_${number}`;
|
||||
const added = await withBusy(`Creating worktree for #${number}…`, async () => {
|
||||
await call(commands.vcsAddWorktree(null, path, pr.headRefOid, null));
|
||||
return true;
|
||||
});
|
||||
if (added) await executeAction("file.openInNewWindow", path);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Pure rules of the pull request data, ported from IntelliJ: the list search value and
|
||||
* its query (`GHPRListSearchValue`, `GHPRSearchQuery`), reviewer states
|
||||
* (`GHReviewersUtils`), the unread rule (`GHPRPersistentInteractionState`), the merge box
|
||||
* state (`GHPRMergeabilityStateBuilder`), permissions (`GHPRSecurityService`) and the
|
||||
* merge commit messages (`GHPRReviewFlowViewModelImpl`).
|
||||
*/
|
||||
import type {
|
||||
CodeReviewCIJob,
|
||||
CodeReviewCIJobState,
|
||||
GHActor,
|
||||
GHCommitCheckSuiteConclusion,
|
||||
GHCommitStatusContextState,
|
||||
GHPRMergeabilityState,
|
||||
GHPRReviewerState,
|
||||
GHPullRequest,
|
||||
GHPullRequestMergeabilityData,
|
||||
GHPullRequestMergeStateStatus,
|
||||
GHPullRequestRequestedReviewer,
|
||||
GHPullRequestReview,
|
||||
GHPullRequestShort,
|
||||
GHPullRequestState,
|
||||
GHRepository,
|
||||
GHRepositoryCoordinates,
|
||||
GHRepositoryPermissionLevel,
|
||||
GHTeam,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List search (`GHPRListSearchValue`)
|
||||
|
||||
export type PrSearchState = "OPEN" | "CLOSED" | "MERGED";
|
||||
export type PrSearchReviewState =
|
||||
| "NO_REVIEW"
|
||||
| "REQUIRED"
|
||||
| "APPROVED"
|
||||
| "CHANGES_REQUESTED"
|
||||
| "REVIEWED_BY_ME"
|
||||
| "NOT_REVIEWED_BY_ME"
|
||||
| "AWAITING_REVIEW";
|
||||
export type PrSearchSort =
|
||||
| "NEWEST"
|
||||
| "OLDEST"
|
||||
| "MOST_COMMENTED"
|
||||
| "LEAST_COMMENTED"
|
||||
| "RECENTLY_UPDATED"
|
||||
| "LEAST_RECENTLY_UPDATED";
|
||||
|
||||
/** The list filter: free text plus the chips (author / assignee are logins). Unset = absent. */
|
||||
export interface PrSearchValue {
|
||||
searchQuery?: string;
|
||||
state?: PrSearchState;
|
||||
assignee?: string;
|
||||
reviewState?: PrSearchReviewState;
|
||||
author?: string;
|
||||
label?: string;
|
||||
sort?: PrSearchSort;
|
||||
}
|
||||
|
||||
/** `GHPRListSearchValue.DEFAULT`: the Quick Filters' "Open" (`EMPTY` is `{}`). */
|
||||
export const DEFAULT_SEARCH: PrSearchValue = { state: "OPEN" };
|
||||
|
||||
const SEARCH_KEYS = ["searchQuery", "state", "assignee", "reviewState", "author", "label", "sort"] as const;
|
||||
|
||||
const STATE_TERMS: Record<PrSearchState, string> = { OPEN: "is:open", CLOSED: "is:closed", MERGED: "is:merged" };
|
||||
const REVIEW_TERMS: Record<PrSearchReviewState, string> = {
|
||||
NO_REVIEW: "review:none",
|
||||
REQUIRED: "review:required",
|
||||
APPROVED: "review:approved",
|
||||
CHANGES_REQUESTED: "review:changes-requested",
|
||||
REVIEWED_BY_ME: "reviewed-by:@me",
|
||||
NOT_REVIEWED_BY_ME: "-reviewed-by:@me",
|
||||
AWAITING_REVIEW: "review-requested:@me",
|
||||
};
|
||||
const SORT_TERMS: Record<PrSearchSort, string> = {
|
||||
NEWEST: "sort:created-desc",
|
||||
OLDEST: "sort:created-asc",
|
||||
MOST_COMMENTED: "sort:comments-desc",
|
||||
LEAST_COMMENTED: "sort:comments-asc",
|
||||
RECENTLY_UPDATED: "sort:updated-desc",
|
||||
LEAST_RECENTLY_UPDATED: "sort:updated-asc",
|
||||
};
|
||||
|
||||
/** How many filters are set (the text counts as one), as the Quick Filters badge and "Clear {n} Filters". */
|
||||
export function filterCount(v: PrSearchValue): number {
|
||||
return SEARCH_KEYS.filter((k) => v[k] !== undefined).length;
|
||||
}
|
||||
|
||||
export function sameSearch(a: PrSearchValue, b: PrSearchValue): boolean {
|
||||
return SEARCH_KEYS.every((k) => a[k] === b[k]);
|
||||
}
|
||||
|
||||
/** A search value with only known, non-empty fields (from storage or a patch). */
|
||||
export function normalizeSearch(v: unknown): PrSearchValue | null {
|
||||
if (!v || typeof v !== "object") return null;
|
||||
const r = v as Record<string, unknown>;
|
||||
const str = (x: unknown) => (typeof x === "string" && x !== "" ? x : undefined);
|
||||
const oneOf = <T extends string>(x: unknown, all: Record<T, unknown>) =>
|
||||
typeof x === "string" && x in all ? (x as T) : undefined;
|
||||
const out: PrSearchValue = {
|
||||
searchQuery: str(r.searchQuery),
|
||||
state: oneOf(r.state, STATE_TERMS),
|
||||
assignee: str(r.assignee),
|
||||
reviewState: oneOf(r.reviewState, REVIEW_TERMS),
|
||||
author: str(r.author),
|
||||
label: str(r.label),
|
||||
sort: oneOf(r.sort, SORT_TERMS),
|
||||
};
|
||||
for (const k of SEARCH_KEYS) if (out[k] === undefined) delete out[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter's search terms (`GHPRListSearchValue.toQuery`), or null when nothing is set.
|
||||
* The typed text is grouped in parentheses: GitHub's advanced search gives `OR` the lowest
|
||||
* precedence, so `a OR b` would otherwise escape the repository and the other filters.
|
||||
*/
|
||||
export function searchTerms(v: PrSearchValue): string | null {
|
||||
const terms: string[] = [];
|
||||
const text = v.searchQuery?.trim();
|
||||
if (text) terms.push(`(${text})`);
|
||||
if (v.state) terms.push(STATE_TERMS[v.state]);
|
||||
if (v.assignee !== undefined) terms.push(`assignee:${v.assignee}`);
|
||||
if (v.reviewState) terms.push(REVIEW_TERMS[v.reviewState]);
|
||||
if (v.author !== undefined) terms.push(`author:${v.author}`);
|
||||
if (v.label !== undefined) terms.push(`label:"${v.label}"`);
|
||||
if (v.sort) terms.push(SORT_TERMS[v.sort]);
|
||||
return terms.length ? terms.join(" ") : null;
|
||||
}
|
||||
|
||||
/** The `issueSearch` query of the list: `type:pr repo:owner/name` and the filter's terms. */
|
||||
export function listSearchQuery(repo: GHRepositoryCoordinates, v: PrSearchValue): string {
|
||||
const terms = searchTerms(v);
|
||||
return [`type:pr repo:${repo.owner}/${repo.name}`, terms].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reviewers (`GHReviewersUtils`)
|
||||
|
||||
export interface ReviewerWithState {
|
||||
reviewer: GHPullRequestRequestedReviewer;
|
||||
state: GHPRReviewerState;
|
||||
}
|
||||
|
||||
export const isTeam = (r: GHPullRequestRequestedReviewer | GHActor | null | undefined): r is GHTeam =>
|
||||
r?.__typename === "Team";
|
||||
|
||||
/** Login of a user / actor, `org/slug` of a team. */
|
||||
export function reviewerKey(r: GHPullRequestRequestedReviewer): string {
|
||||
return isTeam(r) ? r.combinedSlug : r.login;
|
||||
}
|
||||
|
||||
/** A reviewer's effective state from their reviews, newest first; null for no submitted review. */
|
||||
export function reviewerStateOf(reviews: readonly GHPullRequestReview[]): GHPRReviewerState | null {
|
||||
let commented = false;
|
||||
const newestFirst = [...reviews].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
for (const review of newestFirst) {
|
||||
switch (review.state) {
|
||||
case "APPROVED":
|
||||
return "APPROVED";
|
||||
case "CHANGES_REQUESTED":
|
||||
return "CHANGES_REQUESTED";
|
||||
case "DISMISSED":
|
||||
return "COMMENTED";
|
||||
case "COMMENTED":
|
||||
commented = true;
|
||||
break;
|
||||
case "PENDING":
|
||||
break;
|
||||
}
|
||||
}
|
||||
return commented ? "COMMENTED" : null;
|
||||
}
|
||||
|
||||
/** Placeholder author of reviews whose user was deleted (IntelliJ's ghost user). */
|
||||
export const GHOST_USER: GHUser = {
|
||||
__typename: "User",
|
||||
id: "",
|
||||
login: "ghost",
|
||||
url: "https://github.com/ghost",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/10137?v=4",
|
||||
name: "Deleted user",
|
||||
};
|
||||
|
||||
/**
|
||||
* Every reviewer of `pr` with their state, as GitHub shows them: reviewers by their
|
||||
* reviews (the author's own excluded), then the pending requests, which make a reviewer
|
||||
* who already reviewed RE_REQUESTED and one who did not NEEDS_REVIEW.
|
||||
*/
|
||||
export function reviewersStates(
|
||||
pr: Pick<GHPullRequestShort, "author" | "reviews" | "reviewRequests">,
|
||||
): ReviewerWithState[] {
|
||||
const result = new Map<string, ReviewerWithState>();
|
||||
const byReviewer = new Map<string, { reviewer: GHPullRequestRequestedReviewer; reviews: GHPullRequestReview[] }>();
|
||||
for (const review of pr.reviews.nodes) {
|
||||
const reviewer = review.author ?? GHOST_USER;
|
||||
const key = reviewerKey(reviewer);
|
||||
const group = byReviewer.get(key) ?? { reviewer, reviews: [] };
|
||||
group.reviews.push(review);
|
||||
byReviewer.set(key, group);
|
||||
}
|
||||
const authorKey = pr.author ? pr.author.login : null;
|
||||
for (const [key, { reviewer, reviews }] of byReviewer) {
|
||||
if (key === authorKey) continue;
|
||||
const state = reviewerStateOf(reviews);
|
||||
if (state) result.set(key, { reviewer, state });
|
||||
}
|
||||
for (const { requestedReviewer } of pr.reviewRequests.nodes) {
|
||||
if (!requestedReviewer) continue;
|
||||
const key = reviewerKey(requestedReviewer);
|
||||
result.set(key, { reviewer: requestedReviewer, state: result.has(key) ? "RE_REQUESTED" : "NEEDS_REVIEW" });
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
const REVIEWER_ORDER: Record<GHPRReviewerState, number> = {
|
||||
APPROVED: 0,
|
||||
CHANGES_REQUESTED: 1,
|
||||
COMMENTED: 2,
|
||||
RE_REQUESTED: 3,
|
||||
NEEDS_REVIEW: 4,
|
||||
};
|
||||
|
||||
/** `reviewersStates` ordered like the list row's avatars: approved → changes requested → commented → re-requested → needs review. */
|
||||
export function sortedReviewersStates(pr: Pick<GHPullRequestShort, "author" | "reviews" | "reviewRequests">) {
|
||||
return reviewersStates(pr).sort((a, b) => REVIEWER_ORDER[a.state] - REVIEWER_ORDER[b.state]);
|
||||
}
|
||||
|
||||
/** Review threads that are neither resolved nor outdated (the list row's comment counter). */
|
||||
export function unresolvedThreadsCount(pr: Pick<GHPullRequestShort, "reviewThreads">): number {
|
||||
return pr.reviewThreads.nodes.filter((t) => !t.isResolved && !t.isOutdated).length;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unread (`GHPRPersistentInteractionState`)
|
||||
|
||||
/** Registry `github.last.seen.state.margin.millis`. */
|
||||
export const LAST_SEEN_MARGIN_MS = 5000;
|
||||
/** Registry `github.clear.last.seen.state.days`. */
|
||||
export const CLEAR_LAST_SEEN_AFTER_DAYS = 14;
|
||||
|
||||
/**
|
||||
* Whether `pr` counts as seen by `user`: always, unless the user wrote it or their review
|
||||
* is requested; then when it was last seen (ms, stored locally) no earlier than its update.
|
||||
*/
|
||||
export function isSeen(
|
||||
pr: Pick<GHPullRequestShort, "author" | "reviewRequests" | "updatedAt">,
|
||||
user: Pick<GHUser, "id">,
|
||||
lastSeen: number | null | undefined,
|
||||
): boolean {
|
||||
const involved =
|
||||
pr.author?.id === user.id || pr.reviewRequests.nodes.some((r) => r.requestedReviewer?.id === user.id);
|
||||
if (!involved) return true;
|
||||
return lastSeen != null && lastSeen + LAST_SEEN_MARGIN_MS >= Date.parse(pr.updatedAt);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pull request state
|
||||
|
||||
/** `GHUIUtil.getPullRequestStateIcon`. */
|
||||
export function pullRequestStateIcon(state: GHPullRequestState, isDraft: boolean): string {
|
||||
if (state === "MERGED") return "github-pr-merged";
|
||||
if (state === "CLOSED") return "github-pr-closed";
|
||||
return isDraft ? "github-pr-draft" : "github-pr-open";
|
||||
}
|
||||
|
||||
/** The state tag's text, or null for an open pull request (draft wins over open). */
|
||||
export function stateTagText(state: GHPullRequestState, isDraft: boolean): "DRAFT" | "CLOSED" | "MERGED" | null {
|
||||
if (state === "MERGED") return "MERGED";
|
||||
if (state === "CLOSED") return "CLOSED";
|
||||
return isDraft ? "DRAFT" : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mergeability (`GHPRMergeabilityStateBuilder`)
|
||||
|
||||
const CAN_MERGE: ReadonlySet<GHPullRequestMergeStateStatus> = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
|
||||
const ADMIN_CAN_MERGE: ReadonlySet<GHPullRequestMergeStateStatus> = new Set(["BLOCKED", "BEHIND"]);
|
||||
|
||||
function contextJobState(s: GHCommitStatusContextState): CodeReviewCIJobState {
|
||||
if (s === "PENDING") return "PENDING";
|
||||
if (s === "SUCCESS") return "SUCCESS";
|
||||
return "FAILED";
|
||||
}
|
||||
|
||||
function checkRunJobState(c: GHCommitCheckSuiteConclusion | null): CodeReviewCIJobState {
|
||||
if (c === null) return "PENDING";
|
||||
if (c === "SKIPPED") return "SKIPPED";
|
||||
if (c === "SUCCESS") return "SUCCESS";
|
||||
return "FAILED";
|
||||
}
|
||||
|
||||
export function buildMergeabilityState(
|
||||
data: GHPullRequestMergeabilityData,
|
||||
currentUserIsAdmin: boolean,
|
||||
): GHPRMergeabilityState {
|
||||
const rule = data.baseRef?.refUpdateRule ?? null;
|
||||
const canOverrideAsAdmin = rule ? currentUserIsAdmin : false;
|
||||
const requiredContexts = rule ? rule.requiredStatusCheckContexts.filter((c): c is string => c !== null) : [];
|
||||
const isRestricted = rule ? !rule.viewerCanPush : false;
|
||||
const requiredReviews = rule?.requiredApprovingReviewCount ?? 0;
|
||||
|
||||
const hasConflicts = data.mergeable === "MERGEABLE" ? false : data.mergeable === "CONFLICTING" ? true : null;
|
||||
const lastCommit = data.commits.nodes.at(-1)?.commit;
|
||||
const contexts = lastCommit?.status?.contexts ?? [];
|
||||
const ciJobs: CodeReviewCIJob[] = [
|
||||
...contexts.map((c) => ({
|
||||
name: c.context,
|
||||
status: contextJobState(c.state),
|
||||
isRequired: c.isRequired,
|
||||
detailsUrl: c.targetUrl,
|
||||
})),
|
||||
...(lastCommit?.checkSuites?.nodes ?? [])
|
||||
.flatMap((s) => s.checkRuns?.nodes ?? [])
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
status: checkRunJobState(r.conclusion),
|
||||
isRequired: r.isRequired,
|
||||
detailsUrl: r.detailsUrl ?? r.url,
|
||||
})),
|
||||
];
|
||||
const status = data.mergeStateStatus;
|
||||
const canBeMerged = CAN_MERGE.has(status) || (ADMIN_CAN_MERGE.has(status) && canOverrideAsAdmin);
|
||||
let checksState: GHPRMergeabilityState["checksState"] = null;
|
||||
if (status === "BEHIND") checksState = "BLOCKING_BEHIND";
|
||||
else if (status === "BLOCKED") {
|
||||
const passed = new Set(contexts.filter((c) => c.state === "SUCCESS").map((c) => c.context));
|
||||
if (requiredContexts.length && !requiredContexts.every((c) => passed.has(c))) checksState = "BLOCKING_FAILING";
|
||||
}
|
||||
const requiredApprovingReviewsCount =
|
||||
status === "BLOCKED" && !isRestricted && checksState !== "BLOCKING_FAILING" ? requiredReviews : 0;
|
||||
return {
|
||||
hasConflicts,
|
||||
ciJobs,
|
||||
canBeMerged,
|
||||
canBeRebased: data.canBeRebased,
|
||||
isRestricted,
|
||||
requiredApprovingReviewsCount,
|
||||
checksState,
|
||||
mergeable: data.mergeable,
|
||||
mergeStateStatus: status,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permissions (`GHPRSecurityService`)
|
||||
|
||||
const PERMISSION_ORDER: GHRepositoryPermissionLevel[] = ["READ", "TRIAGE", "WRITE", "MAINTAIN", "ADMIN"];
|
||||
|
||||
/** Whether `permission` is at least `level` (an unknown permission has none). */
|
||||
export function hasPermission(
|
||||
permission: GHRepositoryPermissionLevel | null | undefined,
|
||||
level: GHRepositoryPermissionLevel,
|
||||
): boolean {
|
||||
return (permission ? PERMISSION_ORDER.indexOf(permission) : -1) >= PERMISSION_ORDER.indexOf(level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which pull request actions the user may run (IntelliJ's review flow enablement): managing
|
||||
* (request reviewers, close, reopen, post a draft) needs triage access or authorship; merging
|
||||
* needs write access, a method the repository allows, a mergeable state and an unrestricted
|
||||
* base branch, and `mergeForbidden` (the project setting) off.
|
||||
*/
|
||||
export function pullRequestPermissions(
|
||||
pr: Pick<GHPullRequest, "viewerDidAuthor" | "state">,
|
||||
repo: Pick<GHRepository, "viewerPermission" | "mergeCommitAllowed" | "squashMergeAllowed" | "rebaseMergeAllowed">,
|
||||
mergeability: GHPRMergeabilityState | null,
|
||||
mergeForbidden = false,
|
||||
) {
|
||||
const canManage = hasPermission(repo.viewerPermission, "TRIAGE") || pr.viewerDidAuthor;
|
||||
const canWrite = hasPermission(repo.viewerPermission, "WRITE") && !mergeForbidden;
|
||||
const open = pr.state === "OPEN";
|
||||
const mergeable = open && canWrite && !!mergeability && !mergeability.isRestricted;
|
||||
return {
|
||||
canManage,
|
||||
canMerge: mergeable && repo.mergeCommitAllowed && mergeability!.canBeMerged,
|
||||
canSquashMerge: mergeable && repo.squashMergeAllowed && mergeability!.canBeMerged,
|
||||
canRebaseMerge: mergeable && repo.rebaseMergeAllowed && mergeability!.canBeRebased,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge commit messages
|
||||
|
||||
/** The merge dialog's message: subject `Merge pull request #n`, body the title, or `* headline` per commit when squashing. */
|
||||
export function mergeCommitMessage(
|
||||
pr: Pick<GHPullRequest, "number" | "title">,
|
||||
squashCommitHeadlines?: readonly string[],
|
||||
): string {
|
||||
const subject = `Merge pull request #${pr.number}`;
|
||||
const body = squashCommitHeadlines ? `* ${squashCommitHeadlines.join("\n\n* ")}` : pr.title;
|
||||
return `${subject}\n\n${body}`;
|
||||
}
|
||||
|
||||
/** Splits a commit message into its subject (first line) and body (`splitCommitMessage`). */
|
||||
export function splitCommitMessage(message: string): { title: string; body: string } {
|
||||
const i = message.indexOf("\n");
|
||||
if (i < 0) return { title: message.trim(), body: "" };
|
||||
return { title: message.slice(0, i).trim(), body: message.slice(i + 1).trim() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repositories
|
||||
|
||||
/** `GHUIUtil.getRepositoryDisplayName`: `name`, or `owner/name` when the owners differ (or always). */
|
||||
export function repositoryDisplayName(
|
||||
all: readonly GHRepositoryCoordinates[],
|
||||
repo: GHRepositoryCoordinates,
|
||||
alwaysShowOwner = false,
|
||||
): string {
|
||||
const showOwner = alwaysShowOwner || (all.length > 1 && all.some((r) => r.owner !== all[0]!.owner));
|
||||
return showOwner ? `${repo.owner}/${repo.name}` : repo.name;
|
||||
}
|
||||
|
||||
/** `owner/name` of a github.com remote URL (https, ssh or scp-like), else null. */
|
||||
export function parseGitHubRemote(url: string): GHRepositoryCoordinates | null {
|
||||
const m =
|
||||
/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|git:\/\/|(?:[^@/]+@)?)(?:www\.)?github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
|
||||
url.trim(),
|
||||
);
|
||||
return m ? { owner: m[1]!, name: m[2]! } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote of `repo` among `remotes`, by the URL's path: gh also resolves a remote behind
|
||||
* an SSH host alias (`git@work-github:o/r.git`), which `parseGitHubRemote` can't tell is GitHub.
|
||||
*/
|
||||
export function remoteOfRepository(
|
||||
remotes: ReadonlyArray<{ name: string; url: string }>,
|
||||
repo: GHRepositoryCoordinates,
|
||||
): { name: string; url: string } | null {
|
||||
const path = `${repo.owner}/${repo.name}`.toLowerCase();
|
||||
return (
|
||||
remotes.find((r) => {
|
||||
const url = r.url
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\/$/, "")
|
||||
.replace(/\.git$/, "");
|
||||
return url.endsWith(`/${path}`) || url.endsWith(`:${path}`);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Pull request changes (IntelliJ's `GHPRDetailsDataProvider`, `GHPRReviewDataProvider`,
|
||||
* `GHPRViewedStateDataProvider`, `GHPRCommentsDataProvider` mutations). Each sends the
|
||||
* request, then refreshes what it changed — details, mergeability, timeline, threads,
|
||||
* pending review, the list row — so every view of the pull request follows. Errors
|
||||
* propagate to the caller, which shows them where IntelliJ does.
|
||||
*/
|
||||
import * as api from "../api/requests";
|
||||
import type {
|
||||
GHDiffSide,
|
||||
GHPullRequestDraftReviewThread,
|
||||
GHPullRequestPendingReview,
|
||||
GHPullRequestRequestedReviewer,
|
||||
GHPullRequestReviewEvent,
|
||||
GHPullRequestReviewThread,
|
||||
GHPullRequestShort,
|
||||
GHReactionContent,
|
||||
} from "../api/types";
|
||||
import { reloadList, updateListItem } from "./list";
|
||||
import { isTeam } from "./model";
|
||||
import {
|
||||
loadPullRequestData,
|
||||
pullRequestId,
|
||||
reloadPullRequestData,
|
||||
reloadTimeline,
|
||||
setPullRequestData,
|
||||
updateTimeline,
|
||||
} from "./pullRequest";
|
||||
import { requireConnection } from "./repository";
|
||||
|
||||
const settle = (ps: Array<Promise<unknown>>) => Promise.allSettled(ps).then(() => undefined);
|
||||
|
||||
/** After a change of the pull request itself: details, mergeability and new timeline items. */
|
||||
function refreshState(number: number) {
|
||||
return settle([
|
||||
reloadPullRequestData(number, "details"),
|
||||
reloadPullRequestData(number, "mergeability"),
|
||||
updateTimeline(number),
|
||||
]);
|
||||
}
|
||||
|
||||
/** After a review change: pending review, threads, details (reviews) and the timeline. */
|
||||
function refreshReview(number: number) {
|
||||
return settle([
|
||||
reloadPullRequestData(number, "pendingReview"),
|
||||
reloadPullRequestData(number, "threads"),
|
||||
reloadPullRequestData(number, "details"),
|
||||
reloadTimeline(number),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Details
|
||||
|
||||
/**
|
||||
* Edits the title and / or description.
|
||||
*/
|
||||
export async function updateDetails(number: number, patch: { title?: string; body?: string }) {
|
||||
const pr = await api.updatePullRequest(await pullRequestId(number), patch);
|
||||
setPullRequestData(number, "details", () => pr);
|
||||
updateListItem(pr);
|
||||
await updateTimeline(number);
|
||||
return pr;
|
||||
}
|
||||
|
||||
/** Adds / removes requested reviewers (users and teams), as the reviewer picker's delta. */
|
||||
export async function adjustReviewers(
|
||||
number: number,
|
||||
delta: { added: GHPullRequestRequestedReviewer[]; removed: GHPullRequestRequestedReviewer[] },
|
||||
) {
|
||||
const split = (rs: GHPullRequestRequestedReviewer[]) => ({
|
||||
logins: rs.filter((r) => !isTeam(r)).map((r) => (r as { login: string }).login),
|
||||
teamSlugs: rs.filter(isTeam).map((t) => t.slug),
|
||||
});
|
||||
if (delta.removed.length) await api.changeRequestedReviewers(number, "DELETE", split(delta.removed));
|
||||
if (delta.added.length) await api.changeRequestedReviewers(number, "POST", split(delta.added));
|
||||
await refreshState(number);
|
||||
}
|
||||
|
||||
/** "Close Pull Request". */
|
||||
export async function closePullRequest(number: number) {
|
||||
await api.updatePullRequestState(number, "closed");
|
||||
await refreshState(number);
|
||||
}
|
||||
|
||||
/** "Reopen Pull Request". */
|
||||
export async function reopenPullRequest(number: number) {
|
||||
await api.updatePullRequestState(number, "open");
|
||||
await refreshState(number);
|
||||
}
|
||||
|
||||
/** "Post Pull Request": marks a draft ready for review. */
|
||||
export async function markReadyForReview(number: number) {
|
||||
await api.markPullRequestReadyForReview(await pullRequestId(number));
|
||||
await refreshState(number);
|
||||
}
|
||||
|
||||
/** Merge… / Squash and Merge… (with the dialog's message) / Rebase, at head `headRefOid`. */
|
||||
export async function mergePullRequest(
|
||||
number: number,
|
||||
method: "merge" | "squash" | "rebase",
|
||||
headRefOid: string,
|
||||
message?: { title: string; body: string },
|
||||
) {
|
||||
await api.mergePullRequest(number, method, headRefOid, method === "rebase" ? undefined : message);
|
||||
await refreshState(number);
|
||||
}
|
||||
|
||||
/** Deletes the merged head branch (the timeline's merged banner). */
|
||||
export async function deleteMergedBranch(number: number, refId: string) {
|
||||
await api.deleteRef(refId);
|
||||
await settle([reloadPullRequestData(number, "details"), updateTimeline(number)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the assignees (logins).
|
||||
* @public
|
||||
*/
|
||||
export async function setAssignees(number: number, logins: string[]) {
|
||||
await api.updateAssignees(number, logins);
|
||||
await settle([reloadPullRequestData(number, "details"), updateTimeline(number)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the labels (names).
|
||||
* @public
|
||||
*/
|
||||
export async function setLabels(number: number, names: string[]) {
|
||||
await api.replaceLabels(number, names);
|
||||
await settle([reloadPullRequestData(number, "details"), updateTimeline(number)]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewed state
|
||||
|
||||
/**
|
||||
* Marks `paths` viewed / not viewed: the cache changes at once, one request per path runs
|
||||
* in parallel, and the state is reloaded when any fails.
|
||||
*/
|
||||
export async function setViewed(number: number, paths: readonly string[], viewed: boolean) {
|
||||
setPullRequestData(number, "viewedState", (v) => {
|
||||
const next = { ...v };
|
||||
for (const p of paths) next[p] = viewed ? "VIEWED" : "UNVIEWED";
|
||||
return next;
|
||||
});
|
||||
const id = await pullRequestId(number);
|
||||
const results = await Promise.allSettled(
|
||||
paths.map((p) => (viewed ? api.markFileAsViewed(id, p) : api.unmarkFileAsViewed(id, p))),
|
||||
);
|
||||
const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (failed) {
|
||||
await reloadPullRequestData(number, "viewedState").catch(() => undefined);
|
||||
throw failed.reason;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reviews
|
||||
|
||||
/**
|
||||
* `createReview`: a pending review (no `event`), or a submitted one; `threads` are the
|
||||
* new comments ("Start Review" / "Add Single Comment" with `event: "COMMENT"`).
|
||||
*/
|
||||
export async function createReview(
|
||||
number: number,
|
||||
input: {
|
||||
event?: GHPullRequestReviewEvent;
|
||||
body?: string;
|
||||
commitOid?: string;
|
||||
threads?: GHPullRequestDraftReviewThread[];
|
||||
} = {},
|
||||
): Promise<GHPullRequestPendingReview> {
|
||||
const review = await api.createReview({ pullRequestId: await pullRequestId(number), ...input });
|
||||
await refreshReview(number);
|
||||
return review;
|
||||
}
|
||||
|
||||
/** Submits the pending review `reviewId`. */
|
||||
export async function submitReview(number: number, reviewId: string, event: GHPullRequestReviewEvent, body?: string) {
|
||||
await api.submitReview(reviewId, event, body || null);
|
||||
await refreshReview(number);
|
||||
await reloadPullRequestData(number, "mergeability").catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Submit Review popup: submits the pending review, or creates a submitted one. */
|
||||
export async function submitOrCreateReview(number: number, event: GHPullRequestReviewEvent, body: string) {
|
||||
const pending = await loadPullRequestData(number, "pendingReview");
|
||||
if (pending) await submitReview(number, pending.id, event, body);
|
||||
else await createReview(number, { event, body: body || undefined });
|
||||
}
|
||||
|
||||
/** Discards a pending review with its comments. */
|
||||
export async function deleteReview(number: number, reviewId: string) {
|
||||
await api.deleteReview(reviewId);
|
||||
await refreshReview(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a review's body; resolves with the new body.
|
||||
*/
|
||||
export async function updateReviewBody(number: number, reviewId: string, body: string) {
|
||||
const review = await api.updateReview(reviewId, body);
|
||||
await reloadTimeline(number);
|
||||
return review.body;
|
||||
}
|
||||
|
||||
/** Replaces one thread in the cache (resolve / unresolve / new thread) and refreshes the list counter. */
|
||||
function putThread(number: number, thread: GHPullRequestReviewThread) {
|
||||
setPullRequestData(number, "threads", (ts) => {
|
||||
const list = ts ?? [];
|
||||
return list.some((t) => t.id === thread.id)
|
||||
? list.map((t) => (t.id === thread.id ? thread : t))
|
||||
: [...list, thread];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A new thread in the pending review `reviewId` (lines are 1-based, both ends on one side).
|
||||
* @public
|
||||
*/
|
||||
export async function createThread(
|
||||
number: number,
|
||||
input: {
|
||||
reviewId: string;
|
||||
body: string;
|
||||
path: string;
|
||||
line: number;
|
||||
side: GHDiffSide;
|
||||
startLine?: number;
|
||||
startSide?: GHDiffSide;
|
||||
},
|
||||
) {
|
||||
const thread = await api.addReviewThread({
|
||||
pullRequestReviewId: input.reviewId,
|
||||
body: input.body,
|
||||
path: input.path,
|
||||
line: input.line,
|
||||
side: input.side,
|
||||
startLine: input.startLine ?? null,
|
||||
startSide: input.startSide ?? null,
|
||||
});
|
||||
putThread(number, thread);
|
||||
await settle([reloadPullRequestData(number, "pendingReview"), reloadTimeline(number)]);
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* A comment on a single commit's diff (`position` in its patch) in the pending review `reviewId`.
|
||||
* @public
|
||||
*/
|
||||
export async function addCommitComment(
|
||||
number: number,
|
||||
input: { reviewId: string; body: string; commitSha: string; path: string; position: number },
|
||||
) {
|
||||
const comment = await api.addReviewComment({
|
||||
reviewId: input.reviewId,
|
||||
body: input.body,
|
||||
commit: input.commitSha,
|
||||
file: input.path,
|
||||
position: input.position,
|
||||
});
|
||||
await refreshReview(number);
|
||||
return comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replies to the review comment `replyToCommentId` (in the pending review, else a new one).
|
||||
*/
|
||||
export async function replyToComment(number: number, replyToCommentId: string, body: string) {
|
||||
const pending = await loadPullRequestData(number, "pendingReview");
|
||||
const reviewId = pending?.id ?? (await api.createReview({ pullRequestId: await pullRequestId(number) })).id;
|
||||
const comment = await api.addReviewComment({ reviewId, body, inReplyTo: replyToCommentId });
|
||||
// A reply outside a review is posted at once, like GitHub's "Reply".
|
||||
if (!pending) await api.submitReview(reviewId, "COMMENT", null);
|
||||
await refreshReview(number);
|
||||
return comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a review comment.
|
||||
*/
|
||||
export async function updateReviewComment(number: number, commentId: string, body: string) {
|
||||
const comment = await api.updateReviewComment(commentId, body);
|
||||
await settle([reloadPullRequestData(number, "threads"), reloadTimeline(number)]);
|
||||
return comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a review comment (its thread goes with its last comment).
|
||||
*/
|
||||
export async function deleteReviewComment(number: number, commentId: string) {
|
||||
await api.deleteReviewComment(commentId);
|
||||
await refreshReview(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve: marks a review thread resolved.
|
||||
*/
|
||||
export async function resolveThread(number: number, threadId: string) {
|
||||
const thread = await api.resolveReviewThread(threadId);
|
||||
putThread(number, thread);
|
||||
await settle([reloadPullRequestData(number, "details"), reloadTimeline(number)]);
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unresolve: reopens a review thread.
|
||||
*/
|
||||
export async function unresolveThread(number: number, threadId: string) {
|
||||
const thread = await api.unresolveReviewThread(threadId);
|
||||
putThread(number, thread);
|
||||
await settle([reloadPullRequestData(number, "details"), reloadTimeline(number)]);
|
||||
return thread;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation comments and reactions
|
||||
|
||||
/**
|
||||
* Adds a conversation comment (the timeline's composer).
|
||||
*/
|
||||
export async function addIssueComment(number: number, body: string) {
|
||||
await api.createIssueComment(number, body);
|
||||
await updateTimeline(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a conversation comment.
|
||||
*/
|
||||
export async function updateIssueComment(number: number, commentId: string, body: string) {
|
||||
const comment = await api.updateIssueComment(commentId, body);
|
||||
await reloadTimeline(number);
|
||||
return comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a conversation comment.
|
||||
*/
|
||||
export async function deleteIssueComment(number: number, commentId: string) {
|
||||
await api.deleteIssueComment(commentId);
|
||||
await reloadTimeline(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or removes the viewer's reaction on `subjectId` (the description, a comment, a
|
||||
* review comment); `on` says which data holds the subject, to refresh it.
|
||||
*/
|
||||
export async function toggleReaction(
|
||||
number: number,
|
||||
subjectId: string,
|
||||
reaction: GHReactionContent,
|
||||
add: boolean,
|
||||
on: "details" | "timeline" | "threads",
|
||||
) {
|
||||
if (add) await api.addReaction(subjectId, reaction);
|
||||
else await api.removeReaction(subjectId, reaction);
|
||||
if (on === "timeline") await reloadTimeline(number);
|
||||
else await reloadPullRequestData(number, on);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New pull requests
|
||||
|
||||
/**
|
||||
* Creates a pull request in the connected repository (`GHPRCreationService`): `head` is
|
||||
* the branch (`owner:branch` for a fork's). A blank title falls back to the branch name,
|
||||
* a blank body is sent as null. Reviewers, assignees and labels are set afterwards
|
||||
* (`onSettingMetadata` is called first: "Updating pull request fields").
|
||||
*/
|
||||
export async function createPullRequest(input: {
|
||||
base: string;
|
||||
head: string;
|
||||
title: string;
|
||||
body: string;
|
||||
draft: boolean;
|
||||
reviewers?: GHPullRequestRequestedReviewer[];
|
||||
assignees?: string[];
|
||||
labels?: string[];
|
||||
onSettingMetadata?: () => void;
|
||||
}): Promise<GHPullRequestShort> {
|
||||
const { repository } = requireConnection();
|
||||
const branch = input.head.slice(input.head.indexOf(":") + 1);
|
||||
const pr = await api.createPullRequest({
|
||||
repositoryId: repository.id,
|
||||
baseRefName: input.base,
|
||||
headRefName: input.head,
|
||||
title: input.title.trim() || branch,
|
||||
body: input.body.trim() ? input.body : null,
|
||||
draft: input.draft,
|
||||
});
|
||||
const n = pr.number;
|
||||
if (input.reviewers?.length || input.assignees?.length || input.labels?.length) input.onSettingMetadata?.();
|
||||
await settle([
|
||||
...(input.reviewers?.length ? [adjustReviewers(n, { added: input.reviewers, removed: [] })] : []),
|
||||
...(input.assignees?.length ? [api.updateAssignees(n, input.assignees)] : []),
|
||||
...(input.labels?.length ? [api.replaceLabels(n, input.labels)] : []),
|
||||
]);
|
||||
void reloadList();
|
||||
return pr;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Per pull request data cache, like IntelliJ's `GHPRDataProvider` (details, changes,
|
||||
* viewed state, review, timeline providers): each slice loads on first use, keeps its
|
||||
* value while reloading, and is shared by every view of the pull request (its tab, the
|
||||
* timeline editor, the review diff).
|
||||
*
|
||||
* const details = usePullRequestData(number, "details"); // { value, loading, error }
|
||||
* await reloadPullRequestData(number, "mergeability");
|
||||
*
|
||||
* Slices: details (`findPullRequest`), mergeability (`findPullRequestMergeability`, as
|
||||
* `GHPRMergeabilityState`), commits, changes (REST files with patches), viewedState (per
|
||||
* path), threads (review threads), pendingReview (the viewer's unsubmitted review) and
|
||||
* participants. The timeline is cursor-paged separately (`usePullRequestTimeline`).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { errorMessage } from "../api/client";
|
||||
import * as api from "../api/requests";
|
||||
import type {
|
||||
GHCommitFile,
|
||||
GHPRMergeabilityState,
|
||||
GHPRTimelineItem,
|
||||
GHPullRequest,
|
||||
GHPullRequestCommit,
|
||||
GHPullRequestFileViewedState,
|
||||
GHPullRequestPendingReview,
|
||||
GHPullRequestReviewThread,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import { markSeen } from "./interaction";
|
||||
import { updateListItem } from "./list";
|
||||
import { buildMergeabilityState } from "./model";
|
||||
import { currentUserHasPermission, useGhRepository } from "./repository";
|
||||
|
||||
export interface Loadable<T> {
|
||||
/** Last loaded value; kept while reloading. */
|
||||
value: T | undefined;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface Slices {
|
||||
details: GHPullRequest;
|
||||
mergeability: GHPRMergeabilityState;
|
||||
commits: GHPullRequestCommit[];
|
||||
changes: GHCommitFile[];
|
||||
viewedState: Record<string, GHPullRequestFileViewedState>;
|
||||
threads: GHPullRequestReviewThread[];
|
||||
pendingReview: GHPullRequestPendingReview | null;
|
||||
participants: GHUser[];
|
||||
}
|
||||
|
||||
export type PullRequestSlice = keyof Slices;
|
||||
|
||||
export interface TimelineState {
|
||||
items: GHPRTimelineItem[];
|
||||
/** Cursor after the last loaded item. */
|
||||
cursor: string | null;
|
||||
/** More pages exist after `cursor` (as far as is known). */
|
||||
hasMore: boolean;
|
||||
/** Nothing requested yet. */
|
||||
initial: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type PrData = { [K in PullRequestSlice]: Loadable<Slices[K]> } & { timeline: TimelineState };
|
||||
|
||||
const EMPTY: Loadable<never> = { value: undefined, loading: false, error: null };
|
||||
const EMPTY_TIMELINE: TimelineState = {
|
||||
items: [],
|
||||
cursor: null,
|
||||
hasMore: true,
|
||||
initial: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const usePullRequests = create<{ prs: Record<number, Partial<PrData>> }>(() => ({ prs: {} }));
|
||||
|
||||
function patch<K extends keyof PrData>(number: number, key: K, value: Partial<PrData[K]>) {
|
||||
usePullRequests.setState((s) => {
|
||||
const pr = s.prs[number] ?? {};
|
||||
const prev = (pr[key] ?? (key === "timeline" ? EMPTY_TIMELINE : EMPTY)) as PrData[K];
|
||||
return { prs: { ...s.prs, [number]: { ...pr, [key]: { ...prev, ...value } } } };
|
||||
});
|
||||
}
|
||||
|
||||
function patchSlice<K extends PullRequestSlice>(number: number, key: K, value: Partial<Loadable<Slices[K]>>) {
|
||||
patch(number, key, value as Partial<PrData[K]>);
|
||||
}
|
||||
|
||||
function slice<K extends PullRequestSlice>(number: number, key: K): Loadable<Slices[K]> {
|
||||
return (usePullRequests.getState().prs[number]?.[key] as Loadable<Slices[K]> | undefined) ?? EMPTY;
|
||||
}
|
||||
|
||||
/** The node id of pull request `number` (from its details, else looked up). */
|
||||
export async function pullRequestId(number: number): Promise<string> {
|
||||
const known = slice(number, "details").value?.id;
|
||||
if (known) return known;
|
||||
const id = await api.findPullRequestId(number);
|
||||
if (!id) throw new Error(`Pull request #${number} not found`);
|
||||
return id;
|
||||
}
|
||||
|
||||
const fetchers: { [K in PullRequestSlice]: (number: number) => Promise<Slices[K]> } = {
|
||||
details: async (n) => {
|
||||
const pr = await api.findPullRequest(n);
|
||||
if (!pr) throw new Error(`Pull request #${n} not found`);
|
||||
updateListItem(pr);
|
||||
return pr;
|
||||
},
|
||||
mergeability: async (n) => {
|
||||
const data = await api.findPullRequestMergeability(n);
|
||||
if (!data) throw new Error(`Pull request #${n} not found`);
|
||||
scheduleMergeabilityCheck(n, data.mergeable);
|
||||
return buildMergeabilityState(data, currentUserHasPermission("ADMIN"));
|
||||
},
|
||||
commits: (n) => api.allPages((c) => api.pullRequestCommitsPage(n, c)),
|
||||
changes: (n) => api.pullRequestChangedFiles(n),
|
||||
viewedState: async (n) => {
|
||||
const files = await api.allPages((c) => api.pullRequestFilesPage(n, c));
|
||||
return Object.fromEntries(files.map((f) => [f.path, f.viewerViewedState]));
|
||||
},
|
||||
threads: (n) => api.allPages((c) => api.pullRequestReviewThreadsPage(n, c)),
|
||||
pendingReview: async (n) => api.findPendingReview(await pullRequestId(n)),
|
||||
participants: (n) => api.allPages((c) => api.pullRequestParticipantsPage(n, c)),
|
||||
};
|
||||
|
||||
/** GitHub computes mergeability in the background: `UNKNOWN` is asked again a few times. */
|
||||
const MERGEABILITY_RECHECK_MS = 3000;
|
||||
const MERGEABILITY_RECHECKS = 10;
|
||||
const mergeabilityRechecks = new Map<number, number>();
|
||||
|
||||
function scheduleMergeabilityCheck(number: number, mergeable: string) {
|
||||
if (mergeable !== "UNKNOWN") {
|
||||
mergeabilityRechecks.delete(number);
|
||||
return;
|
||||
}
|
||||
const done = mergeabilityRechecks.get(number) ?? 0;
|
||||
if (done >= MERGEABILITY_RECHECKS) return;
|
||||
mergeabilityRechecks.set(number, done + 1);
|
||||
const started = epoch;
|
||||
setTimeout(() => {
|
||||
if (started === epoch) void reloadPullRequestData(number, "mergeability").catch(() => undefined);
|
||||
}, MERGEABILITY_RECHECK_MS);
|
||||
}
|
||||
|
||||
const inFlight = new Map<string, { seq: number; promise: Promise<unknown> }>();
|
||||
let seqCounter = 0;
|
||||
/** Bumped on disconnect / another repository: results of older requests are dropped. */
|
||||
let epoch = 0;
|
||||
|
||||
/** Loads `key` of pull request `number` again (the value stays visible meanwhile). */
|
||||
export function reloadPullRequestData<K extends PullRequestSlice>(number: number, key: K): Promise<Slices[K]> {
|
||||
const id = `${number}:${key}`;
|
||||
const seq = ++seqCounter;
|
||||
const started = epoch;
|
||||
patchSlice(number, key, { loading: true, error: null });
|
||||
const promise = fetchers[key](number).then(
|
||||
(value) => {
|
||||
if (inFlight.get(id)?.seq === seq && started === epoch) {
|
||||
inFlight.delete(id);
|
||||
patchSlice(number, key, { value, loading: false, error: null });
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(e: unknown) => {
|
||||
if (inFlight.get(id)?.seq === seq && started === epoch) {
|
||||
inFlight.delete(id);
|
||||
patchSlice(number, key, { loading: false, error: errorMessage(e) });
|
||||
}
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
inFlight.set(id, { seq, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Loads `key` unless it is loaded or loading; resolves with the value. */
|
||||
export function loadPullRequestData<K extends PullRequestSlice>(number: number, key: K): Promise<Slices[K]> {
|
||||
const running = inFlight.get(`${number}:${key}`);
|
||||
if (running) return running.promise as Promise<Slices[K]>;
|
||||
const s = slice(number, key);
|
||||
if (s.value !== undefined) return Promise.resolve(s.value);
|
||||
return reloadPullRequestData(number, key);
|
||||
}
|
||||
|
||||
/** The current state of a slice, without loading it. */
|
||||
export function pullRequestData<K extends PullRequestSlice>(number: number, key: K): Loadable<Slices[K]> {
|
||||
return slice(number, key);
|
||||
}
|
||||
|
||||
/** React hook: slice `key` of pull request `number`, loaded on first use. */
|
||||
export function usePullRequestData<K extends PullRequestSlice>(number: number, key: K): Loadable<Slices[K]> {
|
||||
const connected = useGhRepository((s) => !!s.connection);
|
||||
useEffect(() => {
|
||||
if (connected) void loadPullRequestData(number, key).catch(() => undefined);
|
||||
}, [number, key, connected]);
|
||||
return usePullRequests((s) => (s.prs[number]?.[key] as Loadable<Slices[K]> | undefined) ?? EMPTY);
|
||||
}
|
||||
|
||||
/** Replaces a loaded value (a mutation returned the new state, an optimistic update). */
|
||||
export function setPullRequestData<K extends PullRequestSlice>(
|
||||
number: number,
|
||||
key: K,
|
||||
update: (value: Slices[K] | undefined) => Slices[K],
|
||||
) {
|
||||
patchSlice(number, key, { value: update(slice(number, key).value) });
|
||||
}
|
||||
|
||||
/** Calls `listener` after any data of pull request `number` changed (outside React); returns the unsubscriber. */
|
||||
export function subscribePullRequestData(number: number, listener: () => void): () => void {
|
||||
return usePullRequests.subscribe((s, prev) => {
|
||||
if (s.prs[number] !== prev.prs[number]) listener();
|
||||
});
|
||||
}
|
||||
|
||||
/** Pull request `number` as far as it is cached (the list row while details load). */
|
||||
export function cachedDetails(number: number): GHPullRequest | undefined {
|
||||
return slice(number, "details").value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline (`GHPRTimelineDataProvider`: pages of 100)
|
||||
|
||||
function timelineOf(number: number): TimelineState {
|
||||
return usePullRequests.getState().prs[number]?.timeline ?? EMPTY_TIMELINE;
|
||||
}
|
||||
|
||||
const timelineRuns = new Map<number, Promise<void>>();
|
||||
|
||||
async function fetchTimelinePages(number: number, from: string | null, all: boolean) {
|
||||
const items: GHPRTimelineItem[] = [];
|
||||
let cursor = from;
|
||||
let hasMore = true;
|
||||
do {
|
||||
const page = await api.pullRequestTimelinePage(number, cursor);
|
||||
if (!page) throw new Error(`Pull request #${number} not found`);
|
||||
items.push(...page.nodes);
|
||||
hasMore = page.pageInfo.hasNextPage;
|
||||
cursor = page.pageInfo.endCursor ?? cursor;
|
||||
} while (all && hasMore);
|
||||
return { items, cursor, hasMore };
|
||||
}
|
||||
|
||||
function runTimeline(number: number, run: () => Promise<void>): Promise<void> {
|
||||
const running = timelineRuns.get(number);
|
||||
if (running) return running;
|
||||
const started = epoch;
|
||||
patch(number, "timeline", { loading: true, error: null, initial: false });
|
||||
const promise = run()
|
||||
.catch((e: unknown) => {
|
||||
if (started === epoch) patch(number, "timeline", { error: errorMessage(e) });
|
||||
})
|
||||
.finally(() => {
|
||||
timelineRuns.delete(number);
|
||||
if (started === epoch) patch(number, "timeline", { loading: false });
|
||||
const t = timelineOf(number);
|
||||
const details = slice(number, "details").value;
|
||||
// IntelliJ stores "last seen" once the timeline is fully loaded.
|
||||
if (!t.hasMore && !t.error && details) markSeen(details.id);
|
||||
});
|
||||
timelineRuns.set(number, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Loads the next timeline page (the first one on first use). */
|
||||
export function loadTimelinePage(number: number): Promise<void> {
|
||||
const t = timelineOf(number);
|
||||
if (!t.hasMore && !t.initial) return Promise.resolve();
|
||||
const started = epoch;
|
||||
return runTimeline(number, async () => {
|
||||
const page = await fetchTimelinePages(number, t.cursor, false);
|
||||
if (started !== epoch) return;
|
||||
patch(number, "timeline", {
|
||||
items: [...timelineOf(number).items, ...page.items],
|
||||
cursor: page.cursor,
|
||||
hasMore: page.hasMore,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** New items after the loaded ones (`timelineUpdateSignal`); loads the first page when nothing is loaded. */
|
||||
export function updateTimeline(number: number): Promise<void> {
|
||||
const t = timelineOf(number);
|
||||
if (t.initial) return loadTimelinePage(number);
|
||||
if (t.hasMore) return Promise.resolve();
|
||||
const started = epoch;
|
||||
return runTimeline(number, async () => {
|
||||
const page = await fetchTimelinePages(number, t.cursor, true);
|
||||
if (started !== epoch) return;
|
||||
patch(number, "timeline", {
|
||||
items: [...timelineOf(number).items, ...page.items],
|
||||
cursor: page.cursor,
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the whole timeline (`signalTimelineNeedsReload`): fully loaded timelines fetch
|
||||
* every page before replacing the items, partly loaded ones start over from the first page.
|
||||
*/
|
||||
export function reloadTimeline(number: number): Promise<void> {
|
||||
const t = timelineOf(number);
|
||||
const complete = !t.initial && !t.hasMore;
|
||||
const started = epoch;
|
||||
return runTimeline(number, async () => {
|
||||
const page = await fetchTimelinePages(number, null, complete);
|
||||
if (started !== epoch) return;
|
||||
patch(number, "timeline", { items: page.items, cursor: page.cursor, hasMore: page.hasMore });
|
||||
});
|
||||
}
|
||||
|
||||
/** React hook: the timeline of `number`, its first page loaded on first use. */
|
||||
export function usePullRequestTimeline(number: number): TimelineState {
|
||||
const connected = useGhRepository((s) => !!s.connection);
|
||||
useEffect(() => {
|
||||
if (connected && timelineOf(number).initial) void loadTimelinePage(number);
|
||||
}, [number, connected]);
|
||||
return usePullRequests((s) => s.prs[number]?.timeline ?? EMPTY_TIMELINE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `updateAll()` of the timeline / "Refresh Review Data": details, mergeability, timeline and threads. */
|
||||
export async function reloadPullRequest(number: number): Promise<void> {
|
||||
const loaded = usePullRequests.getState().prs[number] ?? {};
|
||||
const keys = (Object.keys(fetchers) as PullRequestSlice[]).filter(
|
||||
(k) => k === "details" || loaded[k]?.value !== undefined,
|
||||
);
|
||||
await Promise.allSettled([
|
||||
...keys.map((k) => reloadPullRequestData(number, k)),
|
||||
...(loaded.timeline && !loaded.timeline.initial ? [reloadTimeline(number)] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Forgets every pull request (disconnect, another repository). */
|
||||
export function clearPullRequests() {
|
||||
epoch++;
|
||||
mergeabilityRechecks.clear();
|
||||
inFlight.clear();
|
||||
timelineRuns.clear();
|
||||
usePullRequests.setState({ prs: {} });
|
||||
}
|
||||
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
const a = s.connection?.coordinates;
|
||||
const b = prev.connection?.coordinates;
|
||||
if (a?.owner !== b?.owner || a?.name !== b?.name) clearPullRequests();
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* The project's GitHub connection and repository data, like IntelliJ's `GHPRProjectViewModel`
|
||||
* (repository selection, auto-connect) plus `GHPRRepositoryDataService` and
|
||||
* `GHPRSecurityService`: gh availability, the GitHub remotes, the connected repository
|
||||
* (permission, merge settings, default branch), the current user, and the cached,
|
||||
* incrementally loaded collaborators, contributors, assignees, labels, teams and
|
||||
* mentionable users.
|
||||
*
|
||||
* Auto-connect: a project with one GitHub remote connects at once; with several, the
|
||||
* repository chosen last (saved per project) is used, else the selector is shown.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { GhAvailability } from "@/platform/ipc";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { errorMessage, setCurrentRepository } from "../api/client";
|
||||
import * as api from "../api/requests";
|
||||
import type {
|
||||
GHCollaborator,
|
||||
GHLabel,
|
||||
GHPullRequestRequestedReviewer,
|
||||
GHRepository,
|
||||
GHRepositoryCoordinates,
|
||||
GHRepositoryPermissionLevel,
|
||||
GHTeam,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import { createBatchesLoader, mergeBatches, type BatchesLoader, type BatchesState } from "./batches";
|
||||
import { hasPermission, parseGitHubRemote, remoteOfRepository } from "./model";
|
||||
|
||||
/** A GitHub remote of the project. */
|
||||
export interface GHRemote extends GHRepositoryCoordinates {
|
||||
/** Git remote name (`origin`). */
|
||||
remote: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GHConnection {
|
||||
coordinates: GHRepositoryCoordinates;
|
||||
repository: GHRepository;
|
||||
currentUser: GHUser;
|
||||
}
|
||||
|
||||
interface RepositoryState {
|
||||
/** gh's state; null until checked. */
|
||||
availability: GhAvailability | null;
|
||||
checking: boolean;
|
||||
/** GitHub remotes of the project, deduplicated by repository. */
|
||||
remotes: GHRemote[];
|
||||
/** gh's own choice among them (`gh repo view`), preselected in the selector. */
|
||||
defaultRemote: GHRepositoryCoordinates | null;
|
||||
connection: GHConnection | null;
|
||||
connecting: boolean;
|
||||
/** "Could not connect to repository …" */
|
||||
connectionError: string | null;
|
||||
/** Bumped whenever the repository data is reloaded (IntelliJ's `dataReloadSignal`). */
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export const useGhRepository = create<RepositoryState>(() => ({
|
||||
availability: null,
|
||||
checking: false,
|
||||
remotes: [],
|
||||
defaultRemote: null,
|
||||
connection: null,
|
||||
connecting: false,
|
||||
connectionError: null,
|
||||
revision: 0,
|
||||
}));
|
||||
|
||||
const projectRoot = () => useProject.getState().project?.root ?? null;
|
||||
const selectionKey = (root: string) => `openidea.github.repository.v1:${root}`;
|
||||
|
||||
function loadSelection(root: string | null): string | null {
|
||||
if (!root) return null;
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(selectionKey(root)) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSelection(root: string | null, nameWithOwner: string | null) {
|
||||
if (!root) return;
|
||||
try {
|
||||
if (nameWithOwner) globalThis.localStorage?.setItem(selectionKey(root), nameWithOwner);
|
||||
else globalThis.localStorage?.removeItem(selectionKey(root));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export const sameRepository = (a: GHRepositoryCoordinates | null | undefined, b: GHRepositoryCoordinates | null) =>
|
||||
!!a && !!b && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
|
||||
|
||||
export const nameWithOwner = (r: GHRepositoryCoordinates) => `${r.owner}/${r.name}`;
|
||||
|
||||
/** The GitHub remotes among `remotes`, one per repository. */
|
||||
export function githubRemotes(remotes: ReadonlyArray<{ name: string; url: string }>): GHRemote[] {
|
||||
const out: GHRemote[] = [];
|
||||
for (const r of remotes) {
|
||||
const c = parseGitHubRemote(r.url);
|
||||
if (c && !out.some((o) => sameRepository(o, c))) out.push({ ...c, remote: r.name, url: r.url });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseNameWithOwner(s: string | null | undefined): GHRepositoryCoordinates | null {
|
||||
const m = s ? /^([^/\s]+)\/([^/\s]+)$/.exec(s) : null;
|
||||
return m ? { owner: m[1]!, name: m[2]! } : null;
|
||||
}
|
||||
|
||||
let checkSeq = 0;
|
||||
|
||||
/**
|
||||
* Checks gh and the remotes, then connects as IntelliJ does on start: the only GitHub
|
||||
* repository, else the saved choice. Keeps the current connection when it still applies.
|
||||
*/
|
||||
export async function refreshConnection(): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
const seq = ++checkSeq;
|
||||
useGhRepository.setState({ checking: true });
|
||||
let availability: GhAvailability;
|
||||
let remotes: GHRemote[] = [];
|
||||
let allRemotes: Array<{ name: string; url: string }> = [];
|
||||
try {
|
||||
const [a, r] = await Promise.all([
|
||||
call(commands.ghAvailability()),
|
||||
call(commands.vcsRemotes(null)).catch(() => []),
|
||||
]);
|
||||
availability = a;
|
||||
allRemotes = r;
|
||||
remotes = githubRemotes(r);
|
||||
} catch (e) {
|
||||
availability = { kind: "error", message: errorMessage(e) };
|
||||
}
|
||||
if (seq !== checkSeq) return;
|
||||
const defaultRemote = availability.kind === "ready" ? parseNameWithOwner(availability.repo.nameWithOwner) : null;
|
||||
if (defaultRemote && !remotes.some((r) => sameRepository(r, defaultRemote))) {
|
||||
const own = remoteOfRepository(allRemotes, defaultRemote);
|
||||
remotes.unshift({
|
||||
...defaultRemote,
|
||||
remote: own?.name ?? "",
|
||||
url: own?.url ?? (availability.kind === "ready" ? availability.repo.url : ""),
|
||||
});
|
||||
}
|
||||
useGhRepository.setState({ availability, remotes, defaultRemote, checking: false });
|
||||
if (availability.kind !== "ready") {
|
||||
disconnect({ forget: false });
|
||||
return;
|
||||
}
|
||||
const current = useGhRepository.getState().connection?.coordinates ?? null;
|
||||
const saved = parseNameWithOwner(loadSelection(projectRoot()));
|
||||
const target =
|
||||
remotes.length === 1
|
||||
? remotes[0]!
|
||||
: (remotes.find((r) => sameRepository(r, current)) ?? remotes.find((r) => sameRepository(r, saved)) ?? null);
|
||||
// Only a choice among several repositories is remembered.
|
||||
if (target) await connect(target, { remember: remotes.length > 1 });
|
||||
else disconnect({ forget: false });
|
||||
}
|
||||
|
||||
let connectSeq = 0;
|
||||
|
||||
/** Connects to `repo` (the selector's "View Pull Requests"), remembering the choice. */
|
||||
export async function connect(repo: GHRepositoryCoordinates, opts: { remember?: boolean } = {}): Promise<boolean> {
|
||||
const seq = ++connectSeq;
|
||||
const coordinates = { owner: repo.owner, name: repo.name };
|
||||
useGhRepository.setState({ connecting: true, connectionError: null });
|
||||
try {
|
||||
const [repository, currentUser] = await Promise.all([api.findRepository(coordinates), api.currentUser()]);
|
||||
if (seq !== connectSeq) return false;
|
||||
if (!repository) throw new Error(`Repository ${nameWithOwner(coordinates)} not found`);
|
||||
const same = sameRepository(useGhRepository.getState().connection?.coordinates, coordinates);
|
||||
if (!same) resetRepositoryData();
|
||||
setCurrentRepository({ ...coordinates, id: repository.id });
|
||||
useGhRepository.setState((s) => ({
|
||||
connection: { coordinates, repository, currentUser },
|
||||
connecting: false,
|
||||
revision: s.revision + 1,
|
||||
}));
|
||||
if (opts.remember ?? true) saveSelection(projectRoot(), nameWithOwner(coordinates));
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (seq !== connectSeq) return false;
|
||||
useGhRepository.setState({
|
||||
connecting: false,
|
||||
connectionError: `Could not connect to repository ${nameWithOwner(coordinates)}: ${errorMessage(e)}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the connection; `forget` also clears the saved choice ("Change Repository or Account…"). */
|
||||
export function disconnect({ forget }: { forget: boolean }) {
|
||||
connectSeq++;
|
||||
if (forget) saveSelection(projectRoot(), null);
|
||||
if (!useGhRepository.getState().connection && !useGhRepository.getState().connecting) return;
|
||||
setCurrentRepository(null);
|
||||
resetRepositoryData();
|
||||
useGhRepository.setState({ connection: null, connecting: false });
|
||||
}
|
||||
|
||||
/** Another project: start over (the next `refreshConnection` checks again). */
|
||||
export function resetConnection() {
|
||||
checkSeq++;
|
||||
disconnect({ forget: false });
|
||||
useGhRepository.setState({ availability: null, remotes: [], defaultRemote: null, connectionError: null });
|
||||
}
|
||||
|
||||
/** The connection, or throws (for operations that only run while connected). */
|
||||
export function requireConnection(): GHConnection {
|
||||
const c = useGhRepository.getState().connection;
|
||||
if (!c) throw new Error("Not connected to a GitHub repository");
|
||||
return c;
|
||||
}
|
||||
|
||||
/** `GHPRSecurityService.currentUserHasPermissionLevel`. */
|
||||
export function currentUserHasPermission(level: GHRepositoryPermissionLevel): boolean {
|
||||
return hasPermission(useGhRepository.getState().connection?.repository.viewerPermission, level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `user` is the signed-in user (by node id, else login).
|
||||
* @public
|
||||
*/
|
||||
export function isCurrentUser(user: { id?: string; login?: string } | null | undefined): boolean {
|
||||
const me = useGhRepository.getState().connection?.currentUser;
|
||||
return !!me && !!user && (user.id ? user.id === me.id : user.login === me.login);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the repository (permission, merge settings) and drops the cached lists.
|
||||
* @public
|
||||
*/
|
||||
export async function reloadRepositoryData(): Promise<void> {
|
||||
const c = useGhRepository.getState().connection;
|
||||
resetRepositoryData();
|
||||
if (c) await connect(c.coordinates);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cached lists
|
||||
|
||||
type Cursor = { after: string | null };
|
||||
const FIRST: Cursor = { after: null };
|
||||
|
||||
function gqlPage<T>(fetch: (cursor: string | null) => ReturnType<typeof api.mentionableUsersPage>) {
|
||||
return async (c: Cursor) => {
|
||||
const page = (await fetch(c.after)) as {
|
||||
nodes: T[];
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
} | null;
|
||||
if (!page) return { items: [] as T[], next: null };
|
||||
return {
|
||||
items: page.nodes,
|
||||
next: page.pageInfo.hasNextPage && page.pageInfo.endCursor ? { after: page.pageInfo.endCursor } : null,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const loaders = {
|
||||
collaborators: createBatchesLoader<GHCollaborator, number>(1, (p) => api.collaboratorsPage(p)),
|
||||
contributors: createBatchesLoader<GHUser, number>(1, (p) => api.contributorsPage(p)),
|
||||
assignees: createBatchesLoader<GHUser, number>(1, (p) => api.assigneesPage(p)),
|
||||
labels: createBatchesLoader<GHLabel, number>(1, (p) => api.labelsPage(p)),
|
||||
mentionableUsers: createBatchesLoader<GHUser, Cursor>(FIRST, gqlPage<GHUser>(api.mentionableUsersPage)),
|
||||
teams: createBatchesLoader<GHTeam, Cursor>(FIRST, async (c) => {
|
||||
const repo = useGhRepository.getState().connection?.repository;
|
||||
// Teams exist for organizations only; listing them needs `read:org`, which gh may lack.
|
||||
if (repo?.owner.__typename !== "Organization") return { items: [], next: null };
|
||||
const page = await api.organizationTeamsPage(repo.owner.login, c.after).catch(() => null);
|
||||
if (!page) return { items: [], next: null };
|
||||
return {
|
||||
items: page.nodes,
|
||||
next: page.pageInfo.hasNextPage && page.pageInfo.endCursor ? { after: page.pageInfo.endCursor } : null,
|
||||
};
|
||||
}),
|
||||
} satisfies Record<string, BatchesLoader<unknown>>;
|
||||
|
||||
export type RepositoryListName = keyof typeof loaders;
|
||||
type ItemOf<K extends RepositoryListName> = (typeof loaders)[K] extends BatchesLoader<infer T> ? T : never;
|
||||
|
||||
let template: Promise<string | null> | null = null;
|
||||
|
||||
function resetRepositoryData() {
|
||||
for (const l of Object.values(loaders)) l.reset();
|
||||
template = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads (once) one of the repository lists; resolves with all its items.
|
||||
* @public
|
||||
*/
|
||||
export function loadRepositoryList<K extends RepositoryListName>(name: K): Promise<ItemOf<K>[]> {
|
||||
return (loaders[name] as BatchesLoader<ItemOf<K>>).load();
|
||||
}
|
||||
|
||||
/** React hook: a repository list, loading it on first use (incremental: items grow while `loading`). */
|
||||
export function useRepositoryList<K extends RepositoryListName>(name: K): BatchesState<ItemOf<K>> {
|
||||
const loader = loaders[name] as BatchesLoader<ItemOf<K>>;
|
||||
const connected = useGhRepository((s) => !!s.connection);
|
||||
useEffect(() => {
|
||||
if (connected) void loader.load().catch(() => undefined);
|
||||
}, [loader, connected]);
|
||||
return loader.store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Users that can be chosen as authors in the list filter: collaborators with write
|
||||
* access, else contributors (users without push access cannot list collaborators).
|
||||
*/
|
||||
export function useAuthorCandidates(): BatchesState<GHUser> {
|
||||
const write = useGhRepository((s) => hasPermission(s.connection?.repository.viewerPermission, "WRITE"));
|
||||
const collaborators = useRepositoryList(write ? "collaborators" : "contributors");
|
||||
return collaborators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Potential reviewers (`getBatchedPotentialReviewers`): teams, then collaborators with push (else contributors).
|
||||
*/
|
||||
export function usePotentialReviewers(): BatchesState<GHPullRequestRequestedReviewer> {
|
||||
const write = useGhRepository((s) => hasPermission(s.connection?.repository.viewerPermission, "WRITE"));
|
||||
const teams = useRepositoryList("teams");
|
||||
const users = useRepositoryList(write ? "collaborators" : "contributors");
|
||||
const pushers = write
|
||||
? { ...users, items: (users.items as GHCollaborator[]).filter((u) => u.permissions.push) }
|
||||
: users;
|
||||
return mergeBatches<GHPullRequestRequestedReviewer>([teams, pushers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first non-blank pull request template of the repository (cached).
|
||||
*/
|
||||
export function loadPullRequestTemplate(): Promise<string | null> {
|
||||
template ??= api
|
||||
.pullRequestTemplates()
|
||||
.then((ts) => ts.find((t) => t.body?.trim())?.body ?? null)
|
||||
.catch(() => null);
|
||||
return template;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* IntelliJ's pull request search history (`GHPRListPersistentSearchHistory`), per project:
|
||||
* the last 10 searches (newest first) and the last search used, which the list restores.
|
||||
* On first run the list shows the Quick Filter "Assigned to you" (`initialSearch`).
|
||||
*/
|
||||
import { useProject } from "@/platform/project";
|
||||
import { DEFAULT_SEARCH, filterCount, normalizeSearch, sameSearch, type PrSearchValue } from "./model";
|
||||
|
||||
const MAX_HISTORY = 10;
|
||||
const historyKey = (root: string) => `openidea.github.prSearchHistory.v2:${root}`;
|
||||
const lastKey = (root: string) => `openidea.github.prLastSearch.v2:${root}`;
|
||||
const projectRoot = () => useProject.getState().project?.root ?? null;
|
||||
|
||||
function load(key: string): unknown {
|
||||
try {
|
||||
const raw = globalThis.localStorage?.getItem(key);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function save(key: string, value: unknown) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `history` with `v` first (moved up when already there). Searches without filters and
|
||||
* `defaultSearch` are not kept (the list passes its first-run filter, "Assigned to you").
|
||||
*/
|
||||
export function withSearch(
|
||||
history: readonly PrSearchValue[],
|
||||
v: PrSearchValue,
|
||||
defaultSearch: PrSearchValue = DEFAULT_SEARCH,
|
||||
): PrSearchValue[] {
|
||||
if (filterCount(v) === 0 || sameSearch(v, defaultSearch)) return [...history];
|
||||
return [v, ...history.filter((h) => !sameSearch(h, v))].slice(0, MAX_HISTORY);
|
||||
}
|
||||
|
||||
/** Recent searches of the project, newest first. */
|
||||
export function loadSearchHistory(root = projectRoot()): PrSearchValue[] {
|
||||
if (!root) return [];
|
||||
const v = load(historyKey(root));
|
||||
return Array.isArray(v) ? v.flatMap((x) => normalizeSearch(x) ?? []) : [];
|
||||
}
|
||||
|
||||
/** Adds `v` to the history (see `withSearch`). */
|
||||
export function rememberSearch(v: PrSearchValue, root = projectRoot(), defaultSearch: PrSearchValue = DEFAULT_SEARCH) {
|
||||
if (root) save(historyKey(root), withSearch(loadSearchHistory(root), v, defaultSearch));
|
||||
}
|
||||
|
||||
/** The search the project's list showed last, if any. */
|
||||
export function loadLastSearch(root = projectRoot()): PrSearchValue | null {
|
||||
return root ? normalizeSearch(load(lastKey(root))) : null;
|
||||
}
|
||||
|
||||
/** Remembers `v` as the search to restore. */
|
||||
export function saveLastSearch(v: PrSearchValue, root = projectRoot()) {
|
||||
if (root) save(lastKey(root), v);
|
||||
}
|
||||
|
||||
/** The list's search when the tool window opens: the last one, else "Assigned to you". */
|
||||
export function initialSearch(currentUserLogin: string, root = projectRoot()): PrSearchValue {
|
||||
return loadLastSearch(root) ?? { state: "OPEN", assignee: currentUserLogin };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* The head branch widget (`CodeReviewDetailsBranchComponentFactory`): the branch icon
|
||||
* (the current-branch icon once the head branch is checked out) and a dropdown link with
|
||||
* the head branch (`owner:branch` for forks and equal names). Its popup lists Checkout,
|
||||
* Open in Worktree (when the branch is not checked out), Show in Git Log and Copy Branch
|
||||
* Name, with the "{base} ← {head}" note below.
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Icon, List, Popup } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { GHPullRequest } from "../api/types";
|
||||
import { checkoutPullRequest, openInWorktree } from "../data/local";
|
||||
import { copyBranchName, showInGitLog } from "./flow";
|
||||
import { headBranchLabel } from "./model";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
type BranchAction = "checkout" | "worktree" | "log" | "copy";
|
||||
|
||||
export function BranchWidget({
|
||||
pr,
|
||||
checkedOut,
|
||||
headRemote,
|
||||
hasRemoteBranch = true,
|
||||
}: {
|
||||
pr: GHPullRequest;
|
||||
checkedOut: boolean;
|
||||
/** The local remote of the head repository, for Show in Git Log. */
|
||||
headRemote: string | null;
|
||||
/** GitHub always has the head branch remotely (`CodeReviewBranches.hasRemoteBranch`). */
|
||||
hasRemoteBranch?: boolean;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const anchor = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const source = headBranchLabel(pr);
|
||||
const actions: BranchAction[] = ["checkout", ...(checkedOut ? [] : ["worktree" as const]), "log", "copy"];
|
||||
const label: Record<BranchAction, string> = {
|
||||
checkout: hasRemoteBranch ? tx("Checkout") : tx("Checkout as detached HEAD"),
|
||||
worktree: tx("Open in Worktree"),
|
||||
log: tx("Show in Git Log"),
|
||||
copy: tx("Copy Branch Name"),
|
||||
};
|
||||
const run = (a: BranchAction) => {
|
||||
setOpen(false);
|
||||
if (a === "checkout") void checkoutPullRequest(pr.number);
|
||||
else if (a === "worktree") void openInWorktree(pr.number);
|
||||
else if (a === "log") void showInGitLog(pr, headRemote);
|
||||
else void copyBranchName(source);
|
||||
};
|
||||
return (
|
||||
<div ref={anchor} className={styles.branch}>
|
||||
<button type="button" className={styles.branchIcon} aria-label={tx("Branches")} onClick={() => setOpen(true)}>
|
||||
<Icon name={checkedOut ? "current-branch-label" : "branch-label"} size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.link, styles.dropdownLink, styles.branchLink)}
|
||||
title={source}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className={styles.ellipsis}>{source}</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
{open && (
|
||||
<Popup
|
||||
open
|
||||
anchor={anchor}
|
||||
placement="bottom-start"
|
||||
onClose={() => setOpen(false)}
|
||||
aria-label={tx("Branches")}
|
||||
className={styles.branchPopup}
|
||||
>
|
||||
<List
|
||||
items={actions}
|
||||
getKey={(a) => a}
|
||||
onActivate={(a) => run(a)}
|
||||
hoverSelects
|
||||
rowHeight={24}
|
||||
aria-label={tx("Branches")}
|
||||
renderItem={(a, state) => (
|
||||
<div className={styles.popupRow} data-active={state.active || undefined}>
|
||||
{label[a]}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className={styles.advertiser}>
|
||||
{hasRemoteBranch ? (
|
||||
tx`${pr.baseRefName} ← ${source}`
|
||||
) : (
|
||||
<>
|
||||
<Icon name="warning" size={16} />
|
||||
{tx("Remote branches are not available for private forks")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* The changes tree (`CodeReviewChangeListComponentFactory` with
|
||||
* `CodeReviewProgressRenderer`): file names in their VCS status colour, grouped by
|
||||
* directory or flat, and on the right of files and collapsed folders the unresolved
|
||||
* thread counter (unread icon while the file is not viewed), the unread dot of files not
|
||||
* viewed, and — for the "All commits" list — a viewed checkbox shown on hover and kept
|
||||
* checked once viewed. Double-click / Enter opens the diff; the context menu is
|
||||
* `Github.PullRequest.Changes.Popup`.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { dirKeys, type FileTreeNode } from "@/features/vcs/fileTree";
|
||||
import { baseName, dirName, fileCount, KIND_COLOR } from "@/features/vcs/model";
|
||||
import { currentRepoRoot } from "@/features/vcs/store";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Icon, Tree, fileIconName, showContextMenu, T, type MenuItem, type TreeHandle } from "@/ui";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import type { GHCommitFile, GHPullRequestFileViewedState, GHPullRequestReviewThread } from "../api/types";
|
||||
import { openPullRequestFileDiff } from "../data/local";
|
||||
import { setViewed } from "../data/operations";
|
||||
import { provideGithubHandler } from "../toolwindow/handlers";
|
||||
import { openCommitFileDiff } from "./flow";
|
||||
import {
|
||||
changeKind,
|
||||
changesTree,
|
||||
isViewed,
|
||||
progressOf,
|
||||
selectedFiles,
|
||||
unresolvedThreadsByPath,
|
||||
viewedToggleText,
|
||||
type ProgressState,
|
||||
} from "./model";
|
||||
import { setGroupByDirectory, useChangesGrouping } from "./state";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
type Node = FileTreeNode<GHCommitFile>;
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set();
|
||||
|
||||
export interface ChangesTreeProps {
|
||||
number: number;
|
||||
files: readonly GHCommitFile[];
|
||||
/** The commit whose changes these are; null for all commits (the cumulative list, with viewed state). */
|
||||
commitSha: string | null;
|
||||
viewedState: Record<string, GHPullRequestFileViewedState> | undefined;
|
||||
threads: readonly GHPullRequestReviewThread[] | undefined;
|
||||
/** The head branch is checked out (its files can be edited). */
|
||||
checkedOut: boolean;
|
||||
}
|
||||
|
||||
export function ChangesTree({ number, files, commitSha, viewedState, threads, checkedOut }: ChangesTreeProps) {
|
||||
const tx = useTx();
|
||||
const grouped = useChangesGrouping((s) => s.byDirectory);
|
||||
const hasViewedState = commitSha === null;
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
const roots = useMemo(() => changesTree(files, grouped), [files, grouped]);
|
||||
const allDirs = useMemo(() => dirKeys(roots), [roots]);
|
||||
// Everything starts expanded; collapsing is remembered while the list stays the same.
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(NONE);
|
||||
const expanded = useMemo(() => new Set(allDirs.filter((k) => !collapsed.has(k))), [allDirs, collapsed]);
|
||||
const setExpanded = (next: ReadonlySet<string>) => setCollapsed(new Set(allDirs.filter((k) => !next.has(k))));
|
||||
const [selection, setSelection] = useState<ReadonlySet<string>>(NONE);
|
||||
|
||||
const discussions = useMemo(() => unresolvedThreadsByPath(threads ?? []), [threads]);
|
||||
const leaf = useMemo(
|
||||
() =>
|
||||
(f: GHCommitFile): ProgressState => ({
|
||||
isRead: hasViewedState ? isViewed(viewedState?.[f.filename]) : true,
|
||||
discussions: discussions.get(f.filename) ?? 0,
|
||||
}),
|
||||
[hasViewedState, viewedState, discussions],
|
||||
);
|
||||
// Until the threads and the viewed state load, no state is shown (`isLoading`).
|
||||
const loading = threads === undefined || (hasViewedState && viewedState === undefined);
|
||||
|
||||
const openDiff = (f: GHCommitFile) =>
|
||||
void (commitSha ? openCommitFileDiff(number, commitSha, f) : openPullRequestFileDiff(number, f.filename));
|
||||
const jumpToSource = (f: GHCommitFile) => {
|
||||
const root = currentRepoRoot();
|
||||
if (root) void editorService().openFile(`${root.replace(/[\\/]+$/, "")}/${f.filename}`, { focus: true });
|
||||
};
|
||||
const canEdit = (f: GHCommitFile) =>
|
||||
f.status !== "removed" && (checkedOut || (f.status !== "added" && f.status !== "copied"));
|
||||
|
||||
const toggle = (chosen: readonly GHCommitFile[]) => {
|
||||
if (!hasViewedState || !chosen.length) return;
|
||||
const allViewed = chosen.every((f) => leaf(f).isRead);
|
||||
void setViewed(
|
||||
number,
|
||||
chosen.map((f) => f.filename),
|
||||
!allViewed,
|
||||
).catch(() => undefined);
|
||||
};
|
||||
|
||||
// `Github.PullRequest.Changed.MarkViewed.Toggle` on the selection (⇧⌘S, the context menu).
|
||||
const toggleRef = useRef(() => {});
|
||||
useLayoutEffect(() => {
|
||||
toggleRef.current = () => toggle(selectedFiles(roots, selection));
|
||||
});
|
||||
useEffect(() => provideGithubHandler("toggleViewed", () => toggleRef.current()), []);
|
||||
|
||||
const onContextMenu = (_node: Node | null, keys: Set<string>, e: MouseEvent) => {
|
||||
setSelection(keys);
|
||||
const chosen = selectedFiles(roots, keys);
|
||||
const first = chosen[0];
|
||||
const allViewed = chosen.every((f) => leaf(f).isRead);
|
||||
const items: MenuItem[] = [
|
||||
{ label: "Show Diff", icon: "diff", disabled: !first, run: () => first && openDiff(first) },
|
||||
{ label: "Show Diff in a New Tab or Window", disabled: !first, run: () => first && openDiff(first) },
|
||||
{
|
||||
label: "Jump to Source",
|
||||
icon: "edit-source",
|
||||
disabled: !first || !canEdit(first),
|
||||
run: () => first && jumpToSource(first),
|
||||
},
|
||||
...(hasViewedState && chosen.length
|
||||
? [
|
||||
{
|
||||
kind: "action" as const,
|
||||
action: "github.pullRequest.toggleViewed",
|
||||
label: viewedToggleText(chosen.length, allViewed),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ kind: "separator" },
|
||||
{
|
||||
label: "View Options",
|
||||
icon: "eye",
|
||||
submenu: [
|
||||
{ kind: "header", label: "Group By" },
|
||||
{ label: "Directory", checked: grouped, run: () => setGroupByDirectory(!grouped) },
|
||||
],
|
||||
},
|
||||
{ label: "Expand All", icon: "expand-all", run: () => setExpanded(new Set(allDirs)) },
|
||||
{ label: "Collapse All", icon: "collapse-all", run: () => setExpanded(new Set()) },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "github.pullRequest.refresh", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.openInBrowser", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.copyUrl", arg: number },
|
||||
];
|
||||
e.stopPropagation();
|
||||
showContextMenu(e, items);
|
||||
};
|
||||
|
||||
if (!files.length)
|
||||
return (
|
||||
<div className={styles.treeEmpty}>
|
||||
<T>Pull request does not contain any changes</T>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tree<Node>
|
||||
handleRef={tree}
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.name}
|
||||
getChildren={(n) => (n.file ? undefined : n.children)}
|
||||
isExpandable={(n) => !n.file}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
selected={selection}
|
||||
onSelectionChange={(s) => setSelection(s)}
|
||||
rowClassName={() => styles.changeRow}
|
||||
renderNode={(n, state) => (
|
||||
<ChangeRow
|
||||
node={n}
|
||||
flat={!grouped}
|
||||
progress={loading || (!n.file && state.expanded) ? null : progressOf(n, leaf)}
|
||||
hasViewedState={hasViewedState}
|
||||
canEdit={!!n.file && canEdit(n.file)}
|
||||
onToggleViewed={() => n.file && toggle([n.file])}
|
||||
onEdit={() => n.file && jumpToSource(n.file)}
|
||||
/>
|
||||
)}
|
||||
onActivate={(n) => n.file && openDiff(n.file)}
|
||||
onContextMenu={onContextMenu}
|
||||
speedSearch
|
||||
aria-label={tx("Changes")}
|
||||
className={styles.tree}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeRow(props: {
|
||||
node: Node;
|
||||
flat: boolean;
|
||||
/** Shown for files and collapsed folders once loaded. */
|
||||
progress: ProgressState | null;
|
||||
hasViewedState: boolean;
|
||||
canEdit: boolean;
|
||||
onToggleViewed: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { node, flat, progress } = props;
|
||||
const tx = useTx();
|
||||
const f = node.file;
|
||||
const name = f ? (
|
||||
<>
|
||||
<Icon name={fileIconName(node.name)} size={16} className={styles.fileIcon} />
|
||||
<span className={styles.fileName} style={{ color: KIND_COLOR[changeKind(f)] }}>
|
||||
{node.name}
|
||||
</span>
|
||||
{flat && dirName(f.filename) && <span className={styles.fileDir}>{dirName(f.filename)}</span>}
|
||||
{f.previousFilename && <span className={styles.fileDir}>{tx`renamed from ${baseName(f.previousFilename)}`}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="folder" size={16} className={styles.fileIcon} />
|
||||
<span className={styles.fileName}>{node.name}</span>
|
||||
<span className={styles.fileDir}>{fileCount(node.count)}</span>
|
||||
</>
|
||||
);
|
||||
const counter = progress && progress.discussions > 0 && (
|
||||
<span className={styles.counter}>
|
||||
<Icon name={progress.isRead ? "review-comment" : "review-comment-unread"} size={16} />
|
||||
{progress.discussions}
|
||||
</span>
|
||||
);
|
||||
// The checkbox / dot slot: files of the cumulative list get the checkbox (on hover, or
|
||||
// kept once viewed); anything unread shows the dot otherwise.
|
||||
const toggleable = !!f && props.hasViewedState;
|
||||
const checkbox = toggleable && progress && (
|
||||
<span
|
||||
role="checkbox"
|
||||
aria-checked={progress.isRead}
|
||||
aria-label={tx(progress.isRead ? "Mark as Not Viewed" : "Mark as Viewed")}
|
||||
className={clsx(styles.viewedBox, progress.isRead && styles.viewedChecked)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onToggleViewed();
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{progress.isRead && <Icon name="check" size={12} />}
|
||||
</span>
|
||||
);
|
||||
const dot = progress && !progress.isRead && <Icon name="review-file-unread" size={16} />;
|
||||
return (
|
||||
<span
|
||||
className={styles.fileRow}
|
||||
title={f ? (f.previousFilename ? `${f.previousFilename} → ${f.filename}` : f.filename) : undefined}
|
||||
>
|
||||
{name}
|
||||
{progress && (
|
||||
<span className={styles.progress}>
|
||||
{props.canEdit ? (
|
||||
<>
|
||||
<span className={styles.noHover}>{counter}</span>
|
||||
<span
|
||||
className={clsx(styles.onHover, styles.editSource)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onEdit();
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="edit-source" size={16} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
counter
|
||||
)}
|
||||
{(counter || checkbox || dot) && (
|
||||
<span className={styles.readSlot}>
|
||||
{toggleable && progress.isRead ? (
|
||||
checkbox
|
||||
) : (
|
||||
<>
|
||||
{dot && <span className={toggleable ? styles.noHover : undefined}>{dot}</span>}
|
||||
{toggleable && <span className={styles.onHover}>{checkbox}</span>}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* "Changes from {n} commits" (`CodeReviewDetailsCommitsComponentFactory`): the bold label,
|
||||
* the commit dropdown link (the selected commit's hash, else "{n} commits"; disabled with
|
||||
* one commit), its popup ("All commits (n)", a separator, then every commit with its
|
||||
* author and date, filtered by the search field) and the next / previous arrows. Below the
|
||||
* row, the selected commit's info block with Show / Hide details
|
||||
* (`CodeReviewDetailsCommitInfoComponentFactory`).
|
||||
*/
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
HighlightedText,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
Popup,
|
||||
SearchField,
|
||||
fuzzyMatch,
|
||||
listNavigate,
|
||||
T,
|
||||
type PopupAnchor,
|
||||
} from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { GHPullRequestCommit } from "../api/types";
|
||||
import { GHOST_USER } from "../data/model";
|
||||
import { prettyDateTime } from "../ui/prettyDate";
|
||||
import { commitsTitle, stepCommit } from "./model";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
/** `createCommitsPopupPresenter`: headline, body, author (the ghost user when unknown) and date. */
|
||||
function presentation(c: GHPullRequestCommit) {
|
||||
const user = c.commit.author?.user ?? GHOST_USER;
|
||||
return {
|
||||
title: c.commit.messageHeadline,
|
||||
description: c.commit.messageBody,
|
||||
author: user.name || user.login,
|
||||
date: prettyDateTime(c.commit.committedDate),
|
||||
};
|
||||
}
|
||||
|
||||
export function CommitPicker({
|
||||
commits,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
commits: readonly GHPullRequestCommit[];
|
||||
/** Index of the selected commit; -1 for all. */
|
||||
selected: number;
|
||||
onSelect: (index: number) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const link = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const commit = commits[selected];
|
||||
const arrows = commits.length > 1 && selected >= 0;
|
||||
const longest = Math.max(0, ...commits.map((c) => c.commit.abbreviatedOid.length));
|
||||
const next = stepCommit(selected, commits.length, 1);
|
||||
const previous = stepCommit(selected, commits.length, -1);
|
||||
return (
|
||||
<div className={styles.commits}>
|
||||
<span className={styles.bold}>
|
||||
<T>{commitsTitle(commits.length)}</T>
|
||||
</span>
|
||||
<button
|
||||
ref={link}
|
||||
type="button"
|
||||
className={clsx(styles.link, styles.dropdownLink)}
|
||||
disabled={commits.length <= 1}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
// The link is as wide as the longest hash once one is shown, so the arrows do not move.
|
||||
style={commit ? { minWidth: `calc(${longest}ch + 20px)` } : undefined}
|
||||
>
|
||||
<span>
|
||||
{commit
|
||||
? commit.commit.abbreviatedOid
|
||||
: commits.length === 1
|
||||
? tx("1 commit")
|
||||
: tx`${commits.length} commits`}
|
||||
</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
{arrows && (
|
||||
<>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
size="small"
|
||||
title="Next Commit"
|
||||
disabled={next === null}
|
||||
onClick={() => next !== null && onSelect(next)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-up"
|
||||
size="small"
|
||||
title="Previous Commit"
|
||||
disabled={previous === null}
|
||||
onClick={() => previous !== null && onSelect(previous)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{open && (
|
||||
<CommitsPopup
|
||||
anchor={link}
|
||||
commits={commits}
|
||||
selected={selected}
|
||||
onChoose={onSelect}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Row = { kind: "all" } | { kind: "separator" } | { kind: "commit"; index: number; match: number[] | null };
|
||||
|
||||
function CommitsPopup(props: {
|
||||
anchor: PopupAnchor;
|
||||
commits: readonly GHPullRequestCommit[];
|
||||
selected: number;
|
||||
onChoose: (index: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { commits, selected } = props;
|
||||
const tx = useTx();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const allText = tx`All commits (${commits.length})`;
|
||||
const rows = useMemo((): Row[] => {
|
||||
const q = filter.trim();
|
||||
const out: Row[] = [];
|
||||
if (!q || fuzzyMatch(allText, q)) out.push({ kind: "all" }, { kind: "separator" });
|
||||
commits.forEach((c, index) => {
|
||||
const match = q ? fuzzyMatch(c.commit.messageHeadline, q) : null;
|
||||
if (!q || match) out.push({ kind: "commit", index, match });
|
||||
});
|
||||
return out;
|
||||
}, [commits, filter, allText]);
|
||||
const isSection = (r: Row) => r.kind === "separator";
|
||||
const current = Math.min(active, rows.length - 1);
|
||||
const choose = (r: Row | undefined) => {
|
||||
if (!r || r.kind === "separator") return;
|
||||
props.onChoose(r.kind === "all" ? -1 : r.index);
|
||||
props.onClose();
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const n = listNavigate(e.key, { items: rows, active: current, isSection, wrap: true, pageSize: 7 });
|
||||
if (n !== null && e.key !== "Home" && e.key !== "End") {
|
||||
e.preventDefault();
|
||||
setActive(n);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
choose(rows[current]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
placement="bottom-start"
|
||||
onClose={props.onClose}
|
||||
aria-label={tx("Commits")}
|
||||
className={styles.commitsPopup}
|
||||
>
|
||||
<div className={styles.popupSearch}>
|
||||
<SearchField
|
||||
value={filter}
|
||||
onChange={(v) => {
|
||||
setFilter(v);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search commits"
|
||||
aria-label={tx("Search commits")}
|
||||
data-autofocus
|
||||
/>
|
||||
</div>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(r, i) => (r.kind === "commit" ? `c:${r.index}` : `${r.kind}:${i}`)}
|
||||
isSection={isSection}
|
||||
renderSection={() => <div className={styles.popupSeparator} />}
|
||||
sectionHeight={9}
|
||||
activeIndex={current}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(r) => choose(r)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={52}
|
||||
className={styles.commitsList}
|
||||
aria-label={tx("Commits")}
|
||||
renderItem={(r, state) => {
|
||||
if (r.kind !== "commit")
|
||||
return (
|
||||
<div className={clsx(styles.commitRow, styles.allCommitsRow)} data-active={state.active || undefined}>
|
||||
<span className={styles.check}>{selected < 0 && <Icon name="check" size={16} />}</span>
|
||||
<span>{allText}</span>
|
||||
</div>
|
||||
);
|
||||
const p = presentation(commits[r.index]!);
|
||||
return (
|
||||
<div className={styles.commitRow} data-active={state.active || undefined}>
|
||||
<span className={styles.check}>{selected === r.index && <Icon name="check" size={16} />}</span>
|
||||
<span className={styles.commitText}>
|
||||
<HighlightedText className={styles.ellipsis} text={p.title} indices={r.match} />
|
||||
<span className={clsx(styles.secondary, styles.ellipsis)}>
|
||||
{p.author}, {p.date}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
/** The selected commit: its headline (one line), Show / Hide details, then its body and "author, date". */
|
||||
export function CommitInfo({ commit }: { commit: GHPullRequestCommit }) {
|
||||
const [detailed, setDetailed] = useState(false);
|
||||
const p = presentation(commit);
|
||||
return (
|
||||
<div className={styles.commitInfo}>
|
||||
<div className={styles.commitInfoTop}>
|
||||
<span className={clsx(styles.commitInfoTitle, !detailed && styles.ellipsis)} title={p.title}>
|
||||
{p.title}
|
||||
</span>
|
||||
<button type="button" className={styles.link} onClick={() => setDetailed(!detailed)}>
|
||||
<T>{detailed ? "Hide details" : "Show details"}</T>
|
||||
</button>
|
||||
</div>
|
||||
{detailed && (
|
||||
<>
|
||||
{p.description && <div className={styles.commitDescription}>{p.description}</div>}
|
||||
<div className={styles.secondary}>
|
||||
{p.author}, {p.date}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/*
|
||||
* The details tab, with IntelliJ's new-UI spacing (`ReviewDetailsUIUtil`): 16px left and
|
||||
* 14px right gaps; title 16/16/16/14, commits row 0/16/4/14, commit info 0/16/12/14,
|
||||
* statuses 6/16/10/14 (149px at most), actions 0/14/16/14.
|
||||
*/
|
||||
.panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.centered {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
color: var(--fg-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.secondary {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
.flex {
|
||||
flex: 1;
|
||||
}
|
||||
.ellipsis {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ActionLink */
|
||||
.link {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover:not(:disabled) {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.link:disabled {
|
||||
color: var(--fg-faint);
|
||||
cursor: default;
|
||||
}
|
||||
.dropdownLink {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.titlePanel {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px 14px 16px 16px;
|
||||
}
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--fg);
|
||||
font-size: calc(var(--fs-ui) + 5px);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.number {
|
||||
color: var(--fg-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.number:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tag {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Commits and branch */
|
||||
.commitsAndBranches {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0 14px 4px 16px;
|
||||
}
|
||||
.commits {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 0 5px;
|
||||
}
|
||||
.branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 6px 0 5px;
|
||||
}
|
||||
.branchIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.branchLink {
|
||||
min-width: 0;
|
||||
}
|
||||
.branchPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.popupRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin: 0 6px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-selection);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.popupRow[data-active] {
|
||||
background: var(--bg-list-selected);
|
||||
}
|
||||
/* JBUI.CurrentTheme.Advertiser */
|
||||
.advertiser {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding: 5px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-popup-advertiser);
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Commits popup (CommitRenderer) */
|
||||
.commitsPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 360px;
|
||||
height: 340px;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
.popupSearch {
|
||||
flex: none;
|
||||
padding: 6px 8px 4px;
|
||||
}
|
||||
.commitsList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.popupSeparator {
|
||||
height: 1px;
|
||||
margin: 4px 8px;
|
||||
background: var(--border);
|
||||
}
|
||||
.commitRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
margin: 0 6px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-selection);
|
||||
overflow: hidden;
|
||||
}
|
||||
.commitRow[data-active] {
|
||||
background: var(--bg-list-selected);
|
||||
}
|
||||
.allCommitsRow {
|
||||
height: 26px;
|
||||
}
|
||||
.check {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
align-self: flex-start;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.allCommitsRow .check {
|
||||
align-self: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
.commitText {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Commit info */
|
||||
.commitInfo {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 14px 12px 16px;
|
||||
}
|
||||
.commitInfoTop {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.commitInfoTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.commitDescription {
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Changes tree (CodeReviewProgressRenderer: 4px text gap, 10px between counter and dot, 16px right) */
|
||||
.changes {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
min-height: 60px;
|
||||
}
|
||||
.tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.treeEmpty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.fileRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fileIcon {
|
||||
flex: none;
|
||||
}
|
||||
.fileName {
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fileDir {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--fg-subtle);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.progress {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
padding: 0 8px 0 4px;
|
||||
}
|
||||
.counter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.readSlot {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
}
|
||||
.viewedBox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--border-checkbox);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-checkbox);
|
||||
cursor: pointer;
|
||||
}
|
||||
.viewedChecked {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
}
|
||||
.editSource {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.onHover {
|
||||
display: none;
|
||||
}
|
||||
.changeRow:hover .onHover {
|
||||
display: inline-flex;
|
||||
}
|
||||
.changeRow:hover .noHover {
|
||||
display: none;
|
||||
}
|
||||
.noHover {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Status section */
|
||||
.status {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
max-height: 149px;
|
||||
margin: 6px 14px 10px 16px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 5px 0;
|
||||
}
|
||||
.statusRow > .link {
|
||||
align-self: center;
|
||||
}
|
||||
.reviewerRow {
|
||||
padding: 3px 0;
|
||||
}
|
||||
.statusIcon {
|
||||
flex: none;
|
||||
}
|
||||
.statusText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The status label's icon-text gap is 4px. */
|
||||
.statusRow > .statusIcon + .statusText {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
/* Actions (buttons 10px apart) */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px 16px 14px;
|
||||
}
|
||||
.more {
|
||||
flex: none;
|
||||
}
|
||||
.progressText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
/* JBOptionButton: the main action and an arrow opening the others. */
|
||||
.optionButton {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
.optionMain {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.optionArrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: var(--h-button);
|
||||
margin-left: -1px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-button);
|
||||
border-top-right-radius: var(--radius-control);
|
||||
border-bottom-right-radius: var(--radius-control);
|
||||
background: var(--bg-button);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.optionArrow:hover {
|
||||
background: var(--bg-control-hover);
|
||||
}
|
||||
|
||||
/* Submit Review popup (insets 12) */
|
||||
.submitPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: var(--submit-width);
|
||||
min-width: 320px;
|
||||
height: var(--submit-height);
|
||||
min-height: 160px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
.submitHeader {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.submitEditor {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
.submitEditor:focus {
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
.submitError {
|
||||
flex: none;
|
||||
color: var(--danger);
|
||||
}
|
||||
.submitButtons {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The `#N` tab of the Pull Requests tool window (IntelliJ's `GHPRDetailsComponentFactory`),
|
||||
* top to bottom: the title with `#N` and the state tag, "View Timeline", the commit picker
|
||||
* with the head branch widget on the right, the selected commit's info, the changes tree,
|
||||
* the status section and the review flow buttons. Right-click elsewhere:
|
||||
* `Github.PullRequest.Details.Popup` (Refresh, View in Browser, Copy URL).
|
||||
*
|
||||
* Provides the `submitReview` and `toggleViewed` handlers while it is shown.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent, type RefObject } from "react";
|
||||
import { useVcs } from "@/features/vcs/store";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Spinner, T, showContextMenu, tooltipAttrs, type MenuItem } from "@/ui";
|
||||
import { hasPermission, reviewersStates } from "../data/model";
|
||||
import { openInBrowser } from "../data/local";
|
||||
import { reloadPullRequestData, usePullRequestData } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { provideGithubHandler } from "../toolwindow/handlers";
|
||||
import { openTimeline } from "../toolwindow/timelineInput";
|
||||
import { StateTag } from "../ui/chips";
|
||||
import { BranchWidget } from "./BranchWidget";
|
||||
import { ChangesTree } from "./ChangesTree";
|
||||
import { CommitInfo, CommitPicker } from "./CommitPicker";
|
||||
import { isInCurrentHistory, resolveConflictsLocally, useFlowBusy } from "./flow";
|
||||
import { isHeadCheckedOut, overallReviewState, remoteOf, resolveConflicts, reviewRequestState } from "./model";
|
||||
import { ReviewActions } from "./ReviewActions";
|
||||
import { useCommitFilesOf } from "./state";
|
||||
import { StatusSection } from "./StatusSection";
|
||||
import { showSubmitReview } from "./SubmitReviewPopup";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
const PANEL_MENU: (n: number) => MenuItem[] = (n) => [
|
||||
{ kind: "action", action: "github.pullRequest.refresh", arg: n },
|
||||
{ kind: "action", action: "github.pullRequest.openInBrowser", arg: n },
|
||||
{ kind: "action", action: "github.pullRequest.copyUrl", arg: n },
|
||||
];
|
||||
|
||||
export function DetailsPanel({ number }: { number: number }) {
|
||||
const details = usePullRequestData(number, "details");
|
||||
const submitRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
provideGithubHandler("submitReview", (n) => void showSubmitReview(n, n === number ? submitRef.current : null)),
|
||||
[number],
|
||||
);
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e, PANEL_MENU(number));
|
||||
};
|
||||
|
||||
const pr = details.value;
|
||||
return (
|
||||
<div className={styles.panel} onContextMenu={onContextMenu}>
|
||||
{pr ? (
|
||||
<Details number={number} submitRef={submitRef} />
|
||||
) : details.error ? (
|
||||
<div className={styles.centered}>
|
||||
<div className={styles.error}>{details.error}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => void reloadPullRequestData(number, "details").catch(() => undefined)}
|
||||
>
|
||||
<T>Retry</T>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.centered}>
|
||||
<Spinner size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Details({ number, submitRef }: { number: number; submitRef: RefObject<HTMLButtonElement | null> }) {
|
||||
const tx = useTx();
|
||||
const pr = usePullRequestData(number, "details").value!;
|
||||
const mergeability = usePullRequestData(number, "mergeability").value;
|
||||
const commits = usePullRequestData(number, "commits").value ?? [];
|
||||
const changes = usePullRequestData(number, "changes");
|
||||
const viewedState = usePullRequestData(number, "viewedState").value;
|
||||
const threads = usePullRequestData(number, "threads").value;
|
||||
const permission = useGhRepository((s) => s.connection?.repository.viewerPermission);
|
||||
const remotes = useGhRepository((s) => s.remotes);
|
||||
const branch = useVcs((s) => s.branch);
|
||||
const busy = useFlowBusy((s) => s.busy[number] !== undefined);
|
||||
|
||||
const [selected, setSelected] = useState(-1);
|
||||
// A single-commit pull request only has the cumulative list (`GHPRChangesViewModel`).
|
||||
const commit = commits.length > 1 ? commits[selected] : undefined;
|
||||
const commitFiles = useCommitFilesOf(commit?.commit.oid ?? null);
|
||||
const files = commit ? commitFiles : changes;
|
||||
|
||||
const reviewers = useMemo(() => reviewersStates(pr), [pr]);
|
||||
const reviewState = overallReviewState(pr, reviewers, mergeability?.requiredApprovingReviewsCount ?? 0);
|
||||
const checkedOut = isHeadCheckedOut(branch, remotes, pr);
|
||||
const headRemote = remoteOf(remotes, pr.headRepository?.nameWithOwner)?.remote ?? null;
|
||||
|
||||
// "Already resolved locally": the head branch is checked out and already has the base commit.
|
||||
const historyKey = checkedOut ? `${pr.baseRefOid}:${branch?.headCommit ?? ""}` : null;
|
||||
const [inHistory, setInHistory] = useState<{ key: string; value: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (historyKey)
|
||||
void isInCurrentHistory(historyKey.slice(0, historyKey.indexOf(":"))).then(
|
||||
(value) => alive && setInHistory({ key: historyKey, value }),
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [historyKey]);
|
||||
const baseInHistory = !!historyKey && inHistory?.key === historyKey && inHistory.value;
|
||||
const resolve = resolveConflicts({
|
||||
details: pr,
|
||||
remotes,
|
||||
mergeInProgress: !!branch?.operation,
|
||||
alreadyResolvedLocally: checkedOut && baseInHistory,
|
||||
});
|
||||
|
||||
const state = reviewRequestState(pr);
|
||||
return (
|
||||
<>
|
||||
<div className={styles.titlePanel}>
|
||||
<div className={styles.titleRow}>
|
||||
<h2 className={styles.title}>
|
||||
{pr.title}
|
||||
{" "}
|
||||
<a
|
||||
href={pr.url}
|
||||
className={styles.number}
|
||||
{...tooltipAttrs(tx("View in browser"))}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openInBrowser(pr.url);
|
||||
}}
|
||||
>
|
||||
#{pr.number}
|
||||
</a>
|
||||
</h2>
|
||||
{state !== "OPENED" && (
|
||||
<StateTag
|
||||
state={state === "DRAFT" ? "OPEN" : pr.state}
|
||||
isDraft={state === "DRAFT"}
|
||||
className={styles.tag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={styles.link} onClick={() => void openTimeline(number, { focus: true })}>
|
||||
<T>View Timeline</T>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.commitsAndBranches}>
|
||||
<CommitPicker commits={commits} selected={commit ? selected : -1} onSelect={setSelected} />
|
||||
<BranchWidget pr={pr} checkedOut={checkedOut} headRemote={headRemote} />
|
||||
</div>
|
||||
{commit && <CommitInfo key={commit.commit.oid} commit={commit} />}
|
||||
<div className={styles.changes}>
|
||||
{files.value ? (
|
||||
<ChangesTree
|
||||
key={commit?.commit.oid ?? "all"}
|
||||
number={number}
|
||||
files={files.value}
|
||||
commitSha={commit?.commit.oid ?? null}
|
||||
viewedState={viewedState}
|
||||
threads={threads}
|
||||
checkedOut={checkedOut}
|
||||
/>
|
||||
) : files.error ? (
|
||||
<div className={styles.centered}>
|
||||
<div className={styles.error}>
|
||||
<T>Can't load changes</T>
|
||||
</div>
|
||||
<div className={styles.secondary}>{files.error}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => void reloadPullRequestData(number, "changes").catch(() => undefined)}
|
||||
>
|
||||
<T>Retry</T>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.centered}>
|
||||
<Spinner size={14} />
|
||||
<T>Loading…</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StatusSection
|
||||
pr={pr}
|
||||
mergeability={mergeability}
|
||||
reviewers={reviewers}
|
||||
canManage={hasPermission(permission, "TRIAGE") || pr.viewerDidAuthor}
|
||||
busy={busy}
|
||||
resolve={resolve}
|
||||
permission={permission}
|
||||
onResolve={() => {
|
||||
if ("baseRemote" in resolve) void resolveConflictsLocally(number, resolve, checkedOut);
|
||||
}}
|
||||
/>
|
||||
<ReviewActions
|
||||
pr={pr}
|
||||
mergeability={mergeability}
|
||||
reviewers={reviewers}
|
||||
reviewState={reviewState}
|
||||
submitRef={submitRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* The review flow buttons (`GHPRDetailsActionsComponentFactory`): nothing once merged,
|
||||
* "Reopen Pull Request" when closed, "Post Pull Request" for a draft; an open pull request
|
||||
* gets its role's buttons — Request / Re-request Review…, Submit Review…, Set Myself as
|
||||
* Reviewer, the Merge split button — and the More (⋯) menu. Every action is disabled while
|
||||
* another one runs; managing needs triage access or authorship, merging the rules of
|
||||
* `pullRequestPermissions`.
|
||||
*/
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Button, Icon, IconButton, Spinner, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { GHPRMergeabilityState, GHPullRequest, GHPullRequestRequestedReviewer } from "../api/types";
|
||||
import * as ops from "../data/operations";
|
||||
import { isTeam, pullRequestPermissions, reviewerKey, type ReviewerWithState } from "../data/model";
|
||||
import { useGhRepository, usePotentialReviewers } from "../data/repository";
|
||||
import { usePullRequestData } from "../data/pullRequest";
|
||||
import { ChooserPopup } from "../ui/ChooserPopup";
|
||||
import { adjustReviewers, merge, reRequestReview, runFlow, useFlowBusy } from "./flow";
|
||||
import {
|
||||
dedupe,
|
||||
MERGE_METHOD_LABEL,
|
||||
mergeButtonMethods,
|
||||
openedActions,
|
||||
reviewRequestState,
|
||||
reviewRole,
|
||||
type MergeMethod,
|
||||
type ReviewState,
|
||||
} from "./model";
|
||||
import { showSubmitReview } from "./SubmitReviewPopup";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
export function ReviewActions(props: {
|
||||
pr: GHPullRequest;
|
||||
mergeability: GHPRMergeabilityState | undefined;
|
||||
reviewers: readonly ReviewerWithState[];
|
||||
reviewState: ReviewState;
|
||||
/** The Submit Review… button, for the `submitReview` handler to anchor its popup. */
|
||||
submitRef: RefObject<HTMLButtonElement | null>;
|
||||
}) {
|
||||
const { pr, mergeability, reviewers, reviewState, submitRef } = props;
|
||||
const tx = useTx();
|
||||
const n = pr.number;
|
||||
const connection = useGhRepository((s) => s.connection);
|
||||
const progress = useFlowBusy((s) => s.busy[n]);
|
||||
const busy = progress !== undefined;
|
||||
const pending = usePullRequestData(n, "pendingReview").value;
|
||||
const more = useRef<HTMLButtonElement>(null);
|
||||
const [picker, setPicker] = useState<Element | null>(null);
|
||||
if (!connection) return null;
|
||||
const me = connection.currentUser;
|
||||
const repo = connection.repository;
|
||||
const perms = pullRequestPermissions(pr, repo, mergeability ?? null);
|
||||
const canManage = perms.canManage && !busy;
|
||||
const enabled: Record<MergeMethod, boolean> = {
|
||||
merge: perms.canMerge && !busy,
|
||||
squash: perms.canSquashMerge && !busy,
|
||||
rebase: perms.canRebaseMerge && !busy,
|
||||
};
|
||||
const requested = pr.reviewRequests.nodes.flatMap((r) => (r.requestedReviewer ? [r.requestedReviewer] : []));
|
||||
const requestState = reviewRequestState(pr);
|
||||
|
||||
const progressText = progress ? (
|
||||
<span className={styles.progressText}>
|
||||
<Spinner size={12} />
|
||||
{progress}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (requestState === "MERGED") return null;
|
||||
if (requestState === "CLOSED")
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={!canManage} onClick={() => void runFlow(n, () => ops.reopenPullRequest(n))}>
|
||||
Reopen Pull Request
|
||||
</Button>
|
||||
{progressText}
|
||||
</div>
|
||||
);
|
||||
if (requestState === "DRAFT")
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={!canManage} onClick={() => void runFlow(n, () => ops.markReadyForReview(n))}>
|
||||
Post Pull Request
|
||||
</Button>
|
||||
{progressText}
|
||||
</div>
|
||||
);
|
||||
|
||||
const role = reviewRole(pr, me);
|
||||
const myState = reviewers.find((r) => !isTeam(r.reviewer) && r.reviewer.id === me.id)?.state ?? null;
|
||||
const shown = openedActions(role, reviewState, requested.length, myState);
|
||||
const comments = pending?.commentsCount ?? 0;
|
||||
const methods = mergeButtonMethods(repo);
|
||||
|
||||
const mergeItems = (): MenuItem[] =>
|
||||
(["merge", "squash", "rebase"] as const).map((m) => ({
|
||||
label: MERGE_METHOD_LABEL[m],
|
||||
disabled: !enabled[m],
|
||||
run: () => void merge(n, m),
|
||||
}));
|
||||
const showMore = () => {
|
||||
const r = more.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const items: MenuItem[] = shown.more.map((item): MenuItem => {
|
||||
if (item === "requestReview")
|
||||
return { label: "Request Review…", disabled: !canManage, run: () => setPicker(more.current) };
|
||||
if (item === "mergeGroup") return { label: "Merge Review…", submenu: mergeItems() };
|
||||
return {
|
||||
label: "Close Pull Request",
|
||||
disabled: !canManage,
|
||||
run: () => void runFlow(n, () => ops.closePullRequest(n)),
|
||||
};
|
||||
});
|
||||
showContextMenu({ x: r.left, y: r.bottom + 8 }, items);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
{shown.requestReview && (
|
||||
<Button disabled={!canManage} onClick={(e) => setPicker(e.currentTarget)}>
|
||||
Request Review…
|
||||
</Button>
|
||||
)}
|
||||
{shown.reRequestReview && (
|
||||
<Button disabled={!canManage} onClick={() => void reRequestReview(n, requested, reviewers)}>
|
||||
Re-request Review…
|
||||
</Button>
|
||||
)}
|
||||
{shown.submitReview && (
|
||||
<Button ref={submitRef} onClick={(e) => void showSubmitReview(n, e.currentTarget)}>
|
||||
{comments > 0 ? tx`Submit Review (${comments})…` : tx("Submit Review…")}
|
||||
</Button>
|
||||
)}
|
||||
{shown.setMyselfAsReviewer && (
|
||||
<Button disabled={!canManage} onClick={() => void adjustReviewers(n, requested, [...requested, me])}>
|
||||
Set Myself as Reviewer
|
||||
</Button>
|
||||
)}
|
||||
{shown.merge && (
|
||||
<span className={styles.optionButton}>
|
||||
<Button
|
||||
className={styles.optionMain}
|
||||
disabled={!enabled[methods.main]}
|
||||
onClick={() => void merge(n, methods.main)}
|
||||
>
|
||||
{MERGE_METHOD_LABEL[methods.main]}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optionArrow}
|
||||
aria-label={tx("More merge options")}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.parentElement!.getBoundingClientRect();
|
||||
showContextMenu(
|
||||
{ x: r.left, y: r.bottom + 2 },
|
||||
methods.others.map((m) => ({
|
||||
label: MERGE_METHOD_LABEL[m],
|
||||
disabled: !enabled[m],
|
||||
run: () => void merge(n, m),
|
||||
})),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<IconButton ref={more} icon="more-horizontal" title="More Actions" className={styles.more} onClick={showMore} />
|
||||
{progressText}
|
||||
{picker && (
|
||||
<ReviewerPicker
|
||||
anchor={picker}
|
||||
pr={pr}
|
||||
current={dedupe([...requested, ...reviewers.map((r) => r.reviewer)])}
|
||||
onClose={(chosen) => {
|
||||
setPicker(null);
|
||||
void adjustReviewers(n, dedupe([...requested, ...reviewers.map((r) => r.reviewer)]), chosen);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `showMultipleChooserPopupWithIncrementalLoading` of the potential reviewers (the author excluded), above the button. */
|
||||
function ReviewerPicker(props: {
|
||||
anchor: Element;
|
||||
pr: GHPullRequest;
|
||||
current: GHPullRequestRequestedReviewer[];
|
||||
onClose: (chosen: GHPullRequestRequestedReviewer[]) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const candidates = usePotentialReviewers();
|
||||
const authorId = props.pr.author?.id;
|
||||
const items = candidates.items.filter((r) => isTeam(r) || r.id !== authorId);
|
||||
return (
|
||||
<ChooserPopup
|
||||
mode="multi"
|
||||
anchor={props.anchor}
|
||||
placement="top-start"
|
||||
items={items}
|
||||
loading={candidates.loading}
|
||||
error={candidates.error}
|
||||
chosen={props.current}
|
||||
getKey={reviewerKey}
|
||||
present={(r) =>
|
||||
isTeam(r)
|
||||
? { text: r.combinedSlug, detail: r.teamName, avatarUrl: r.teamAvatarUrl }
|
||||
: { text: r.login, detail: r.name, avatarUrl: r.avatarUrl }
|
||||
}
|
||||
onClose={props.onClose}
|
||||
aria-label={tx("Reviewers")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* The status section (`GHPRStatusChecksComponentFactory`): "Loading pull request status"
|
||||
* until the mergeability loads, then the access line, the CI line with its "Details…"
|
||||
* chooser, the conflicts line with Resolve, the required reviews and restriction lines,
|
||||
* "Missing reviewer" and a row per reviewer (right-click: Re-request / Remove for users
|
||||
* who can manage the pull request). At most 149px high, scrolling.
|
||||
*/
|
||||
import { useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Icon, showContextMenu, T, tooltipAttrs, type MenuItem } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { CodeReviewCIJob, GHPRMergeabilityState, GHPullRequest } from "../api/types";
|
||||
import { openInBrowser } from "../data/local";
|
||||
import { reviewerKey, type ReviewerWithState } from "../data/model";
|
||||
import { Avatar, AVATAR_SIZE, reviewerRingColor } from "../ui/Avatar";
|
||||
import { ChooserPopup } from "../ui/ChooserPopup";
|
||||
import { adjustReviewers } from "./flow";
|
||||
import {
|
||||
accessDeniedText,
|
||||
ciSummary,
|
||||
isAwaitingReview,
|
||||
JOB_ICON,
|
||||
presentableName,
|
||||
reviewerStatusIcon,
|
||||
reviewerStatusText,
|
||||
STATUS_ICON,
|
||||
type ResolveConflicts,
|
||||
type StatusIcon,
|
||||
} from "./model";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
const StatusGlyph = ({ icon }: { icon: StatusIcon }) => (
|
||||
<Icon name={STATUS_ICON[icon]} size={16} className={styles.statusIcon} />
|
||||
);
|
||||
|
||||
function StatusLine({ icon, children }: { icon: StatusIcon; children: ReactNode }) {
|
||||
return (
|
||||
<div className={styles.statusRow}>
|
||||
<StatusGlyph icon={icon} />
|
||||
<span className={styles.statusText}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusSection(props: {
|
||||
pr: GHPullRequest;
|
||||
mergeability: GHPRMergeabilityState | undefined;
|
||||
reviewers: readonly ReviewerWithState[];
|
||||
canManage: boolean;
|
||||
busy: boolean;
|
||||
resolve: ResolveConflicts;
|
||||
onResolve: () => void;
|
||||
permission: Parameters<typeof accessDeniedText>[0];
|
||||
}) {
|
||||
const { pr, mergeability, reviewers } = props;
|
||||
const tx = useTx();
|
||||
const access = accessDeniedText(props.permission, pr.viewerDidAuthor, pr.isDraft);
|
||||
const accessLine = access && (
|
||||
<div className={styles.statusRow}>
|
||||
<StatusGlyph icon="error" />
|
||||
<span className={styles.statusText}>
|
||||
<T>{access}</T>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (!mergeability)
|
||||
return (
|
||||
<div className={styles.status}>
|
||||
<StatusLine icon="pending">
|
||||
<T>Loading pull request status</T>
|
||||
</StatusLine>
|
||||
{accessLine}
|
||||
</div>
|
||||
);
|
||||
const required = mergeability.requiredApprovingReviewsCount;
|
||||
return (
|
||||
<div className={styles.status}>
|
||||
{accessLine}
|
||||
<CiLine jobs={mergeability.ciJobs} />
|
||||
<ConflictsLine
|
||||
hasConflicts={mergeability.hasConflicts}
|
||||
resolve={props.resolve}
|
||||
busy={props.busy}
|
||||
onResolve={props.onResolve}
|
||||
/>
|
||||
{required > 0 && !pr.isDraft && (
|
||||
<StatusLine icon="failed">
|
||||
{tx`At least ${required} approving review is required by reviewers with write access`}
|
||||
</StatusLine>
|
||||
)}
|
||||
{mergeability.isRestricted && !pr.isDraft && (
|
||||
<StatusLine icon="failed">
|
||||
<T>You are not authorized to merge request into this branch</T>
|
||||
</StatusLine>
|
||||
)}
|
||||
{!reviewers.length && (
|
||||
<StatusLine icon="warning">
|
||||
<T>Missing reviewer</T>
|
||||
</StatusLine>
|
||||
)}
|
||||
{reviewers.map((r) => (
|
||||
<ReviewerRow key={reviewerKey(r.reviewer)} pr={pr} entry={r} canManage={props.canManage} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CiLine({ jobs }: { jobs: readonly CodeReviewCIJob[] }) {
|
||||
const tx = useTx();
|
||||
const link = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = ciSummary(jobs);
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<div className={styles.statusRow}>
|
||||
<StatusGlyph icon={summary.icon} />
|
||||
<span className={styles.statusText}>
|
||||
<T>{summary.text}</T>
|
||||
</span>
|
||||
<button ref={link} type="button" className={styles.link} onClick={() => setOpen(true)}>
|
||||
<T>Details…</T>
|
||||
</button>
|
||||
{open && (
|
||||
<ChooserPopup
|
||||
mode="single"
|
||||
anchor={link}
|
||||
placement="top-start"
|
||||
items={jobs}
|
||||
getKey={(j) => `${j.name}\u0000${j.detailsUrl ?? ""}`}
|
||||
present={(j) => ({ text: j.name, icon: STATUS_ICON[JOB_ICON[j.status]] })}
|
||||
onChoose={(j) => j.detailsUrl && void openInBrowser(j.detailsUrl)}
|
||||
onClose={() => setOpen(false)}
|
||||
height={Math.min(300, 40 + jobs.length * 28)}
|
||||
aria-label={tx("Open in Browser")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictsLine(props: {
|
||||
hasConflicts: boolean | null;
|
||||
resolve: ResolveConflicts;
|
||||
busy: boolean;
|
||||
onResolve: () => void;
|
||||
}) {
|
||||
const { hasConflicts, resolve } = props;
|
||||
// Shown while GitHub is still checking, hidden once it found none.
|
||||
if (hasConflicts === false) return null;
|
||||
const error = "error" in resolve ? resolve.error : null;
|
||||
return (
|
||||
<div className={styles.statusRow}>
|
||||
<StatusGlyph icon={hasConflicts === null ? "pending" : "failed"} />
|
||||
<span className={styles.statusText}>
|
||||
<T>{hasConflicts === null ? "Checking branch for conflicts" : "Branch has conflicts that must be resolved"}</T>
|
||||
</span>
|
||||
{hasConflicts && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
disabled={props.busy || !!error}
|
||||
onClick={props.onResolve}
|
||||
{...(error ? tooltipAttrs(error) : {})}
|
||||
>
|
||||
<T>Resolve</T>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewerRow({ pr, entry, canManage }: { pr: GHPullRequest; entry: ReviewerWithState; canManage: boolean }) {
|
||||
const { reviewer, state } = entry;
|
||||
const tx = useTx();
|
||||
const number = pr.number;
|
||||
const name = presentableName(reviewer);
|
||||
const requested = pr.reviewRequests.nodes.flatMap((r) => (r.requestedReviewer ? [r.requestedReviewer] : []));
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
if (!canManage) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items: MenuItem[] = [
|
||||
...(!isAwaitingReview(state)
|
||||
? [
|
||||
{
|
||||
label: tx`Re-request Review from ${name}`,
|
||||
verbatim: true,
|
||||
run: () => void adjustReviewers(number, requested, [...requested, reviewer]),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: tx`Remove ${name} from reviewers`,
|
||||
verbatim: true,
|
||||
run: () =>
|
||||
void adjustReviewers(
|
||||
number,
|
||||
requested,
|
||||
requested.filter((r) => reviewerKey(r) !== reviewerKey(reviewer)),
|
||||
),
|
||||
},
|
||||
];
|
||||
showContextMenu(e, items);
|
||||
};
|
||||
const avatarUrl = "teamAvatarUrl" in reviewer ? reviewer.teamAvatarUrl : reviewer.avatarUrl;
|
||||
return (
|
||||
<div className={clsx(styles.statusRow, styles.reviewerRow)} onContextMenu={onContextMenu}>
|
||||
<Icon name={reviewerStatusIcon(state)} size={16} className={styles.statusIcon} />
|
||||
<Avatar url={avatarUrl} name={name} size={AVATAR_SIZE.OUTLINED} ring={reviewerRingColor(state)} title={null} />
|
||||
<span className={styles.statusText}>{reviewerStatusText(state, name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The Submit Review popup (`GHPRSubmitReviewPopup`, `CodeReviewSubmitPopupHandler`): bold
|
||||
* "Submit Review" with the pending comment count, the discard (pending review with
|
||||
* comments) and close buttons, the review comment editor, the error, then Approve /
|
||||
* Request Changes (not for the author) and Comment. Shown above the button that opened
|
||||
* it, or centred in the window. 500×200, resizable.
|
||||
*/
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { Button, IconButton, Popup, Spinner, T } from "@/ui";
|
||||
import { useTx, tx } from "@/platform/i18n";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { errorMessage } from "../api/client";
|
||||
import type { GHPullRequestReviewEvent } from "../api/types";
|
||||
import { deleteReview, submitOrCreateReview } from "../data/operations";
|
||||
import { loadPullRequestData, usePullRequestData } from "../data/pullRequest";
|
||||
import { openDialog } from "../ui/dialogs";
|
||||
import { setReviewText, useReviewText } from "./state";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
/** Opens the popup for pull request `number`; resolves when it closes. */
|
||||
export function showSubmitReview(number: number, anchor?: Element | null): Promise<unknown> {
|
||||
// The details and the pending review are needed for the buttons and the header.
|
||||
void loadPullRequestData(number, "details").catch(() => undefined);
|
||||
void loadPullRequestData(number, "pendingReview").catch(() => undefined);
|
||||
const at = anchor?.isConnected
|
||||
? anchor
|
||||
: { x: Math.round(window.innerWidth / 2) - 250, y: Math.round(window.innerHeight / 3) };
|
||||
return openDialog<true>((done) => <SubmitReview number={number} anchor={at} onClose={() => done(null)} />);
|
||||
}
|
||||
|
||||
function SubmitReview(props: { number: number; anchor: Element | { x: number; y: number }; onClose: () => void }) {
|
||||
const { number } = props;
|
||||
const t = useTx();
|
||||
const details = usePullRequestData(number, "details").value;
|
||||
const pending = usePullRequestData(number, "pendingReview").value;
|
||||
const text = useReviewText((s) => s.text[number] ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const comments = pending?.commentsCount ?? 0;
|
||||
const hasAnyComment = text.length > 0 || comments > 0;
|
||||
const isAuthor = details?.viewerDidAuthor ?? false;
|
||||
|
||||
const submit = async (event: GHPullRequestReviewEvent) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submitOrCreateReview(number, event, text);
|
||||
setReviewText(number, "");
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const discard = async () => {
|
||||
if (!pending) return;
|
||||
const r = await ask({
|
||||
title: tx("Discard Pending Review"),
|
||||
body: tx("Are you sure you want to delete all pending comments?"),
|
||||
buttons: [
|
||||
{ id: "yes", label: "Yes", primary: true },
|
||||
{ id: "no", label: "No" },
|
||||
],
|
||||
cancel: false,
|
||||
});
|
||||
if (r.button !== "yes") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteReview(number, pending.id);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const anchored = "getBoundingClientRect" in props.anchor;
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
placement={anchored ? "top-start" : "bottom-start"}
|
||||
onClose={(reason) => {
|
||||
// A click outside does not drop a review while it is being sent.
|
||||
if (reason !== "outside" || !busy) props.onClose();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label={t("Submit Review")}
|
||||
className={styles.submitPopup}
|
||||
style={{ "--submit-width": "500px", "--submit-height": "200px" } as CSSProperties}
|
||||
>
|
||||
<div className={styles.submitHeader}>
|
||||
<span className={styles.bold}>
|
||||
<T>Submit Review</T>
|
||||
</span>
|
||||
{comments > 0 && (
|
||||
<span>{comments === 1 ? t`${comments} pending comment` : t`${comments} pending comments`}</span>
|
||||
)}
|
||||
<span className={styles.flex} />
|
||||
{pending && comments > 0 && (
|
||||
<IconButton icon="trash" size="small" title="Discard pending comments" disabled={busy} onClick={discard} />
|
||||
)}
|
||||
<IconButton icon="close" size="small" title="Close" onClick={props.onClose} />
|
||||
</div>
|
||||
<textarea
|
||||
data-autofocus
|
||||
className={styles.submitEditor}
|
||||
placeholder={t("Review comment")}
|
||||
aria-label={t("Review comment")}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setError(null);
|
||||
setReviewText(number, e.target.value);
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<div className={styles.submitError}>
|
||||
<T>Failed to submit review</T>
|
||||
<div className={styles.secondary}>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.submitButtons}>
|
||||
{!isAuthor && (
|
||||
<>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit("APPROVE")}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button disabled={busy || !hasAnyComment} onClick={() => void submit("REQUEST_CHANGES")}>
|
||||
Request Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
disabled={busy || !hasAnyComment}
|
||||
title="Submit general feedback without explicit approval"
|
||||
onClick={() => void submit("COMMENT")}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
{busy && <Spinner size={14} />}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import type { GHPRMergeabilityState, GHPullRequest, GHRepository, GHUser } from "../api/types";
|
||||
import { reviewersStates } from "../data/model";
|
||||
import { clearPullRequests, setPullRequestData } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { ReviewActions } from "./ReviewActions";
|
||||
import { StatusSection } from "./StatusSection";
|
||||
|
||||
const me: GHUser = { __typename: "User", id: "U1", login: "me", url: "", avatarUrl: "", name: null };
|
||||
const bob: GHUser = { __typename: "User", id: "U2", login: "bob", url: "", avatarUrl: "", name: "Bob" };
|
||||
const repository: GHRepository = {
|
||||
id: "R1",
|
||||
owner: { __typename: "User", id: "O1", login: "org" },
|
||||
nameWithOwner: "org/app",
|
||||
viewerPermission: "WRITE",
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
defaultBranchRef: { name: "main" },
|
||||
isFork: false,
|
||||
url: "https://github.com/org/app",
|
||||
sshUrl: "",
|
||||
};
|
||||
const mergeability: GHPRMergeabilityState = {
|
||||
hasConflicts: true,
|
||||
ciJobs: [{ name: "build", status: "PENDING", isRequired: true, detailsUrl: null }],
|
||||
canBeMerged: true,
|
||||
canBeRebased: true,
|
||||
isRestricted: false,
|
||||
requiredApprovingReviewsCount: 1,
|
||||
checksState: null,
|
||||
mergeable: "CONFLICTING",
|
||||
mergeStateStatus: "BLOCKED",
|
||||
};
|
||||
|
||||
function pr(patch: Partial<GHPullRequest> = {}): GHPullRequest {
|
||||
return {
|
||||
id: "PR1",
|
||||
url: "https://github.com/org/app/pull/7",
|
||||
number: 7,
|
||||
title: "Title",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
author: me,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
isReadByViewer: true,
|
||||
assignees: { nodes: [] },
|
||||
labels: { nodes: [] },
|
||||
reviewRequests: { nodes: [] },
|
||||
reviewThreads: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
mergeable: "MERGEABLE",
|
||||
viewerCanUpdate: true,
|
||||
viewerCanReact: true,
|
||||
viewerDidAuthor: true,
|
||||
reactions: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: [],
|
||||
},
|
||||
body: "",
|
||||
reviewDecision: null,
|
||||
baseRefName: "main",
|
||||
baseRefOid: "base",
|
||||
baseRepository: repository,
|
||||
headRef: { id: "ref" },
|
||||
headRefName: "feature",
|
||||
headRefOid: "head",
|
||||
headRepository: repository,
|
||||
viewerCanDeleteHeadRef: false,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useGhRepository.setState({
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me },
|
||||
});
|
||||
setPullRequestData(7, "pendingReview", () => null);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearPullRequests();
|
||||
useGhRepository.setState({ connection: null });
|
||||
});
|
||||
|
||||
describe("StatusSection", () => {
|
||||
it("shows the loading line until the mergeability loads", () => {
|
||||
render(
|
||||
<StatusSection
|
||||
pr={pr()}
|
||||
mergeability={undefined}
|
||||
reviewers={[]}
|
||||
canManage
|
||||
busy={false}
|
||||
resolve={{ error: "Details have not finished loading" }}
|
||||
onResolve={() => {}}
|
||||
permission="WRITE"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Loading pull request status")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists checks, conflicts, required reviews and reviewers", () => {
|
||||
const p = pr({ reviewRequests: { nodes: [{ requestedReviewer: bob }] } });
|
||||
render(
|
||||
<StatusSection
|
||||
pr={p}
|
||||
mergeability={mergeability}
|
||||
reviewers={reviewersStates(p)}
|
||||
canManage
|
||||
busy={false}
|
||||
resolve={{ error: "Merge is already in progress" }}
|
||||
onResolve={() => {}}
|
||||
permission="READ"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Repository write access required to merge pull requests")).toBeTruthy();
|
||||
expect(screen.getByText("Checks in progress…")).toBeTruthy();
|
||||
expect(screen.getByText("Details…")).toBeTruthy();
|
||||
expect(screen.getByText("Branch has conflicts that must be resolved")).toBeTruthy();
|
||||
expect((screen.getByText("Resolve").closest("button") as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText("At least 1 approving review is required by reviewers with write access")).toBeTruthy();
|
||||
expect(screen.getByText("Waiting for review from Bob")).toBeTruthy();
|
||||
expect(screen.queryByText("Missing reviewer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReviewActions", () => {
|
||||
const actions = (p: GHPullRequest, state: "ACCEPTED" | "NEED_REVIEW" = "NEED_REVIEW") =>
|
||||
render(
|
||||
<ReviewActions
|
||||
pr={p}
|
||||
mergeability={{ ...mergeability, hasConflicts: false }}
|
||||
reviewers={reviewersStates(p)}
|
||||
reviewState={state}
|
||||
submitRef={createRef()}
|
||||
/>,
|
||||
);
|
||||
|
||||
it("offers Reopen, Post and nothing for closed, draft and merged pull requests", () => {
|
||||
actions(pr({ state: "CLOSED" }));
|
||||
expect(screen.getByText("Reopen Pull Request")).toBeTruthy();
|
||||
cleanup();
|
||||
actions(pr({ isDraft: true }));
|
||||
expect(screen.getByText("Post Pull Request")).toBeTruthy();
|
||||
cleanup();
|
||||
const { container } = actions(pr({ state: "MERGED" }));
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("shows the author's Request Review… and, once accepted, the merge button", () => {
|
||||
actions(pr());
|
||||
expect(screen.getByText("Request Review…")).toBeTruthy();
|
||||
expect(screen.queryByText("Squash and Merge…")).toBeNull();
|
||||
cleanup();
|
||||
actions(pr(), "ACCEPTED");
|
||||
expect(screen.queryByText("Request Review…")).toBeNull();
|
||||
// Merge commits are not allowed in this repository: squash is the main action.
|
||||
expect(screen.getByText("Squash and Merge…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Submit Review… to reviewers and Set Myself as Reviewer to guests", () => {
|
||||
const asReviewer = pr({
|
||||
author: bob,
|
||||
viewerDidAuthor: false,
|
||||
reviewRequests: { nodes: [{ requestedReviewer: me }] },
|
||||
});
|
||||
actions(asReviewer);
|
||||
expect(screen.getByText("Submit Review…")).toBeTruthy();
|
||||
cleanup();
|
||||
actions(pr({ author: bob, viewerDidAuthor: false }));
|
||||
expect(screen.getByText("Set Myself as Reviewer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* The details tab's operations (`GHPRReviewFlowViewModelImpl`, `GHPRBranchesViewModel`,
|
||||
* `GHPRResolveConflictsLocallyViewModel`): one review-flow action runs at a time per pull
|
||||
* request (`isBusy` disables the others), mutations go through `data/operations.ts`, local
|
||||
* git through `data/local.ts` and the vcs feature. Failures are reported as notifications.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import type { DiffData } from "@/features/vcs/diffInput";
|
||||
import { mergeIntoCurrent } from "@/features/vcs/branchOps";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { errorMessage } from "../api/client";
|
||||
import type { GHCommitFile, GHPullRequest, GHPullRequestRequestedReviewer } from "../api/types";
|
||||
import { mergeCommitMessage, splitCommitMessage, type ReviewerWithState } from "../data/model";
|
||||
import { checkoutPullRequest, fileDiffData, prepareDiff } from "../data/local";
|
||||
import * as ops from "../data/operations";
|
||||
import { loadPullRequestData } from "../data/pullRequest";
|
||||
import { mergeCommitMessageDialog } from "../ui/dialogs";
|
||||
import { reviewersDelta, type MergeMethod } from "./model";
|
||||
|
||||
/** Per pull request: the running review-flow action's progress text (empty string: no text). */
|
||||
export const useFlowBusy = create<{ busy: Record<number, string> }>(() => ({ busy: {} }));
|
||||
|
||||
function setBusy(number: number, text: string | null) {
|
||||
useFlowBusy.setState((s) => {
|
||||
const busy = { ...s.busy };
|
||||
if (text === null) delete busy[number];
|
||||
else busy[number] = text;
|
||||
return { busy };
|
||||
});
|
||||
}
|
||||
|
||||
export const isFlowBusy = (number: number) => useFlowBusy.getState().busy[number] !== undefined;
|
||||
|
||||
function reportError(title: string, e: unknown) {
|
||||
notifications().notify({ group: "vcs", level: "error", title, body: errorMessage(e), key: "github-details" });
|
||||
}
|
||||
|
||||
/** `SingleCoroutineLauncher`: runs `action` unless another one of the pull request runs. */
|
||||
export async function runFlow(number: number, action: () => Promise<unknown>, progress = ""): Promise<void> {
|
||||
if (isFlowBusy(number)) return;
|
||||
setBusy(number, progress);
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
reportError(tx("Pull request action failed"), e);
|
||||
} finally {
|
||||
setBusy(number, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** `adjustReviewers` with IntelliJ's progress text. */
|
||||
export function adjustReviewers(
|
||||
number: number,
|
||||
current: readonly GHPullRequestRequestedReviewer[],
|
||||
chosen: readonly GHPullRequestRequestedReviewer[],
|
||||
) {
|
||||
const d = reviewersDelta(current, chosen);
|
||||
if (!d.added.length && !d.removed.length) return Promise.resolve();
|
||||
return runFlow(number, () => ops.adjustReviewers(number, d), tx("Adjusting reviewers…"));
|
||||
}
|
||||
|
||||
/** "Re-request Review…": requests everyone who reviewed again. */
|
||||
export function reRequestReview(
|
||||
number: number,
|
||||
requested: readonly GHPullRequestRequestedReviewer[],
|
||||
reviewers: readonly ReviewerWithState[],
|
||||
) {
|
||||
return adjustReviewers(number, requested, [...requested, ...reviewers.map((r) => r.reviewer)]);
|
||||
}
|
||||
|
||||
/** Merge… / Squash and Merge… (after the commit message dialog) / Rebase. */
|
||||
export function merge(number: number, method: MergeMethod) {
|
||||
return runFlow(number, async () => {
|
||||
const pr = await loadPullRequestData(number, "details");
|
||||
if (method === "rebase") return ops.mergePullRequest(number, "rebase", pr.headRefOid);
|
||||
const headlines =
|
||||
method === "squash"
|
||||
? (await loadPullRequestData(number, "commits")).map((c) => c.commit.messageHeadline)
|
||||
: undefined;
|
||||
const message = await mergeCommitMessageDialog({
|
||||
title: method === "squash" ? "Squash and Merge Review" : "Merge Review",
|
||||
message: mergeCommitMessage(pr, headlines),
|
||||
});
|
||||
if (message === null) return;
|
||||
await ops.mergePullRequest(number, method, pr.headRefOid, splitCommitMessage(message));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branches
|
||||
|
||||
/** "Show in Git Log": fetches the pull request, then shows its head in a log tab of its own. */
|
||||
export async function showInGitLog(pr: GHPullRequest, headRemote: string | null) {
|
||||
const revs = await prepareDiff(pr);
|
||||
if (!revs) return;
|
||||
const tracked = headRemote ? `${headRemote}/${pr.headRefName}` : null;
|
||||
const branches = tracked ? await call(commands.vcsBranches(null)).catch(() => []) : [];
|
||||
const ref = tracked && branches.some((b) => b.remote && b.name === tracked) ? tracked : revs.head;
|
||||
await executeAction("vcs.showLog", { branch: ref });
|
||||
}
|
||||
|
||||
/** "Copy Branch Name". */
|
||||
export async function copyBranchName(name: string) {
|
||||
await navigator.clipboard?.writeText(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Resolve": checks the head branch out (fetching what it needs) and merges the base
|
||||
* branch into it, which stops on the conflicts for the user to resolve and push.
|
||||
*/
|
||||
export function resolveConflictsLocally(
|
||||
number: number,
|
||||
target: { baseRemote: string; baseRefName: string },
|
||||
isCheckedOut: boolean,
|
||||
) {
|
||||
return runFlow(number, async () => {
|
||||
const pr = await loadPullRequestData(number, "details");
|
||||
// Fetches the base branch as well (updating `<remote>/<base>`).
|
||||
if (!(await prepareDiff(pr))) return;
|
||||
if (!isCheckedOut && !(await checkoutPullRequest(number))) return;
|
||||
await mergeIntoCurrent(`${target.baseRemote}/${target.baseRefName}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether `oid` is in the history of HEAD (`isInCurrentHistory`); false when unknown. */
|
||||
export async function isInCurrentHistory(oid: string): Promise<boolean> {
|
||||
try {
|
||||
const commits = await call(
|
||||
commands.vcsLog(null, {
|
||||
branch: null,
|
||||
path: null,
|
||||
author: null,
|
||||
text: null,
|
||||
range: `HEAD..${oid}`,
|
||||
skip: 0,
|
||||
limit: 1,
|
||||
}),
|
||||
);
|
||||
return commits.length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diffs of a single commit
|
||||
|
||||
/** Opens a file of commit `sha` (its parent ↔ it) in the diff viewer. */
|
||||
export async function openCommitFileDiff(number: number, sha: string, file: GHCommitFile) {
|
||||
const pr = await loadPullRequestData(number, "details");
|
||||
const revs = await prepareDiff(pr);
|
||||
if (!revs) return;
|
||||
const data: DiffData = fileDiffData(pr, file, { ...revs, base: `${sha}^`, head: sha, files: [] });
|
||||
const short = sha.slice(0, 8);
|
||||
data.left = data.left.type === "rev" ? { ...data.left, label: `${short}^` } : data.left;
|
||||
data.right = data.right.type === "rev" ? { ...data.right, label: short } : data.right;
|
||||
data.context = { ...data.context!, commit: sha };
|
||||
await executeAction("vcs.openDiff", data);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CodeReviewCIJob,
|
||||
GHCommitFile,
|
||||
GHPullRequest,
|
||||
GHPullRequestReviewThread,
|
||||
GHRepository,
|
||||
GHTeam,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import type { GHRemote } from "../data/repository";
|
||||
import {
|
||||
accessDeniedText,
|
||||
changeKind,
|
||||
changesTree,
|
||||
ciSummary,
|
||||
commitsTitle,
|
||||
headBranchLabel,
|
||||
isHeadCheckedOut,
|
||||
isViewed,
|
||||
mergeButtonMethods,
|
||||
openedActions,
|
||||
overallReviewState,
|
||||
presentableName,
|
||||
progressOf,
|
||||
resolveConflicts,
|
||||
reviewersDelta,
|
||||
reviewerStatusIcon,
|
||||
reviewerStatusText,
|
||||
reviewRequestState,
|
||||
reviewRole,
|
||||
selectedFiles,
|
||||
stepCommit,
|
||||
unresolvedThreadsByPath,
|
||||
viewedToggleText,
|
||||
} from "./model";
|
||||
|
||||
const user = (login: string, name: string | null = null): GHUser => ({
|
||||
__typename: "User",
|
||||
id: `id-${login}`,
|
||||
login,
|
||||
url: `https://github.com/${login}`,
|
||||
avatarUrl: "",
|
||||
name,
|
||||
});
|
||||
const team: GHTeam = {
|
||||
__typename: "Team",
|
||||
id: "T1",
|
||||
slug: "core",
|
||||
url: "",
|
||||
teamAvatarUrl: null,
|
||||
teamName: "Core",
|
||||
combinedSlug: "org/core",
|
||||
};
|
||||
const repo = (nameWithOwner: string, isFork = false): GHRepository => ({
|
||||
id: nameWithOwner,
|
||||
owner: { __typename: "User", id: "o", login: nameWithOwner.split("/")[0]! },
|
||||
nameWithOwner,
|
||||
viewerPermission: "WRITE",
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
defaultBranchRef: { name: "main" },
|
||||
isFork,
|
||||
url: `https://github.com/${nameWithOwner}`,
|
||||
sshUrl: "",
|
||||
});
|
||||
const file = (filename: string, status: GHCommitFile["status"] = "modified"): GHCommitFile => ({
|
||||
sha: null,
|
||||
filename,
|
||||
status,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
changes: 1,
|
||||
patch: null,
|
||||
previousFilename: null,
|
||||
});
|
||||
const thread = (path: string, isResolved = false) => ({ path, isResolved }) as GHPullRequestReviewThread;
|
||||
const remote = (owner: string, name: string, r: string): GHRemote => ({ owner, name, remote: r, url: "" });
|
||||
|
||||
function pr(patch: Partial<GHPullRequest> = {}): GHPullRequest {
|
||||
return {
|
||||
id: "PR1",
|
||||
url: "https://github.com/o/r/pull/7",
|
||||
number: 7,
|
||||
title: "Title",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
author: user("alice"),
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
isReadByViewer: true,
|
||||
assignees: { nodes: [] },
|
||||
labels: { nodes: [] },
|
||||
reviewRequests: { nodes: [] },
|
||||
reviewThreads: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
mergeable: "MERGEABLE",
|
||||
viewerCanUpdate: true,
|
||||
viewerCanReact: true,
|
||||
viewerDidAuthor: false,
|
||||
reactions: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: [],
|
||||
},
|
||||
body: "",
|
||||
reviewDecision: null,
|
||||
baseRefName: "main",
|
||||
baseRefOid: "base",
|
||||
baseRepository: repo("o/r"),
|
||||
headRef: { id: "ref" },
|
||||
headRefName: "feature",
|
||||
headRefOid: "head",
|
||||
headRepository: repo("o/r"),
|
||||
viewerCanDeleteHeadRef: false,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("review flow", () => {
|
||||
it("derives the request state, a draft first", () => {
|
||||
expect(reviewRequestState(pr())).toBe("OPENED");
|
||||
expect(reviewRequestState(pr({ state: "MERGED" }))).toBe("MERGED");
|
||||
expect(reviewRequestState(pr({ state: "CLOSED" }))).toBe("CLOSED");
|
||||
expect(reviewRequestState(pr({ state: "CLOSED", isDraft: true }))).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("decides the role from authorship, requests and reviews", () => {
|
||||
const bob = user("bob");
|
||||
expect(reviewRole(pr(), user("alice"))).toBe("AUTHOR");
|
||||
expect(reviewRole(pr({ reviewRequests: { nodes: [{ requestedReviewer: bob }] } }), bob)).toBe("REVIEWER");
|
||||
const reviewed = pr({ reviews: { nodes: [{ author: bob } as GHPullRequest["reviews"]["nodes"][0]] } });
|
||||
expect(reviewRole(reviewed, bob)).toBe("REVIEWER");
|
||||
expect(reviewRole(pr(), bob)).toBe("GUEST");
|
||||
});
|
||||
|
||||
it("accepts on enough approvals, else follows the review decision", () => {
|
||||
const approved = [{ reviewer: user("b"), state: "APPROVED" as const }];
|
||||
expect(overallReviewState(pr(), approved, 0)).toBe("ACCEPTED");
|
||||
expect(overallReviewState(pr(), approved, 2)).toBe("NEED_REVIEW");
|
||||
expect(overallReviewState(pr({ reviewDecision: "APPROVED" }), [], 2)).toBe("ACCEPTED");
|
||||
expect(overallReviewState(pr({ reviewDecision: "CHANGES_REQUESTED" }), [], 0)).toBe("WAIT_FOR_UPDATES");
|
||||
expect(overallReviewState(pr({ reviewDecision: "REVIEW_REQUIRED" }), [], 0)).toBe("NEED_REVIEW");
|
||||
});
|
||||
|
||||
it("shows the author's buttons per review state", () => {
|
||||
expect(openedActions("AUTHOR", "NEED_REVIEW", 0, null)).toMatchObject({
|
||||
requestReview: true,
|
||||
reRequestReview: false,
|
||||
merge: false,
|
||||
more: ["mergeGroup", "close"],
|
||||
});
|
||||
expect(openedActions("AUTHOR", "WAIT_FOR_UPDATES", 1, null)).toMatchObject({
|
||||
requestReview: true,
|
||||
reRequestReview: false,
|
||||
});
|
||||
expect(openedActions("AUTHOR", "WAIT_FOR_UPDATES", 0, null)).toMatchObject({
|
||||
requestReview: false,
|
||||
reRequestReview: true,
|
||||
});
|
||||
expect(openedActions("AUTHOR", "ACCEPTED", 0, null)).toMatchObject({
|
||||
requestReview: false,
|
||||
merge: true,
|
||||
more: ["requestReview", "close"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the reviewer's and the guest's buttons", () => {
|
||||
expect(openedActions("REVIEWER", "NEED_REVIEW", 1, "NEEDS_REVIEW")).toMatchObject({
|
||||
submitReview: true,
|
||||
merge: false,
|
||||
more: ["requestReview", "mergeGroup", "close"],
|
||||
});
|
||||
expect(openedActions("REVIEWER", "ACCEPTED", 0, "APPROVED")).toMatchObject({ submitReview: false, merge: true });
|
||||
expect(openedActions("REVIEWER", "ACCEPTED", 1, "RE_REQUESTED").merge).toBe(false);
|
||||
expect(openedActions("GUEST", "ACCEPTED", 0, null)).toMatchObject({
|
||||
setMyselfAsReviewer: true,
|
||||
merge: false,
|
||||
more: ["requestReview", "mergeGroup", "close"],
|
||||
});
|
||||
});
|
||||
|
||||
it("picks the merge button's main method from the repository settings", () => {
|
||||
expect(mergeButtonMethods({ mergeCommitAllowed: true, squashMergeAllowed: true })).toEqual({
|
||||
main: "merge",
|
||||
others: ["squash", "rebase"],
|
||||
});
|
||||
expect(mergeButtonMethods({ mergeCommitAllowed: false, squashMergeAllowed: true }).main).toBe("squash");
|
||||
expect(mergeButtonMethods({ mergeCommitAllowed: false, squashMergeAllowed: false })).toEqual({
|
||||
main: "rebase",
|
||||
others: ["merge", "squash"],
|
||||
});
|
||||
});
|
||||
|
||||
it("computes the reviewer delta by login and team slug", () => {
|
||||
const a = user("a");
|
||||
const b = user("b");
|
||||
expect(reviewersDelta([a, team], [b, { ...team }, b])).toEqual({ added: [b], removed: [a] });
|
||||
});
|
||||
|
||||
it("presents reviewers by name, else login or slug", () => {
|
||||
expect(presentableName(user("a", "Ann"))).toBe("Ann");
|
||||
expect(presentableName(user("a"))).toBe("a");
|
||||
expect(presentableName(team)).toBe("Core");
|
||||
expect(presentableName({ ...team, teamName: "" })).toBe("org/core");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status section", () => {
|
||||
const job = (status: CodeReviewCIJob["status"], isRequired = false): CodeReviewCIJob => ({
|
||||
name: status,
|
||||
status,
|
||||
isRequired,
|
||||
detailsUrl: null,
|
||||
});
|
||||
|
||||
it("summarises the CI jobs", () => {
|
||||
expect(ciSummary([])).toBeNull();
|
||||
expect(ciSummary([job("SUCCESS", true), job("FAILED")])).toEqual({
|
||||
icon: "success",
|
||||
text: "All required checks have passed",
|
||||
});
|
||||
expect(ciSummary([job("PENDING", true), job("FAILED", true)])?.icon).toBe("failedInProgress");
|
||||
expect(ciSummary([job("PENDING", true)])).toEqual({ icon: "pending", text: "Checks in progress…" });
|
||||
expect(ciSummary([job("FAILED", true), job("SKIPPED")])).toEqual({ icon: "failed", text: "Checks failed" });
|
||||
});
|
||||
|
||||
it("explains missing access", () => {
|
||||
expect(accessDeniedText("READ", false, false)).toBe("Repository access required to manage pull requests");
|
||||
expect(accessDeniedText("READ", true, false)).toBe("Repository write access required to merge pull requests");
|
||||
expect(accessDeniedText("READ", true, true)).toBeNull();
|
||||
expect(accessDeniedText("TRIAGE", false, true)).toBe("Repository write access required to merge pull requests");
|
||||
expect(accessDeniedText("WRITE", false, false)).toBeNull();
|
||||
expect(accessDeniedText("WRITE", false, false, true)).toBe("Merging is disabled for this project");
|
||||
});
|
||||
|
||||
it("words and marks reviewer states like GitHub", () => {
|
||||
expect(reviewerStatusText("APPROVED", "Ann")).toBe("Ann approved review");
|
||||
expect(reviewerStatusText("CHANGES_REQUESTED", "Ann")).toBe("Ann is waiting for updates");
|
||||
expect(reviewerStatusText("NEEDS_REVIEW", "Ann")).toBe("Waiting for review from Ann");
|
||||
expect(reviewerStatusText("RE_REQUESTED", "Ann")).toBe("Re-requested review from Ann");
|
||||
expect(reviewerStatusText("COMMENTED", "Ann")).toBe("Ann left comments");
|
||||
expect(reviewerStatusIcon("APPROVED")).toBe("success");
|
||||
expect(reviewerStatusIcon("CHANGES_REQUESTED")).toBe("error");
|
||||
expect(reviewerStatusIcon("RE_REQUESTED")).toBe("warning");
|
||||
expect(reviewerStatusIcon("COMMENTED")).toBe("review-comment");
|
||||
});
|
||||
|
||||
it("decides whether Resolve can run", () => {
|
||||
const remotes = [remote("o", "r", "origin")];
|
||||
const base = { remotes, mergeInProgress: false, alreadyResolvedLocally: false };
|
||||
expect(resolveConflicts({ ...base, details: pr() })).toEqual({
|
||||
headRemote: "origin",
|
||||
baseRemote: "origin",
|
||||
headRefName: "feature",
|
||||
baseRefName: "main",
|
||||
});
|
||||
expect(resolveConflicts({ ...base, details: undefined })).toEqual({ error: "Details have not finished loading" });
|
||||
expect(resolveConflicts({ ...base, details: pr(), mergeInProgress: true })).toEqual({
|
||||
error: "Merge is already in progress",
|
||||
});
|
||||
expect(resolveConflicts({ ...base, details: pr({ headRepository: null }) })).toEqual({
|
||||
error: "Source repository not found",
|
||||
});
|
||||
expect(resolveConflicts({ ...base, details: pr({ headRepository: repo("fork/r", true) }) })).toEqual({
|
||||
error: "Source remote not found: https://github.com/fork/r",
|
||||
});
|
||||
expect(resolveConflicts({ ...base, details: pr(), alreadyResolvedLocally: true })).toEqual({
|
||||
error: "Already resolved locally, push your changes to resolve",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("branches", () => {
|
||||
it("shows owner:branch for forks and equal names", () => {
|
||||
expect(headBranchLabel(pr())).toBe("feature");
|
||||
expect(headBranchLabel(pr({ headRepository: repo("fork/r", true) }))).toBe("fork:feature");
|
||||
expect(headBranchLabel(pr({ headRefName: "main" }))).toBe("o:main");
|
||||
});
|
||||
|
||||
it("knows when the head branch is checked out", () => {
|
||||
const remotes = [remote("o", "r", "origin"), remote("fork", "r", "fork")];
|
||||
const head = (h: string | null, upstream: string | null, headCommit = "x") => ({ head: h, headCommit, upstream });
|
||||
expect(isHeadCheckedOut(head("feature", "origin/feature"), remotes, pr())).toBe(true);
|
||||
expect(isHeadCheckedOut(head("feature", "fork/feature"), remotes, pr())).toBe(false);
|
||||
expect(isHeadCheckedOut(head("other", "origin/other"), remotes, pr())).toBe(false);
|
||||
expect(isHeadCheckedOut(head(null, null), remotes, pr())).toBe(false);
|
||||
// gh's fork checkouts track a URL: the name and the head commit decide.
|
||||
const fork = pr({ headRepository: repo("someone/r", true) });
|
||||
expect(isHeadCheckedOut(head("someone/feature", null, "head"), remotes, fork)).toBe(true);
|
||||
expect(isHeadCheckedOut(head("feature", null, "older"), remotes, fork)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit picker", () => {
|
||||
it("labels and steps through the commits", () => {
|
||||
expect(commitsTitle(1)).toBe("Changes");
|
||||
expect(commitsTitle(3)).toBe("Changes from");
|
||||
expect(stepCommit(-1, 3, 1)).toBeNull();
|
||||
expect(stepCommit(0, 3, 1)).toBe(1);
|
||||
expect(stepCommit(2, 3, 1)).toBeNull();
|
||||
expect(stepCommit(0, 3, -1)).toBeNull();
|
||||
expect(stepCommit(2, 3, -1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changes tree", () => {
|
||||
const files = [file("src/a.ts"), file("src/b.ts", "added"), file("README.md", "removed")];
|
||||
|
||||
it("groups by directory or lists flat", () => {
|
||||
expect(changesTree(files, true).map((n) => n.name)).toEqual(["src", "README.md"]);
|
||||
expect(changesTree(files, false).map((n) => n.name)).toEqual(["a.ts", "b.ts", "README.md"]);
|
||||
});
|
||||
|
||||
it("counts unresolved threads and the viewed state, folders summing their files", () => {
|
||||
const threads = unresolvedThreadsByPath([thread("src/a.ts"), thread("src/a.ts"), thread("src/b.ts", true)]);
|
||||
expect([...threads]).toEqual([["src/a.ts", 2]]);
|
||||
const viewed = { "src/a.ts": "VIEWED", "src/b.ts": "DISMISSED" } as const;
|
||||
const leaf = (f: GHCommitFile) => ({
|
||||
isRead: isViewed(viewed[f.filename as keyof typeof viewed]),
|
||||
discussions: threads.get(f.filename) ?? 0,
|
||||
});
|
||||
const [src, readme] = changesTree(files, true);
|
||||
expect(progressOf(src!, leaf)).toEqual({ isRead: false, discussions: 2 });
|
||||
expect(progressOf(src!.children[0]!, leaf)).toEqual({ isRead: true, discussions: 2 });
|
||||
expect(progressOf(readme!, leaf)).toEqual({ isRead: true, discussions: 0 });
|
||||
});
|
||||
|
||||
it("collects the files under the selection", () => {
|
||||
const roots = changesTree(files, true);
|
||||
expect(selectedFiles(roots, new Set(["d:src", "f:src/a.ts"])).map((f) => f.filename)).toEqual([
|
||||
"src/a.ts",
|
||||
"src/b.ts",
|
||||
]);
|
||||
expect(selectedFiles(roots, new Set(["f:README.md"])).map((f) => f.filename)).toEqual(["README.md"]);
|
||||
});
|
||||
|
||||
it("colours by VCS status and words the viewed toggle", () => {
|
||||
expect(changeKind(file("a", "added"))).toBe("added");
|
||||
expect(changeKind(file("a", "removed"))).toBe("deleted");
|
||||
expect(changeKind(file("a", "renamed"))).toBe("modified");
|
||||
expect(viewedToggleText(1, false)).toBe("Mark as Viewed");
|
||||
expect(viewedToggleText(1, true)).toBe("Mark as Not Viewed");
|
||||
expect(viewedToggleText(2, false)).toBe("Mark All as Viewed");
|
||||
expect(viewedToggleText(2, true)).toBe("Mark All as Not Viewed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Pure rules of the details tab, ported from IntelliJ: the review flow
|
||||
* (`GHPRReviewFlowViewModelImpl`, `GHPRDetailsActionsComponentFactory`), the status
|
||||
* section (`GHPRStatusChecksComponentFactory`, `CodeReviewDetailsStatusComponentFactory`,
|
||||
* `GHPRReviewerStateUIUtil`), the commit picker (`CodeReviewDetailsCommitsComponentFactory`),
|
||||
* the branch widget (`GHPRBranchesViewModel`), the changes tree's progress state
|
||||
* (`CodeReviewProgressTreeModel`, `GHPRChangeListViewModel`) and the viewed toggle
|
||||
* (`GHPRViewedStateToggleAction`).
|
||||
*/
|
||||
import type { BranchInfo } from "@/bindings";
|
||||
import { buildFileTree, type FileTreeNode } from "@/features/vcs/fileTree";
|
||||
import type { EffectiveKind } from "@/features/vcs/model";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type {
|
||||
CodeReviewCIJob,
|
||||
CodeReviewCIJobState,
|
||||
GHCommitFile,
|
||||
GHPRReviewerState,
|
||||
GHPullRequest,
|
||||
GHPullRequestFileViewedState,
|
||||
GHPullRequestRequestedReviewer,
|
||||
GHPullRequestReviewThread,
|
||||
GHRepositoryPermissionLevel,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import { hasPermission, isTeam, reviewerKey, type ReviewerWithState } from "../data/model";
|
||||
import type { GHRemote } from "../data/repository";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review flow
|
||||
|
||||
/** `ReviewRequestState`: a draft is DRAFT whatever its state. */
|
||||
export type ReviewRequestState = "OPENED" | "CLOSED" | "MERGED" | "DRAFT";
|
||||
/** `ReviewRole`. */
|
||||
export type ReviewRole = "AUTHOR" | "REVIEWER" | "GUEST";
|
||||
/** `ReviewState`: the overall review state. */
|
||||
export type ReviewState = "ACCEPTED" | "WAIT_FOR_UPDATES" | "NEED_REVIEW";
|
||||
|
||||
export function reviewRequestState(pr: Pick<GHPullRequest, "state" | "isDraft">): ReviewRequestState {
|
||||
if (pr.isDraft) return "DRAFT";
|
||||
if (pr.state === "CLOSED") return "CLOSED";
|
||||
if (pr.state === "MERGED") return "MERGED";
|
||||
return "OPENED";
|
||||
}
|
||||
|
||||
/** AUTHOR when `me` wrote the pull request, REVIEWER when requested or when they reviewed, else GUEST. */
|
||||
export function reviewRole(
|
||||
pr: Pick<GHPullRequest, "author" | "reviewRequests" | "reviews">,
|
||||
me: Pick<GHUser, "id">,
|
||||
): ReviewRole {
|
||||
if (pr.author?.id === me.id) return "AUTHOR";
|
||||
const requested = pr.reviewRequests.nodes.some((r) => r.requestedReviewer?.id === me.id);
|
||||
if (requested || pr.reviews.nodes.some((r) => r.author?.id === me.id)) return "REVIEWER";
|
||||
return "GUEST";
|
||||
}
|
||||
|
||||
/**
|
||||
* ACCEPTED when the approvals reach the required count (one when none is required),
|
||||
* else from GitHub's review decision.
|
||||
*/
|
||||
export function overallReviewState(
|
||||
pr: Pick<GHPullRequest, "reviewDecision">,
|
||||
reviewers: readonly ReviewerWithState[],
|
||||
requiredApprovingReviewsCount: number,
|
||||
): ReviewState {
|
||||
const approved = reviewers.filter((r) => r.state === "APPROVED").length;
|
||||
const isApproved = requiredApprovingReviewsCount === 0 ? approved > 0 : approved >= requiredApprovingReviewsCount;
|
||||
if (isApproved || pr.reviewDecision === "APPROVED") return "ACCEPTED";
|
||||
if (pr.reviewDecision === "CHANGES_REQUESTED") return "WAIT_FOR_UPDATES";
|
||||
return "NEED_REVIEW";
|
||||
}
|
||||
|
||||
/** `GHPRReviewerState.isAwaitingReview`. */
|
||||
export const isAwaitingReview = (s: GHPRReviewerState | null | undefined) =>
|
||||
s === "NEEDS_REVIEW" || s === "RE_REQUESTED";
|
||||
|
||||
/** Entries of the More (⋯) menu. */
|
||||
export type MoreItem = "requestReview" | "mergeGroup" | "close";
|
||||
|
||||
/** Which review-flow controls an open pull request shows. */
|
||||
export interface OpenedActions {
|
||||
requestReview: boolean;
|
||||
reRequestReview: boolean;
|
||||
submitReview: boolean;
|
||||
setMyselfAsReviewer: boolean;
|
||||
merge: boolean;
|
||||
more: MoreItem[];
|
||||
}
|
||||
|
||||
/** The open pull request's buttons and More menu for `role` (`GHPRDetailsActionsComponentFactory`). */
|
||||
export function openedActions(
|
||||
role: ReviewRole,
|
||||
state: ReviewState,
|
||||
requestedReviewers: number,
|
||||
myState: GHPRReviewerState | null,
|
||||
): OpenedActions {
|
||||
const none: OpenedActions = {
|
||||
requestReview: false,
|
||||
reRequestReview: false,
|
||||
submitReview: false,
|
||||
setMyselfAsReviewer: false,
|
||||
merge: false,
|
||||
more: [],
|
||||
};
|
||||
const accepted = state === "ACCEPTED";
|
||||
if (role === "AUTHOR")
|
||||
return {
|
||||
...none,
|
||||
requestReview: state === "NEED_REVIEW" || (state === "WAIT_FOR_UPDATES" && requestedReviewers > 0),
|
||||
reRequestReview: state === "WAIT_FOR_UPDATES" && requestedReviewers === 0,
|
||||
merge: accepted,
|
||||
more: accepted ? ["requestReview", "close"] : ["mergeGroup", "close"],
|
||||
};
|
||||
if (role === "REVIEWER")
|
||||
return {
|
||||
...none,
|
||||
submitReview: myState !== "APPROVED",
|
||||
merge: accepted && !isAwaitingReview(myState),
|
||||
more: accepted ? ["requestReview", "close"] : ["requestReview", "mergeGroup", "close"],
|
||||
};
|
||||
return { ...none, setMyselfAsReviewer: true, more: ["requestReview", "mergeGroup", "close"] };
|
||||
}
|
||||
|
||||
export type MergeMethod = "merge" | "squash" | "rebase";
|
||||
|
||||
/** The merge split button's main method (the first the repository allows) and the others in its menu. */
|
||||
export function mergeButtonMethods(repo: { mergeCommitAllowed: boolean; squashMergeAllowed: boolean }): {
|
||||
main: MergeMethod;
|
||||
others: MergeMethod[];
|
||||
} {
|
||||
const all: MergeMethod[] = ["merge", "squash", "rebase"];
|
||||
const main: MergeMethod = repo.mergeCommitAllowed ? "merge" : repo.squashMergeAllowed ? "squash" : "rebase";
|
||||
return { main, others: all.filter((m) => m !== main) };
|
||||
}
|
||||
|
||||
export const MERGE_METHOD_LABEL: Record<MergeMethod, string> = {
|
||||
merge: "Merge…",
|
||||
squash: "Squash and Merge…",
|
||||
rebase: "Rebase",
|
||||
};
|
||||
|
||||
/** Reviewers to add and remove to go from `current` to `chosen` (`CollectionDelta`). */
|
||||
export function reviewersDelta(
|
||||
current: readonly GHPullRequestRequestedReviewer[],
|
||||
chosen: readonly GHPullRequestRequestedReviewer[],
|
||||
): { added: GHPullRequestRequestedReviewer[]; removed: GHPullRequestRequestedReviewer[] } {
|
||||
const before = new Set(current.map(reviewerKey));
|
||||
const after = new Set(chosen.map(reviewerKey));
|
||||
return {
|
||||
added: dedupe(chosen).filter((r) => !before.has(reviewerKey(r))),
|
||||
removed: dedupe(current).filter((r) => !after.has(reviewerKey(r))),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reviewers without repeats (by login / team slug). */
|
||||
export function dedupe(reviewers: readonly GHPullRequestRequestedReviewer[]): GHPullRequestRequestedReviewer[] {
|
||||
const seen = new Set<string>();
|
||||
return reviewers.filter((r) => {
|
||||
const k = reviewerKey(r);
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** `getPresentableName`: the name, else the login (teams: name, else `org/slug`). */
|
||||
export function presentableName(r: GHPullRequestRequestedReviewer): string {
|
||||
if (isTeam(r)) return r.teamName || r.combinedSlug;
|
||||
return r.name || r.login;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status section
|
||||
|
||||
/** A status row's icon (`CIBuildStatusIcons` / `ReviewDetailsUIUtil`). */
|
||||
export type StatusIcon = "success" | "failed" | "pending" | "failedInProgress" | "skipped" | "warning" | "error";
|
||||
|
||||
export const STATUS_ICON: Record<StatusIcon, string> = {
|
||||
success: "success",
|
||||
failed: "ci-failed",
|
||||
pending: "ci-pending",
|
||||
failedInProgress: "ci-failed-in-progress",
|
||||
skipped: "ci-skipped",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export const JOB_ICON: Record<CodeReviewCIJobState, StatusIcon> = {
|
||||
FAILED: "failed",
|
||||
PENDING: "pending",
|
||||
SKIPPED: "skipped",
|
||||
SUCCESS: "success",
|
||||
};
|
||||
|
||||
/** The CI line (`calcPipelineIcon` / `calcPipelineText`); null without jobs. */
|
||||
export function ciSummary(jobs: readonly CodeReviewCIJob[]): { icon: StatusIcon; text: string } | null {
|
||||
if (!jobs.length) return null;
|
||||
const failed = jobs.filter((j) => j.status === "FAILED").length;
|
||||
const pending = jobs.filter((j) => j.status === "PENDING").length;
|
||||
if (jobs.filter((j) => j.isRequired).every((j) => j.status === "SUCCESS"))
|
||||
return { icon: "success", text: "All required checks have passed" };
|
||||
if (pending && failed) return { icon: "failedInProgress", text: "Checks in progress, and some already failed…" };
|
||||
if (pending) return { icon: "pending", text: "Checks in progress…" };
|
||||
return { icon: "failed", text: "Checks failed" };
|
||||
}
|
||||
|
||||
/** The access line (`createAccessDeniedLabel`); null when the user may do everything. */
|
||||
export function accessDeniedText(
|
||||
permission: GHRepositoryPermissionLevel | null | undefined,
|
||||
viewerDidAuthor: boolean,
|
||||
isDraft: boolean,
|
||||
mergeForbidden = false,
|
||||
): string | null {
|
||||
const canMerge = hasPermission(permission, "WRITE");
|
||||
const canClose = hasPermission(permission, "TRIAGE") || viewerDidAuthor;
|
||||
const canMarkReadyForReview = canMerge || viewerDidAuthor;
|
||||
if (!canClose) return "Repository access required to manage pull requests";
|
||||
if (!canMarkReadyForReview && isDraft) return "Repository write access required to merge pull requests";
|
||||
if (!canMerge && !isDraft) return "Repository write access required to merge pull requests";
|
||||
if (mergeForbidden && !isDraft) return "Merging is disabled for this project";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A reviewer row's text (`GHPRReviewerStateUIUtil.getText`). */
|
||||
export function reviewerStatusText(state: GHPRReviewerState, name: string): string {
|
||||
switch (state) {
|
||||
case "APPROVED":
|
||||
return tx`${name} approved review`;
|
||||
case "CHANGES_REQUESTED":
|
||||
return tx`${name} is waiting for updates`;
|
||||
case "NEEDS_REVIEW":
|
||||
return tx`Waiting for review from ${name}`;
|
||||
case "RE_REQUESTED":
|
||||
return tx`Re-requested review from ${name}`;
|
||||
case "COMMENTED":
|
||||
return tx`${name} left comments`;
|
||||
}
|
||||
}
|
||||
|
||||
/** A reviewer row's status icon: approved, changes requested, awaiting (warning), commented (neutral comment). */
|
||||
export function reviewerStatusIcon(state: GHPRReviewerState): string {
|
||||
if (state === "APPROVED") return "success";
|
||||
if (state === "CHANGES_REQUESTED") return "error";
|
||||
if (state === "COMMENTED") return "review-comment";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
/** Why Resolve cannot run (`GHPRResolveConflictsLocallyError`), or the remotes it merges between. */
|
||||
export type ResolveConflicts =
|
||||
| { error: string }
|
||||
| { headRemote: string; baseRemote: string; headRefName: string; baseRefName: string };
|
||||
|
||||
/** The local git remote of `nameWithOwner` among the project's GitHub remotes. */
|
||||
export function remoteOf(remotes: readonly GHRemote[], nameWithOwner: string | null | undefined): GHRemote | null {
|
||||
if (!nameWithOwner) return null;
|
||||
const key = nameWithOwner.toLowerCase();
|
||||
return remotes.find((r) => `${r.owner}/${r.name}`.toLowerCase() === key) ?? null;
|
||||
}
|
||||
|
||||
/** `GHPRResolveConflictsLocallyViewModel.requestOrError`. */
|
||||
export function resolveConflicts(input: {
|
||||
details: GHPullRequest | undefined;
|
||||
remotes: readonly GHRemote[];
|
||||
/** A merge or rebase is in progress in the repository. */
|
||||
mergeInProgress: boolean;
|
||||
/** The current branch tracks the head branch and contains the base commit. */
|
||||
alreadyResolvedLocally: boolean;
|
||||
}): ResolveConflicts {
|
||||
const { details: pr, remotes } = input;
|
||||
if (input.mergeInProgress) return { error: "Merge is already in progress" };
|
||||
if (!pr) return { error: "Details have not finished loading" };
|
||||
if (!pr.headRepository) return { error: tx`${tx("Source")} repository not found` };
|
||||
if (!pr.baseRepository) return { error: tx`${tx("Target")} repository not found` };
|
||||
const head = remoteOf(remotes, pr.headRepository.nameWithOwner);
|
||||
if (!head) return { error: tx`${tx("Source")} remote not found: ${pr.headRepository.url}` };
|
||||
const base = remoteOf(remotes, pr.baseRepository.nameWithOwner);
|
||||
if (!base) return { error: tx`${tx("Target")} remote not found: ${pr.baseRepository.url}` };
|
||||
if (input.alreadyResolvedLocally) return { error: "Already resolved locally, push your changes to resolve" };
|
||||
return { headRemote: head.remote, baseRemote: base.remote, headRefName: pr.headRefName, baseRefName: pr.baseRefName };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branches
|
||||
|
||||
/** The head branch as the widget shows it: `owner:branch` for forks and when head and base names are equal. */
|
||||
export function headBranchLabel(pr: Pick<GHPullRequest, "headRefName" | "baseRefName" | "headRepository">): string {
|
||||
const repo = pr.headRepository;
|
||||
if (repo && (repo.isFork || pr.baseRefName === pr.headRefName)) return `${repo.owner.login}:${pr.headRefName}`;
|
||||
return pr.headRefName;
|
||||
}
|
||||
|
||||
/** The GitHub repository of a branch's upstream (`origin/feature` or a fetch URL), with its branch name. */
|
||||
function upstreamOf(upstream: string, remotes: readonly GHRemote[]): { repo: string | null; branch: string } | null {
|
||||
const named = remotes
|
||||
.filter((r) => upstream.startsWith(`${r.remote}/`))
|
||||
.sort((a, b) => b.remote.length - a.remote.length)[0];
|
||||
if (named) return { repo: `${named.owner}/${named.name}`, branch: upstream.slice(named.remote.length + 1) };
|
||||
const slash = upstream.indexOf("/");
|
||||
return slash > 0 ? { repo: null, branch: upstream.slice(slash + 1) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pull request's head branch is checked out (`testRemoteBranchCheckedOut`): the
|
||||
* current branch tracks the head repository's branch, or (gh's fork checkouts, which track
|
||||
* a URL) it is named like the head branch and sits on the head commit.
|
||||
*/
|
||||
export function isHeadCheckedOut(
|
||||
branch: Pick<BranchInfo, "head" | "headCommit" | "upstream"> | null,
|
||||
remotes: readonly GHRemote[],
|
||||
pr: Pick<GHPullRequest, "headRefName" | "headRefOid" | "headRepository">,
|
||||
): boolean {
|
||||
if (!branch?.head) return false;
|
||||
const up = branch.upstream ? upstreamOf(branch.upstream, remotes) : null;
|
||||
if (up?.repo && pr.headRepository)
|
||||
return up.branch === pr.headRefName && up.repo.toLowerCase() === pr.headRepository.nameWithOwner.toLowerCase();
|
||||
const name = branch.head.slice(branch.head.lastIndexOf("/") + 1);
|
||||
return (name === pr.headRefName || branch.head === pr.headRefName) && branch.headCommit === pr.headRefOid;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commit picker
|
||||
|
||||
/** The bold label before the dropdown: "Changes" for one commit, else "Changes from". */
|
||||
export const commitsTitle = (count: number) => (count === 1 ? "Changes" : "Changes from");
|
||||
|
||||
/** Index of the next (1) / previous (-1) commit, or null at either end or with all commits shown. */
|
||||
export function stepCommit(selected: number, count: number, dir: 1 | -1): number | null {
|
||||
if (selected < 0) return null;
|
||||
const next = selected + dir;
|
||||
return next >= 0 && next < count ? next : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Changes tree
|
||||
|
||||
/** `CodeReviewProgressTreeModel`'s node state: read (viewed) and the unresolved discussions. */
|
||||
export interface ProgressState {
|
||||
isRead: boolean;
|
||||
discussions: number;
|
||||
}
|
||||
|
||||
/** Unresolved review threads per path (`unresolvedThreadsCountByChange`). */
|
||||
export function unresolvedThreadsByPath(threads: readonly GHPullRequestReviewThread[]): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
for (const t of threads) if (!t.isResolved) out.set(t.path, (out.get(t.path) ?? 0) + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Only VIEWED counts as viewed (DISMISSED: changed since); a path without state counts as read. */
|
||||
export const isViewed = (state: GHPullRequestFileViewedState | undefined) => state === undefined || state === "VIEWED";
|
||||
|
||||
/** A tree node's progress: a file's own, a folder's the sum of its files (read when all are). */
|
||||
export function progressOf(
|
||||
node: FileTreeNode<GHCommitFile>,
|
||||
leaf: (file: GHCommitFile) => ProgressState,
|
||||
): ProgressState {
|
||||
if (node.file) return leaf(node.file);
|
||||
let isRead = true;
|
||||
let discussions = 0;
|
||||
for (const c of node.children) {
|
||||
const s = progressOf(c, leaf);
|
||||
isRead &&= s.isRead;
|
||||
discussions += s.discussions;
|
||||
}
|
||||
return { isRead, discussions };
|
||||
}
|
||||
|
||||
const fileName = (path: string) => path.slice(path.lastIndexOf("/") + 1);
|
||||
|
||||
/** The tree's roots: grouped by directory, or a flat list. */
|
||||
export function changesTree(files: readonly GHCommitFile[], groupByDirectory: boolean): FileTreeNode<GHCommitFile>[] {
|
||||
if (groupByDirectory) return buildFileTree(files, (f) => f.filename);
|
||||
return [...files]
|
||||
.sort((a, b) => fileName(a.filename).localeCompare(fileName(b.filename)) || a.filename.localeCompare(b.filename))
|
||||
.map((f) => ({ key: `f:${f.filename}`, name: fileName(f.filename), file: f, children: [], count: 1 }));
|
||||
}
|
||||
|
||||
/** Files of the selected nodes, folders contributing all theirs (`SELECTED_CHANGES`), in tree order. */
|
||||
export function selectedFiles(roots: readonly FileTreeNode<GHCommitFile>[], keys: ReadonlySet<string>): GHCommitFile[] {
|
||||
const out: GHCommitFile[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (n: FileTreeNode<GHCommitFile>) => {
|
||||
if (n.file) {
|
||||
if (!seen.has(n.file.filename)) out.push(n.file);
|
||||
seen.add(n.file.filename);
|
||||
} else n.children.forEach(add);
|
||||
};
|
||||
const walk = (ns: readonly FileTreeNode<GHCommitFile>[]) => {
|
||||
for (const n of ns) {
|
||||
if (keys.has(n.key)) add(n);
|
||||
else walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(roots);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The file's VCS status colour kind (`Change.getFileStatus`). */
|
||||
export function changeKind(f: Pick<GHCommitFile, "status">): EffectiveKind {
|
||||
if (f.status === "added" || f.status === "copied") return "added";
|
||||
if (f.status === "removed") return "deleted";
|
||||
return "modified";
|
||||
}
|
||||
|
||||
/** The viewed toggle's text (`GHPRViewedStateToggleAction`). */
|
||||
export function viewedToggleText(count: number, allViewed: boolean): string {
|
||||
if (count === 1) return allViewed ? "Mark as Not Viewed" : "Mark as Viewed";
|
||||
return allViewed ? "Mark All as Not Viewed" : "Mark All as Viewed";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Client state of the details tab: the changes of single commits (REST `commits/{sha}`,
|
||||
* cached per commit), the changes tree's grouping (`GithubPullRequestsProjectUISettings
|
||||
* .changesGrouping`, kept in local storage) and the Submit Review popup's text, kept per
|
||||
* pull request until the review is submitted (IntelliJ keeps it as the pending review's
|
||||
* comment).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { errorMessage } from "../api/client";
|
||||
import { commitChangedFiles } from "../api/requests";
|
||||
import type { GHCommitFile } from "../api/types";
|
||||
import type { Loadable } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
|
||||
const useCommitFiles = create<{ bySha: Record<string, Loadable<GHCommitFile[]>> }>(() => ({ bySha: {} }));
|
||||
|
||||
const EMPTY: Loadable<GHCommitFile[]> = { value: undefined, loading: false, error: null };
|
||||
|
||||
function loadCommitFiles(sha: string) {
|
||||
const cur = useCommitFiles.getState().bySha[sha];
|
||||
if (cur?.value || cur?.loading) return;
|
||||
const put = (v: Loadable<GHCommitFile[]>) => useCommitFiles.setState((s) => ({ bySha: { ...s.bySha, [sha]: v } }));
|
||||
put({ value: undefined, loading: true, error: null });
|
||||
commitChangedFiles(sha).then(
|
||||
(value) => put({ value, loading: false, error: null }),
|
||||
(e: unknown) => put({ value: undefined, loading: false, error: errorMessage(e) }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The files commit `sha` changes; nothing for null. */
|
||||
export function useCommitFilesOf(sha: string | null): Loadable<GHCommitFile[]> {
|
||||
useEffect(() => {
|
||||
if (sha) loadCommitFiles(sha);
|
||||
}, [sha]);
|
||||
return useCommitFiles((s) => (sha ? (s.bySha[sha] ?? EMPTY) : EMPTY));
|
||||
}
|
||||
|
||||
/** Drops the cached commit changes (Refresh). */
|
||||
export function clearCommitFiles() {
|
||||
useCommitFiles.setState({ bySha: {} });
|
||||
}
|
||||
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
if (s.connection?.coordinates !== prev.connection?.coordinates) clearCommitFiles();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GROUPING_KEY = "openidea.github.changesGrouping.v1";
|
||||
|
||||
function loadGrouping(): boolean {
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(GROUPING_KEY) !== "none";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Group By › Directory of the changes tree (on by default). */
|
||||
export const useChangesGrouping = create<{ byDirectory: boolean }>(() => ({ byDirectory: loadGrouping() }));
|
||||
|
||||
export function setGroupByDirectory(on: boolean) {
|
||||
useChangesGrouping.setState({ byDirectory: on });
|
||||
try {
|
||||
globalThis.localStorage?.setItem(GROUPING_KEY, on ? "directory" : "none");
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The Submit Review popup's text per pull request. */
|
||||
export const useReviewText = create<{ text: Record<number, string> }>(() => ({ text: {} }));
|
||||
|
||||
export function setReviewText(number: number, text: string) {
|
||||
useReviewText.setState((s) => ({ text: { ...s.text, [number]: text } }));
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/** Create Pull Request, Submit Review and Checkout-by-number dialogs, rendered by one overlay. */
|
||||
import { Fragment, useEffect, useState, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { ReviewEvent } from "@/platform/ipc";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog, Input, Select, T } from "@/ui";
|
||||
import styles from "./Github.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
interface Entry {
|
||||
id: number;
|
||||
node: ReactNode;
|
||||
}
|
||||
|
||||
const useDialogs = create<{ stack: Entry[] }>(() => ({ stack: [] }));
|
||||
let seq = 0;
|
||||
|
||||
function open<T>(render: (done: (v: T | null) => void) => ReactNode): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
const id = ++seq;
|
||||
const done = (v: T | null) => {
|
||||
useDialogs.setState((s) => ({ stack: s.stack.filter((e) => e.id !== id) }));
|
||||
resolve(v);
|
||||
};
|
||||
useDialogs.setState((s) => ({ stack: [...s.stack, { id, node: render(done) }] }));
|
||||
});
|
||||
}
|
||||
|
||||
export function GithubDialogHost() {
|
||||
const stack = useDialogs((s) => s.stack);
|
||||
return (
|
||||
<>
|
||||
{stack.map((e) => (
|
||||
<Fragment key={e.id}>{e.node}</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CreatePrInput {
|
||||
title: string;
|
||||
body: string;
|
||||
base: string;
|
||||
draft: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
/** Asks for the new pull request's title, description, base branch and options. */
|
||||
export function createPullRequestDialog(opts: {
|
||||
head: string;
|
||||
defaultBase: string | null;
|
||||
title: string;
|
||||
needsPush: boolean;
|
||||
}) {
|
||||
return open<CreatePrInput>((done) => <CreateDialog {...opts} onDone={done} />);
|
||||
}
|
||||
|
||||
function CreateDialog(props: {
|
||||
head: string;
|
||||
defaultBase: string | null;
|
||||
title: string;
|
||||
needsPush: boolean;
|
||||
onDone: (v: CreatePrInput | null) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(props.title);
|
||||
const [body, setBody] = useState("");
|
||||
const [base, setBase] = useState(props.defaultBase ?? "");
|
||||
const [bases, setBases] = useState<string[]>(props.defaultBase ? [props.defaultBase] : []);
|
||||
const [draft, setDraft] = useState(false);
|
||||
const [push, setPush] = useState(props.needsPush);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// Base candidates: branches of the remotes (without `origin/`), the default first.
|
||||
call(commands.vcsBranches(null))
|
||||
.then((branches) => {
|
||||
const names = new Set<string>();
|
||||
if (props.defaultBase) names.add(props.defaultBase);
|
||||
for (const b of branches) {
|
||||
if (!b.remote || b.name.endsWith("/HEAD")) continue;
|
||||
const short = b.name.slice(b.name.indexOf("/") + 1);
|
||||
if (short !== props.head) names.add(short);
|
||||
}
|
||||
if (alive) {
|
||||
setBases([...names]);
|
||||
setBase((cur) => cur || [...names][0] || "");
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [props.defaultBase, props.head]);
|
||||
|
||||
const ok = !!title.trim() && !!base;
|
||||
const submit = () => ok && props.onDone({ title: title.trim(), body, base, draft, push });
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Create Pull Request"
|
||||
onClose={() => props.onDone(null)}
|
||||
width={560}
|
||||
buttons={[
|
||||
{ label: draft ? "Create Draft" : "Create", role: "default", disabled: !ok, onClick: submit },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formRow}>
|
||||
<label className={styles.fieldLabel}>
|
||||
<T>Base:</T>
|
||||
</label>
|
||||
<Select
|
||||
value={base || null}
|
||||
options={bases.map((b) => ({ value: b, label: b }))}
|
||||
onChange={setBase}
|
||||
aria-label="Base branch"
|
||||
/>
|
||||
<span className={styles.fieldLabel}>← {props.head}</span>
|
||||
</div>
|
||||
<Input
|
||||
data-autofocus
|
||||
aria-label="Title"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
aria-label={tx("Description")}
|
||||
placeholder={tx("Description (Markdown)")}
|
||||
className={styles.textarea}
|
||||
rows={10}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<div className={styles.formRow}>
|
||||
<Checkbox checked={draft} onChange={setDraft} label="Draft" />
|
||||
<Checkbox checked={push} onChange={setPush} label={tx`Push '${props.head}' first`} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const REVIEW_LABEL: Record<ReviewEvent, string> = {
|
||||
approve: "Approve",
|
||||
requestChanges: "Request Changes",
|
||||
comment: "Comment",
|
||||
};
|
||||
|
||||
/** Asks for a review comment; resolves with the chosen event and body. */
|
||||
export function reviewDialog(number: number, initial: ReviewEvent) {
|
||||
return open<{ event: ReviewEvent; body: string }>((done) => (
|
||||
<ReviewDialog number={number} initial={initial} onDone={done} />
|
||||
));
|
||||
}
|
||||
|
||||
function ReviewDialog(props: {
|
||||
number: number;
|
||||
initial: ReviewEvent;
|
||||
onDone: (v: { event: ReviewEvent; body: string } | null) => void;
|
||||
}) {
|
||||
const [body, setBody] = useState("");
|
||||
const [event, setEvent] = useState<ReviewEvent>(props.initial);
|
||||
const needsBody = event !== "approve";
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={tx`Review #${props.number}`}
|
||||
onClose={() => props.onDone(null)}
|
||||
width={520}
|
||||
buttons={[
|
||||
{
|
||||
label: REVIEW_LABEL[event],
|
||||
role: "default",
|
||||
disabled: needsBody && !body.trim(),
|
||||
onClick: () => props.onDone({ event, body }),
|
||||
},
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<Select<ReviewEvent>
|
||||
value={event}
|
||||
onChange={setEvent}
|
||||
aria-label="Review action"
|
||||
options={(Object.keys(REVIEW_LABEL) as ReviewEvent[]).map((v) => ({ value: v, label: REVIEW_LABEL[v] }))}
|
||||
/>
|
||||
<textarea
|
||||
data-autofocus
|
||||
aria-label={tx("Review comment")}
|
||||
placeholder={needsBody ? tx("Leave a comment (required)") : tx("Leave a comment (optional)")}
|
||||
className={styles.textarea}
|
||||
rows={8}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Asks for a pull request number (Git › GitHub › Checkout Pull Request…). */
|
||||
export function numberDialog(title: string) {
|
||||
return open<number>((done) => <NumberDialog title={title} onDone={done} />);
|
||||
}
|
||||
|
||||
function NumberDialog(props: { title: string; onDone: (v: number | null) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
const n = Number.parseInt(value.replace(/^#/, ""), 10);
|
||||
const ok = Number.isInteger(n) && n > 0;
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={props.title}
|
||||
onClose={() => props.onDone(null)}
|
||||
width={360}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", disabled: !ok, onClick: () => props.onDone(n) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (ok) props.onDone(n);
|
||||
}}
|
||||
>
|
||||
<label className={styles.fieldLabel}>
|
||||
<T>Pull request number:</T>
|
||||
</label>
|
||||
<Input
|
||||
data-autofocus
|
||||
mono
|
||||
aria-label="Pull request number"
|
||||
placeholder="#123"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Settings › Version Control › GitHub (`GithubSettingsConfigurable`); accounts are gh's, so
|
||||
* only the pull request option is here. Applies at once, like the Log page.
|
||||
*/
|
||||
import { Checkbox } from "@/ui";
|
||||
import { setReviewDiffSettings, useReviewDiffSettings } from "./settings";
|
||||
import styles from "./ReviewDiff.module.css";
|
||||
|
||||
export function GithubSettingsPage() {
|
||||
const autoMarkViewed = useReviewDiffSettings((s) => s.autoMarkViewed);
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Checkbox
|
||||
checked={autoMarkViewed}
|
||||
onChange={(v) => setReviewDiffSettings({ autoMarkViewed: v })}
|
||||
label="Automatically mark opened files as viewed"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The review inlays of a diff (`GHPRReviewEditorComponentsFactory`), in the editor inlay
|
||||
* panel (`CodeReviewCommentUIUtil.createEditorInlayPanel`: rounded, the editor background):
|
||||
*
|
||||
* - a review thread, the timeline's compact `ThreadView` (more than 3 comments: the first,
|
||||
* "… {n} more replies" and the last; Reply / Resolve);
|
||||
* - a new comment: the current user's avatar and the comment field; Escape closes it, asking
|
||||
* first when it has text ("Discard Comment"); its buttons follow the pending review.
|
||||
*/
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { usePullRequestData } from "../data/pullRequest";
|
||||
import { CommentEditor, submitShortcut, ThreadView } from "../timeline/comments";
|
||||
import { SUBMIT_ACTION_TEXT, submitActions } from "./model";
|
||||
import { removeNewComment, submitNewComment, updateNewComment, useNewComments, type NewComment } from "./newComments";
|
||||
import { useReviewDiffSettings } from "./settings";
|
||||
import styles from "./ReviewDiff.module.css";
|
||||
|
||||
export function ThreadInlay({ number, threadId }: { number: number; threadId: string }) {
|
||||
const thread = usePullRequestData(number, "threads").value?.find((t) => t.id === threadId);
|
||||
if (!thread) return null;
|
||||
return (
|
||||
<div className={styles.inlay} data-thread-inlay={threadId}>
|
||||
<ThreadView number={number} thread={thread} variant="compact" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Closes a new comment; with text, after "Are you sure you want to discard the unsaved comment?". */
|
||||
export async function discardNewComment(c: NewComment): Promise<void> {
|
||||
const current = useNewComments.getState().comments.find((x) => x.id === c.id) ?? c;
|
||||
if (current.text.trim()) {
|
||||
// `MessageDialogBuilder.yesNo`.
|
||||
const r = await ask({
|
||||
title: "Discard Comment",
|
||||
body: tx("Are you sure you want to discard the unsaved comment?"),
|
||||
buttons: [
|
||||
{ id: "yes", label: "Yes", primary: true },
|
||||
{ id: "no", label: "No" },
|
||||
],
|
||||
cancel: false,
|
||||
});
|
||||
if (r.button !== "yes") return;
|
||||
}
|
||||
removeNewComment(c.id);
|
||||
}
|
||||
|
||||
export function NewCommentInlay({ id }: { id: string }) {
|
||||
const comment = useNewComments((s) => s.comments.find((c) => c.id === id));
|
||||
const pending = usePullRequestData(comment?.number ?? 0, "pendingReview");
|
||||
const preferred = useReviewDiffSettings((s) => s.reviewCommentsPreferred);
|
||||
if (!comment) return null;
|
||||
// Until the pending review is known there is nothing to submit with (`submitActions` is empty).
|
||||
const actions = pending.value === undefined ? [] : submitActions(!!pending.value, preferred);
|
||||
const [primary, ...secondary] = actions;
|
||||
const textOf = () => useNewComments.getState().comments.find((c) => c.id === id)?.text ?? "";
|
||||
return (
|
||||
<div className={styles.inlay} data-new-comment={id}>
|
||||
<div className={styles.newComment}>
|
||||
<CommentEditor
|
||||
initialText={comment.text}
|
||||
submitLabel={primary ? SUBMIT_ACTION_TEXT[primary] : "Add Review Comment"}
|
||||
submitHint={primary ? tx`${submitShortcut()} to comment` : ""}
|
||||
onSubmit={(text) => (primary ? submitNewComment(comment, primary, text) : Promise.resolve())}
|
||||
onTextChange={(text) => updateNewComment(id, { text })}
|
||||
onCancel={() => void discardNewComment(comment)}
|
||||
noCancelButton
|
||||
extraActions={secondary.map((a) => ({
|
||||
label: SUBMIT_ACTION_TEXT[a],
|
||||
disabled: !comment.text.trim(),
|
||||
onClick: () => submitNewComment(comment, a, textOf()),
|
||||
}))}
|
||||
showAvatar
|
||||
variant="compact"
|
||||
autoFocus
|
||||
focusRequest={comment.focus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/* The editor inlay panel (`CodeReviewCommentUIUtil.createEditorInlayPanel`): 10px arc, the
|
||||
tear line colour as border, the editor background; content at most 42em wide, and never
|
||||
wider than the pane (the scroller is its size container; 96px: gutters and margins). */
|
||||
.inlay {
|
||||
box-sizing: border-box;
|
||||
max-width: min(calc(42em + 60px), calc(100cqw - 96px));
|
||||
margin: 4px 12px 4px 4px;
|
||||
padding: 6px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-editor);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-ui);
|
||||
white-space: normal;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.newComment {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.sep {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
margin: 0 4px;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The review actions of a pull request's diff toolbar (`GHPRDiffService` `CONTEXT_ACTIONS`):
|
||||
* Refresh Review Data, the Submit Review button ("Submit Review…" / "Submit Review ({n})…",
|
||||
* the details panel's popup through the `submitReview` handler), Previous / Next Comment.
|
||||
*/
|
||||
import { useContextKeys } from "@/platform/context";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Button, IconButton } from "@/ui";
|
||||
import { usePullRequestData } from "../data/pullRequest";
|
||||
import { githubHandler, handlerContextKey } from "../toolwindow/handlers";
|
||||
import type { ReviewDiff } from "./reviewDiff";
|
||||
import styles from "./ReviewDiff.module.css";
|
||||
|
||||
/** `{0, choice, 0#Submit Review…|1#Submit Review ({0})…}`. */
|
||||
const submitReviewText = (pending: number) => (pending > 0 ? tx`Submit Review (${pending})…` : tx("Submit Review…"));
|
||||
|
||||
export function ReviewToolbar({ diff }: { diff: ReviewDiff }) {
|
||||
const pending = usePullRequestData(diff.number, "pendingReview");
|
||||
const threads = usePullRequestData(diff.number, "threads");
|
||||
const canSubmit = useContextKeys((k) => !!k[handlerContextKey("submitReview")]);
|
||||
const loading = pending.loading || threads.loading;
|
||||
return (
|
||||
<>
|
||||
<span className={styles.sep} />
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Refresh Review Data"
|
||||
size="small"
|
||||
disabled={loading}
|
||||
onClick={() => diff.refresh()}
|
||||
/>
|
||||
<Button size="small" disabled={!canSubmit} onClick={() => githubHandler("submitReview")?.(diff.number)}>
|
||||
{submitReviewText(pending.value?.commentsCount ?? 0)}
|
||||
</Button>
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Comment"
|
||||
size="small"
|
||||
disabled={!diff.canNavigate}
|
||||
onClick={() => void diff.gotoComment(-1)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
title="Next Comment"
|
||||
size="small"
|
||||
disabled={!diff.canNavigate}
|
||||
onClick={() => void diff.gotoComment(1)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Review in the diff viewer (IntelliJ's `GHPRReviewDiffExtension`): pull request diffs
|
||||
* (opened by `openPullRequestFileDiff` / the details panel's commit diffs, context
|
||||
* `PULL_REQUEST_DIFF`) get their review threads as inlays, the gutter "+" with single- and
|
||||
* multi-line comments, Start Review / Add Single Comment, the review toolbar and the viewed
|
||||
* toggle, through the vcs diff viewer provider point. Also registers the editor actions and
|
||||
* Settings › Version Control › GitHub. The editor actions also serve review mode's editors.
|
||||
*/
|
||||
import { registerDiffViewerProvider } from "@/features/vcs/diffViewerProviders";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerSettingsPage } from "@/workbench/contributions";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { activeReviewEditor } from "../reviewMode/editor";
|
||||
import { clearNewComments } from "./newComments";
|
||||
import { activeReviewDiff, createReviewDiff } from "./reviewDiff";
|
||||
|
||||
/** The editor actions apply to the focused review diff, or to the review in the active editor (review mode). */
|
||||
const WHEN = "projectOpen && githubConnected && (githubReviewDiff || githubReviewEditor)";
|
||||
|
||||
function reviewTarget() {
|
||||
const diff = activeReviewDiff();
|
||||
return (getContext().diffEditorFocus && diff) || activeReviewEditor() || diff;
|
||||
}
|
||||
|
||||
/** "Mark File as Viewed" / "Mark File as Not Viewed" in the focused review diff; false when none is focused. */
|
||||
export function toggleReviewDiffViewed(): boolean {
|
||||
const diff = activeReviewDiff();
|
||||
if (!diff) return false;
|
||||
diff.toggleViewed();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function activateReviewDiff(): () => void {
|
||||
const disposers = [
|
||||
registerDiffViewerProvider({ id: "github.review", create: createReviewDiff }),
|
||||
registerActions([
|
||||
{
|
||||
// Code.Review.Editor.New.Comment
|
||||
id: "github.pullRequest.diff.addComment",
|
||||
title: "Add Review Comment",
|
||||
description: "Create a new code review comment on the line under the caret",
|
||||
category: "GitHub",
|
||||
when: WHEN,
|
||||
run: () => reviewTarget()?.addComment(false),
|
||||
},
|
||||
{
|
||||
id: "github.pullRequest.diff.addMultilineComment",
|
||||
title: "Add Multiline Review Comment",
|
||||
description: "Create a new code review comment on the selected lines",
|
||||
category: "GitHub",
|
||||
when: WHEN,
|
||||
run: () => reviewTarget()?.addComment(true),
|
||||
},
|
||||
{
|
||||
// CodeReview.PreviousComment
|
||||
id: "github.pullRequest.diff.previousComment",
|
||||
title: "Previous Comment",
|
||||
description: "Go to previous visible comment",
|
||||
category: "GitHub",
|
||||
icon: "arrow-up",
|
||||
when: WHEN,
|
||||
run: () => void reviewTarget()?.gotoComment(-1),
|
||||
},
|
||||
{
|
||||
// CodeReview.NextComment
|
||||
id: "github.pullRequest.diff.nextComment",
|
||||
title: "Next Comment",
|
||||
description: "Go to next visible comment",
|
||||
category: "GitHub",
|
||||
icon: "arrow-down",
|
||||
when: WHEN,
|
||||
run: () => void reviewTarget()?.gotoComment(1),
|
||||
},
|
||||
{
|
||||
id: "github.pullRequest.diff.refreshReview",
|
||||
title: "Refresh Review Data",
|
||||
category: "GitHub",
|
||||
icon: "refresh",
|
||||
when: WHEN,
|
||||
run: () => reviewTarget()?.refresh(),
|
||||
},
|
||||
]),
|
||||
registerSettingsPage({
|
||||
id: "vcs.github",
|
||||
title: "GitHub",
|
||||
order: 9,
|
||||
keywords: ["pull request", "viewed", "review"],
|
||||
component: lazyComponent(() => import("./GithubSettingsPage").then((m) => m.GithubSettingsPage)),
|
||||
}),
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
const a = s.connection?.coordinates;
|
||||
const b = prev.connection?.coordinates;
|
||||
if (a?.owner !== b?.owner || a?.name !== b?.name) clearNewComments();
|
||||
}),
|
||||
];
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GHPullRequestReviewComment, GHPullRequestReviewThread } from "../api/types";
|
||||
import {
|
||||
adjacentThread,
|
||||
commentableRanges,
|
||||
diffPosition,
|
||||
draftThread,
|
||||
isThreadVisible,
|
||||
mapAcrossPatch,
|
||||
newCommentLocation,
|
||||
parsePatch,
|
||||
submitActions,
|
||||
threadLocation,
|
||||
threadOrder,
|
||||
} from "./model";
|
||||
import { parseReviewDiffSettings } from "./settings";
|
||||
|
||||
// a.ts: line 2 changed, line 4 added; 20 lines later one removed.
|
||||
const PATCH = [
|
||||
"@@ -1,4 +1,5 @@",
|
||||
" one",
|
||||
"-two",
|
||||
"+TWO",
|
||||
" three",
|
||||
"+three and a half",
|
||||
" four",
|
||||
"@@ -20,3 +21,2 @@ function x() {",
|
||||
" twenty",
|
||||
"-twenty-one",
|
||||
" twenty-two",
|
||||
].join("\n");
|
||||
|
||||
const comment = (over: Partial<GHPullRequestReviewComment> = {}): GHPullRequestReviewComment =>
|
||||
({
|
||||
id: "C1",
|
||||
author: null,
|
||||
body: "",
|
||||
createdAt: "2026-10-01T10:00:00Z",
|
||||
url: "",
|
||||
state: "SUBMITTED",
|
||||
commit: { oid: "head", abbreviatedOid: "head" },
|
||||
originalCommit: { oid: "c1", abbreviatedOid: "c1" },
|
||||
diffHunk: "",
|
||||
pullRequestReview: null,
|
||||
viewerCanDelete: false,
|
||||
viewerCanUpdate: false,
|
||||
viewerCanReact: false,
|
||||
reactions: { nodes: [] },
|
||||
...over,
|
||||
}) as GHPullRequestReviewComment;
|
||||
|
||||
const thread = (over: Partial<GHPullRequestReviewThread> = {}, c = comment()): GHPullRequestReviewThread => ({
|
||||
id: "T1",
|
||||
isResolved: false,
|
||||
isOutdated: false,
|
||||
path: "a.ts",
|
||||
subjectType: "LINE",
|
||||
diffSide: "RIGHT",
|
||||
line: 3,
|
||||
originalLine: 2,
|
||||
startDiffSide: null,
|
||||
startLine: null,
|
||||
originalStartLine: null,
|
||||
comments: { nodes: [c] },
|
||||
viewerCanReply: true,
|
||||
viewerCanResolve: true,
|
||||
viewerCanUnresolve: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("patches", () => {
|
||||
it("parses hunks with 0-based starts", () => {
|
||||
const hunks = parsePatch(PATCH + "\n");
|
||||
expect(hunks.map(({ rows: _r, ...h }) => h)).toEqual([
|
||||
{ leftStart: 0, leftCount: 4, rightStart: 0, rightCount: 5 },
|
||||
{ leftStart: 19, leftCount: 3, rightStart: 20, rightCount: 2 },
|
||||
]);
|
||||
expect(hunks[1]!.rows).toEqual([" twenty", "-twenty-one", " twenty-two"]);
|
||||
expect(parsePatch(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes every hunk line of a side as commentable (`patch.ranges`)", () => {
|
||||
expect(commentableRanges(parsePatch(PATCH))).toEqual({
|
||||
left: [
|
||||
{ start: 0, end: 4 },
|
||||
{ start: 19, end: 22 },
|
||||
],
|
||||
right: [
|
||||
{ start: 0, end: 5 },
|
||||
{ start: 20, end: 22 },
|
||||
],
|
||||
});
|
||||
// A new file has no left side.
|
||||
expect(commentableRanges(parsePatch("@@ -0,0 +1,2 @@\n+a\n+b")).left).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps lines across the patch", () => {
|
||||
const hunks = parsePatch(PATCH);
|
||||
expect(mapAcrossPatch(hunks, "left", 0)).toBe(0);
|
||||
expect(mapAcrossPatch(hunks, "left", 2)).toBe(2); // "three"
|
||||
expect(mapAcrossPatch(hunks, "left", 3)).toBe(4); // "four"
|
||||
expect(mapAcrossPatch(hunks, "right", 3)).toBe(3); // the added line: before "four"
|
||||
expect(mapAcrossPatch(hunks, "left", 10)).toBe(11); // between the hunks: +1
|
||||
expect(mapAcrossPatch(hunks, "left", 21)).toBe(21); // "twenty-two"
|
||||
expect(mapAcrossPatch(hunks, "left", 30)).toBe(30); // after: +1 -1
|
||||
expect(mapAcrossPatch(hunks, "right", 30)).toBe(30);
|
||||
});
|
||||
|
||||
it("finds GitHub's diff position of a line", () => {
|
||||
expect(diffPosition(PATCH, "right", 0)).toBe(1);
|
||||
expect(diffPosition(PATCH, "left", 1)).toBe(2);
|
||||
expect(diffPosition(PATCH, "right", 1)).toBe(3);
|
||||
expect(diffPosition(PATCH, "right", 3)).toBe(5);
|
||||
// The second header counts as a row.
|
||||
expect(diffPosition(PATCH, "left", 20)).toBe(9);
|
||||
expect(diffPosition(PATCH, "right", 21)).toBe(10);
|
||||
expect(diffPosition(PATCH, "right", 10)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("threads in a diff", () => {
|
||||
it("places current threads of the changes at their lines", () => {
|
||||
expect(threadLocation(thread(), { commit: null })).toEqual({
|
||||
side: "right",
|
||||
line: 2,
|
||||
startSide: "right",
|
||||
startLine: 2,
|
||||
});
|
||||
expect(
|
||||
threadLocation(thread({ diffSide: "LEFT", line: 5, startLine: 3, startDiffSide: "LEFT" }), { commit: null }),
|
||||
).toEqual({ side: "left", line: 4, startSide: "left", startLine: 2 });
|
||||
// Outdated and file threads have no line.
|
||||
expect(threadLocation(thread({ line: null, isOutdated: true }), { commit: null })).toBeNull();
|
||||
expect(threadLocation(thread({ subjectType: "FILE" }), { commit: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("places right-side threads of a commit's diff at their original lines", () => {
|
||||
expect(threadLocation(thread(), { commit: "c1" })).toEqual({
|
||||
side: "right",
|
||||
line: 1,
|
||||
startSide: "right",
|
||||
startLine: 1,
|
||||
});
|
||||
expect(threadLocation(thread(), { commit: "head" })?.line).toBe(2);
|
||||
expect(threadLocation(thread(), { commit: "other" })).toBeNull();
|
||||
expect(threadLocation(thread({ diffSide: "LEFT" }), { commit: "c1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by Show Review Threads", () => {
|
||||
const resolved = { isResolved: true };
|
||||
expect(isThreadVisible(resolved, "all")).toBe(true);
|
||||
expect(isThreadVisible(resolved, "unresolved")).toBe(false);
|
||||
expect(isThreadVisible({ isResolved: false }, "unresolved")).toBe(true);
|
||||
expect(isThreadVisible({ isResolved: false }, "none")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("new comments", () => {
|
||||
it("builds the GitHub input of single- and multi-line comments", () => {
|
||||
expect(draftThread("a.ts", newCommentLocation("right", 4, 4), "hi")).toEqual({
|
||||
body: "hi",
|
||||
path: "a.ts",
|
||||
line: 5,
|
||||
side: "RIGHT",
|
||||
startLine: null,
|
||||
startSide: null,
|
||||
});
|
||||
expect(draftThread("a.ts", newCommentLocation("left", 6, 2), "x")).toMatchObject({
|
||||
line: 7,
|
||||
side: "LEFT",
|
||||
startLine: 3,
|
||||
startSide: "LEFT",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers Add Review Comment with a pending review, else the preferred of Start Review / Add Single Comment", () => {
|
||||
expect(submitActions(true, false)).toEqual(["reviewComment"]);
|
||||
expect(submitActions(false, true)).toEqual(["startReview", "singleComment"]);
|
||||
expect(submitActions(false, false)).toEqual(["singleComment", "startReview"]);
|
||||
});
|
||||
|
||||
it("parses the stored settings with IntelliJ's defaults", () => {
|
||||
expect(parseReviewDiffSettings(null)).toEqual({
|
||||
threadsView: "unresolved",
|
||||
reviewCommentsPreferred: true,
|
||||
autoMarkViewed: false,
|
||||
});
|
||||
expect(parseReviewDiffSettings({ threadsView: "none", autoMarkViewed: true, reviewCommentsPreferred: 1 })).toEqual({
|
||||
threadsView: "none",
|
||||
reviewCommentsPreferred: true,
|
||||
autoMarkViewed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Previous / Next Comment", () => {
|
||||
const files = [
|
||||
{ path: "a.ts", hunks: parsePatch(PATCH) },
|
||||
{ path: "b.ts", hunks: [] },
|
||||
];
|
||||
const threads = [
|
||||
thread({ id: "b1", path: "b.ts", line: 1 }),
|
||||
thread({ id: "a-right", line: 5 }),
|
||||
thread({ id: "a-left", diffSide: "LEFT", line: 2 }),
|
||||
thread({ id: "a-first", line: 1 }),
|
||||
thread({ id: "outdated", line: null }),
|
||||
thread({ id: "elsewhere", path: "c.ts" }),
|
||||
];
|
||||
const order = threadOrder(files, threads);
|
||||
|
||||
it("orders threads by file, right line, left line", () => {
|
||||
expect(order.map((p) => p.id)).toEqual(["a-first", "a-left", "a-right", "b1"]);
|
||||
expect(order[1]).toMatchObject({ side: "left", line: 1, left: 1, right: 1 });
|
||||
});
|
||||
|
||||
it("moves from a thread or from the caret, skipping hidden threads", () => {
|
||||
expect(adjacentThread(order, { id: "a-first" }, 1)?.id).toBe("a-left");
|
||||
expect(adjacentThread(order, { id: "a-first" }, -1)).toBeNull();
|
||||
expect(adjacentThread(order, { id: "a-left" }, 1, (id) => id !== "a-right")?.id).toBe("b1");
|
||||
// The caret's line counts: the thread on it is the next and the previous one.
|
||||
expect(adjacentThread(order, { file: 0, left: 3, right: 4 }, 1)?.id).toBe("a-right");
|
||||
expect(adjacentThread(order, { file: 0, left: 3, right: 4 }, -1)?.id).toBe("a-right");
|
||||
expect(adjacentThread(order, { file: 0, left: 3, right: 3 }, -1)?.id).toBe("a-left");
|
||||
expect(adjacentThread(order, { file: 1, left: 9, right: 9 }, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Pure logic of review in the diff (IntelliJ's `GHPRDiffReviewViewModel`, `GHPRDiffViewModel`,
|
||||
* `GHPRThreadsViewModels`, `GHPRReviewNewCommentEditorViewModel`, `PatchHunkUtil`):
|
||||
*
|
||||
* - a file's patch (GitHub's `patch` of the changed file) parsed into hunks, the commentable
|
||||
* ranges of each side (the hunks' lines), line mapping across a patch, and the "position"
|
||||
* of a line (what commit comments are addressed by);
|
||||
* - where a review thread sits in the cumulative diff or in a single commit's diff, and
|
||||
* whether the "Show Review Threads" option shows it;
|
||||
* - the new comment's GitHub input (`line`, `side`, `startLine`, `startSide`) and its submit
|
||||
* actions;
|
||||
* - Previous / Next Comment: the threads in unified file / line order.
|
||||
*
|
||||
* Lines are 0-based unless the name says otherwise (GitHub's are 1-based).
|
||||
*/
|
||||
import type { GHDiffSide, GHPullRequestDraftReviewThread, GHPullRequestReviewThread } from "../api/types";
|
||||
|
||||
export type Side = "left" | "right";
|
||||
|
||||
export const toGitHubSide = (side: Side): GHDiffSide => (side === "left" ? "LEFT" : "RIGHT");
|
||||
export const fromGitHubSide = (side: GHDiffSide): Side => (side === "LEFT" ? "left" : "right");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Patches
|
||||
|
||||
export interface PatchHunk {
|
||||
/** 0-based first line of each side. */
|
||||
leftStart: number;
|
||||
leftCount: number;
|
||||
rightStart: number;
|
||||
rightCount: number;
|
||||
/** The rows after the header, with their first character (" ", "+", "-", "\"). */
|
||||
rows: string[];
|
||||
}
|
||||
|
||||
const HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
||||
|
||||
/** The hunks of a file's patch (GitHub's `patch`: hunks without the file header). */
|
||||
export function parsePatch(patch: string | null | undefined): PatchHunk[] {
|
||||
const rows = (patch ?? "").replace(/\r\n?/g, "\n").split("\n");
|
||||
while (rows.length && rows[rows.length - 1] === "") rows.pop();
|
||||
const hunks: PatchHunk[] = [];
|
||||
for (const row of rows) {
|
||||
const h = HEADER.exec(row);
|
||||
if (h) {
|
||||
const leftCount = h[2] === undefined ? 1 : Number(h[2]);
|
||||
const rightCount = h[4] === undefined ? 1 : Number(h[4]);
|
||||
// An empty side's start is the line before it (`-0,0`, `+10,0`).
|
||||
hunks.push({
|
||||
leftStart: leftCount ? Number(h[1]) - 1 : Number(h[1]),
|
||||
leftCount,
|
||||
rightStart: rightCount ? Number(h[3]) - 1 : Number(h[3]),
|
||||
rightCount,
|
||||
rows: [],
|
||||
});
|
||||
} else hunks[hunks.length - 1]?.rows.push(row || " ");
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** A half-open line range `[start, end)`. */
|
||||
export interface Span {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** The commentable lines of each side (`patch.ranges`): the lines of every hunk, context included. */
|
||||
export function commentableRanges(hunks: readonly PatchHunk[]): Record<Side, Span[]> {
|
||||
const out: Record<Side, Span[]> = { left: [], right: [] };
|
||||
for (const h of hunks) {
|
||||
if (h.leftCount) out.left.push({ start: h.leftStart, end: h.leftStart + h.leftCount });
|
||||
if (h.rightCount) out.right.push({ start: h.rightStart, end: h.rightStart + h.rightCount });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line of the other side matching `line` of `from` (`forcefullyMapLine`): outside the
|
||||
* hunks lines shift by what the hunks before added or removed; inside, context lines map
|
||||
* exactly and changed lines to the other side's line at that point.
|
||||
*/
|
||||
export function mapAcrossPatch(hunks: readonly PatchHunk[], from: Side, line: number): number {
|
||||
const mine = from === "left" ? "-" : "+";
|
||||
const theirs = from === "left" ? "+" : "-";
|
||||
let shift = 0;
|
||||
for (const h of hunks) {
|
||||
const [start, count, otherStart, otherCount] =
|
||||
from === "left"
|
||||
? [h.leftStart, h.leftCount, h.rightStart, h.rightCount]
|
||||
: [h.rightStart, h.rightCount, h.leftStart, h.leftCount];
|
||||
if (line < start) break;
|
||||
if (line < start + count) {
|
||||
let here = start;
|
||||
let there = otherStart;
|
||||
for (const row of h.rows) {
|
||||
const c = row[0];
|
||||
if (c === "\\") continue;
|
||||
if (c === theirs) there++;
|
||||
else {
|
||||
if (here === line) return there;
|
||||
here++;
|
||||
if (c !== mine) there++;
|
||||
}
|
||||
}
|
||||
return there;
|
||||
}
|
||||
shift = otherStart + otherCount - (start + count);
|
||||
}
|
||||
return Math.max(0, line + shift);
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub's diff "position" of `line` of `side` (`PatchHunkUtil.findDiffFileLineIndex`): the
|
||||
* row's index counted from the first hunk header (the row under it is 1, later headers
|
||||
* count too); null when the line is not in the patch.
|
||||
*/
|
||||
export function diffPosition(patch: string | null | undefined, side: Side, line: number): number | null {
|
||||
let position = 0;
|
||||
let started = false;
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
for (const row of (patch ?? "").replace(/\r\n?/g, "\n").split("\n")) {
|
||||
const h = HEADER.exec(row);
|
||||
if (h) {
|
||||
if (started) position++;
|
||||
started = true;
|
||||
left = Number(h[1]) - 1;
|
||||
right = Number(h[3]) - 1;
|
||||
continue;
|
||||
}
|
||||
if (!started) continue;
|
||||
position++;
|
||||
const c = row[0] ?? " ";
|
||||
if (c === "\\") continue;
|
||||
if (c !== "+") {
|
||||
if (side === "left" && left === line) return position;
|
||||
left++;
|
||||
}
|
||||
if (c !== "-") {
|
||||
if (side === "right" && right === line) return position;
|
||||
right++;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threads in a diff
|
||||
|
||||
/** A comment's place: the last line (where its inlay goes) and the first. */
|
||||
export interface CommentLocation {
|
||||
side: Side;
|
||||
line: number;
|
||||
startSide: Side;
|
||||
startLine: number;
|
||||
}
|
||||
|
||||
/** Which diff of the pull request is shown: the cumulative one, or one commit's (`commit`). */
|
||||
export interface ReviewDiffKind {
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `thread` sits in the diff (`mapThreadToChange`), or null when it can't be placed:
|
||||
* the cumulative diff places current threads at their `line` / `startLine`; a single commit's
|
||||
* diff places right-side threads made on that commit at their original lines (and those whose
|
||||
* current commit it is at their lines). Outdated threads have no place in the cumulative diff.
|
||||
*/
|
||||
export function threadLocation(thread: GHPullRequestReviewThread, diff: ReviewDiffKind): CommentLocation | null {
|
||||
if (thread.subjectType === "FILE") return null;
|
||||
const root = thread.comments.nodes[0];
|
||||
const side = fromGitHubSide(thread.diffSide);
|
||||
const at = (line: number | null, start: number | null): CommentLocation | null =>
|
||||
line == null
|
||||
? null
|
||||
: {
|
||||
side,
|
||||
line: line - 1,
|
||||
startSide: start == null ? side : fromGitHubSide(thread.startDiffSide ?? thread.diffSide),
|
||||
startLine: (start ?? line) - 1,
|
||||
};
|
||||
if (diff.commit === null) return at(thread.line, thread.startLine);
|
||||
if (side !== "right") return null;
|
||||
if (root?.originalCommit?.oid === diff.commit) return at(thread.originalLine, thread.originalStartLine);
|
||||
if (root?.commit?.oid === diff.commit) return at(thread.line, thread.startLine);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "Show Review Threads" (`DiscussionsViewOption`). */
|
||||
export type ThreadsViewOption = "all" | "unresolved" | "none";
|
||||
|
||||
export const THREADS_VIEW_OPTIONS: ReadonlyArray<{ value: ThreadsViewOption; label: string }> = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "unresolved", label: "Unresolved Only" },
|
||||
{ value: "none", label: "Do Not Show" },
|
||||
];
|
||||
|
||||
export function isThreadVisible(thread: Pick<GHPullRequestReviewThread, "isResolved">, option: ThreadsViewOption) {
|
||||
return option === "all" || (option === "unresolved" && !thread.isResolved);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New comments
|
||||
|
||||
/** `createThreadDTO`: the GitHub input of a new comment on `path` at `loc`. */
|
||||
export function draftThread(path: string, loc: CommentLocation, body: string): GHPullRequestDraftReviewThread {
|
||||
const single = loc.startSide === loc.side && loc.startLine === loc.line;
|
||||
return {
|
||||
body,
|
||||
path,
|
||||
line: loc.line + 1,
|
||||
side: toGitHubSide(loc.side),
|
||||
startLine: single ? null : loc.startLine + 1,
|
||||
startSide: single ? null : toGitHubSide(loc.startSide),
|
||||
};
|
||||
}
|
||||
|
||||
/** The location of a new comment on the 0-based lines `start`..`end` of one side. */
|
||||
export function newCommentLocation(side: Side, start: number, end: number): CommentLocation {
|
||||
return { side, line: Math.max(start, end), startSide: side, startLine: Math.min(start, end) };
|
||||
}
|
||||
|
||||
export type SubmitAction = "startReview" | "singleComment" | "reviewComment";
|
||||
|
||||
/**
|
||||
* The new comment's buttons, the primary one first: with a pending review "Add Review Comment";
|
||||
* else "Start Review" and "Add Single Comment", the one used last first (`reviewCommentsPreferred`).
|
||||
*/
|
||||
export function submitActions(hasPendingReview: boolean, reviewPreferred: boolean): SubmitAction[] {
|
||||
if (hasPendingReview) return ["reviewComment"];
|
||||
return reviewPreferred ? ["startReview", "singleComment"] : ["singleComment", "startReview"];
|
||||
}
|
||||
|
||||
export const SUBMIT_ACTION_TEXT: Record<SubmitAction, string> = {
|
||||
startReview: "Start Review",
|
||||
singleComment: "Add Single Comment",
|
||||
reviewComment: "Add Review Comment",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Previous / Next Comment
|
||||
|
||||
/** A thread's place in the unified order (`UnifiedCodeReviewItemPosition`): file, then right, then left line. */
|
||||
export interface ThreadPosition {
|
||||
id: string;
|
||||
/** Index of the file in the pull request's changes. */
|
||||
file: number;
|
||||
path: string;
|
||||
left: number;
|
||||
right: number;
|
||||
createdAt: string;
|
||||
/** Where navigation reveals it. */
|
||||
side: Side;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export function comparePositions(
|
||||
a: Pick<ThreadPosition, "file" | "left" | "right">,
|
||||
b: Pick<ThreadPosition, "file" | "left" | "right">,
|
||||
) {
|
||||
return a.file - b.file || a.right - b.right || a.left - b.left;
|
||||
}
|
||||
|
||||
/**
|
||||
* The threads of the cumulative diff in navigation order (`createSortedThreadPositionsAndIds`):
|
||||
* each thread's line is mapped to both sides with its file's patch.
|
||||
*/
|
||||
export function threadOrder(
|
||||
files: ReadonlyArray<{ path: string; hunks: readonly PatchHunk[] }>,
|
||||
threads: readonly GHPullRequestReviewThread[],
|
||||
): ThreadPosition[] {
|
||||
const index = new Map(files.map((f, i) => [f.path, i]));
|
||||
const out: ThreadPosition[] = [];
|
||||
for (const t of threads) {
|
||||
const file = index.get(t.path);
|
||||
const loc = threadLocation(t, { commit: null });
|
||||
if (file === undefined || !loc) continue;
|
||||
const other = mapAcrossPatch(files[file]!.hunks, loc.side, loc.line);
|
||||
out.push({
|
||||
id: t.id,
|
||||
file,
|
||||
path: t.path,
|
||||
left: loc.side === "left" ? loc.line : other,
|
||||
right: loc.side === "right" ? loc.line : other,
|
||||
createdAt: t.comments.nodes[0]?.createdAt ?? "",
|
||||
side: loc.side,
|
||||
line: loc.line,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => comparePositions(a, b) || a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible thread after (`dir` 1) or before (-1) the current one (`from.id`) or the caret
|
||||
* (`from` a position: the thread on the caret's line counts as next / previous).
|
||||
*/
|
||||
export function adjacentThread(
|
||||
order: readonly ThreadPosition[],
|
||||
from: { id: string } | Pick<ThreadPosition, "file" | "left" | "right">,
|
||||
dir: 1 | -1,
|
||||
visible: (id: string) => boolean = () => true,
|
||||
): ThreadPosition | null {
|
||||
let i: number;
|
||||
if ("id" in from) {
|
||||
const at = order.findIndex((p) => p.id === from.id);
|
||||
if (at < 0) return null;
|
||||
i = at + dir;
|
||||
} else if (dir === 1) {
|
||||
i = order.findIndex((p) => comparePositions(p, from) >= 0);
|
||||
if (i < 0) return null;
|
||||
} else {
|
||||
i = order.length - 1;
|
||||
while (i >= 0 && comparePositions(order[i]!, from) > 0) i--;
|
||||
}
|
||||
for (; i >= 0 && i < order.length; i += dir) if (visible(order[i]!.id)) return order[i]!;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* New review comments being written in diffs (IntelliJ's `GHPRThreadsViewModels.newComments`
|
||||
* with `GHPRReviewNewCommentEditorViewModel`): kept per pull request, file and diff (the
|
||||
* cumulative one or a commit's) while the diff tabs come and go, and submitted with
|
||||
*
|
||||
* - "Start Review" / "Add Single Comment" without a pending review: `createReview` with the
|
||||
* comment as its thread (pending, or posted at once with `event: COMMENT`); the choice
|
||||
* becomes the preferred button;
|
||||
* - "Add Review Comment" with one: `addPullRequestReviewThread` on the cumulative diff, a
|
||||
* commit comment at the line's diff position (merge base ↔ commit) on a commit's diff.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { compareChangedFiles } from "../api/requests";
|
||||
import { addCommitComment, createReview, createThread } from "../data/operations";
|
||||
import { loadPullRequestData } from "../data/pullRequest";
|
||||
import { diffPosition, draftThread, type CommentLocation, type SubmitAction } from "./model";
|
||||
import { setReviewDiffSettings } from "./settings";
|
||||
|
||||
export interface NewComment {
|
||||
id: string;
|
||||
number: number;
|
||||
/** Repository-relative path. */
|
||||
path: string;
|
||||
/** The commit whose diff it is on; null for the pull request's changes. */
|
||||
commit: string | null;
|
||||
location: CommentLocation;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
/** Bumped to move the focus into its editor. */
|
||||
focus: number;
|
||||
}
|
||||
|
||||
export const useNewComments = create<{ comments: NewComment[] }>(() => ({ comments: [] }));
|
||||
|
||||
let seq = 0;
|
||||
|
||||
const sameDiff = (c: NewComment, number: number, path: string, commit: string | null) =>
|
||||
c.number === number && c.path === path && c.commit === commit;
|
||||
|
||||
/** The new comments of a diff. */
|
||||
export function newCommentsOf(number: number, path: string, commit: string | null): NewComment[] {
|
||||
return useNewComments.getState().comments.filter((c) => sameDiff(c, number, path, commit));
|
||||
}
|
||||
|
||||
/** `requestNewComment`: opens a new comment at `location`, or focuses the one already there. */
|
||||
export function requestNewComment(
|
||||
number: number,
|
||||
path: string,
|
||||
commit: string | null,
|
||||
location: CommentLocation,
|
||||
): NewComment {
|
||||
const existing = newCommentsOf(number, path, commit).find(
|
||||
(c) =>
|
||||
c.location.side === location.side &&
|
||||
c.location.line === location.line &&
|
||||
c.location.startLine === location.startLine,
|
||||
);
|
||||
if (existing) {
|
||||
updateNewComment(existing.id, { focus: existing.focus + 1 });
|
||||
return existing;
|
||||
}
|
||||
const comment: NewComment = {
|
||||
id: `new-${++seq}`,
|
||||
number,
|
||||
path,
|
||||
commit,
|
||||
location,
|
||||
text: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
focus: 1,
|
||||
};
|
||||
useNewComments.setState((s) => ({ comments: [...s.comments, comment] }));
|
||||
return comment;
|
||||
}
|
||||
|
||||
export function updateNewComment(id: string, patch: Partial<Omit<NewComment, "id">>) {
|
||||
useNewComments.setState((s) => ({ comments: s.comments.map((c) => (c.id === id ? { ...c, ...patch } : c)) }));
|
||||
}
|
||||
|
||||
export function removeNewComment(id: string) {
|
||||
useNewComments.setState((s) => ({ comments: s.comments.filter((c) => c.id !== id) }));
|
||||
}
|
||||
|
||||
/** Forgets every new comment (another repository). */
|
||||
export function clearNewComments() {
|
||||
useNewComments.setState({ comments: [] });
|
||||
}
|
||||
|
||||
/** The revision the comment's diff shows on the right: the commit, else the pull request's head. */
|
||||
async function afterRevision(c: NewComment): Promise<string> {
|
||||
return c.commit ?? (await loadPullRequestData(c.number, "details")).headRefOid;
|
||||
}
|
||||
|
||||
/** Sends `body` with `action`; the comment is closed once it is created. */
|
||||
export async function submitNewComment(c: NewComment, action: SubmitAction, body: string): Promise<void> {
|
||||
if (action === "reviewComment") {
|
||||
const pending = await loadPullRequestData(c.number, "pendingReview");
|
||||
if (!pending) throw new Error("No pending review");
|
||||
if (c.commit === null) {
|
||||
const t = draftThread(c.path, c.location, body);
|
||||
await createThread(c.number, {
|
||||
reviewId: pending.id,
|
||||
body,
|
||||
path: c.path,
|
||||
line: t.line,
|
||||
side: t.side,
|
||||
startLine: t.startLine ?? undefined,
|
||||
startSide: t.startSide ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const pr = await loadPullRequestData(c.number, "details");
|
||||
const files = await compareChangedFiles(pr.baseRefOid, c.commit);
|
||||
const patch = files.find((f) => f.filename === c.path)?.patch;
|
||||
const position = diffPosition(patch, c.location.side, c.location.line);
|
||||
if (position === null) throw new Error("Can't map file line to diff");
|
||||
await addCommitComment(c.number, {
|
||||
reviewId: pending.id,
|
||||
body,
|
||||
commitSha: c.commit,
|
||||
path: c.path,
|
||||
position,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await createReview(c.number, {
|
||||
event: action === "singleComment" ? "COMMENT" : undefined,
|
||||
commitOid: await afterRevision(c),
|
||||
threads: [draftThread(c.path, c.location, body)],
|
||||
});
|
||||
setReviewDiffSettings({ reviewCommentsPreferred: action === "startReview" });
|
||||
}
|
||||
removeNewComment(c.id);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { ContextMenuHost } from "@/ui";
|
||||
import type { EditorInput } from "@/workbench/contributions";
|
||||
|
||||
const texts: Record<string, string> = {};
|
||||
const ghApi = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
fsProbe: () => Promise.resolve({ status: "error", error: "none" }),
|
||||
vcsFileAt: (_path: string, rev: string) => Promise.resolve({ status: "ok", data: texts[rev] ?? "" }),
|
||||
ghApi: (req: unknown) => ghApi(req),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { DiffEditor } from "@/features/vcs/DiffEditor";
|
||||
import { setCurrentRepository } from "../api/client";
|
||||
import type {
|
||||
GHCommitFile,
|
||||
GHPullRequest,
|
||||
GHPullRequestReviewComment,
|
||||
GHPullRequestReviewThread,
|
||||
GHRepository,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import { PULL_REQUEST_DIFF } from "../data/local";
|
||||
import { clearPullRequests, setPullRequestData } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { activateReviewDiff } from ".";
|
||||
import { clearNewComments, requestNewComment, useNewComments } from "./newComments";
|
||||
import { newCommentLocation } from "./model";
|
||||
import { setReviewDiffSettings, useReviewDiffSettings } from "./settings";
|
||||
|
||||
const N = 7;
|
||||
const PATH = "src/a.ts";
|
||||
const BASE = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
||||
const HEAD = BASE.replace("line 3\n", "line three\n");
|
||||
const PATCH = "@@ -1,6 +1,6 @@\n line 1\n line 2\n-line 3\n+line three\n line 4\n line 5\n line 6";
|
||||
|
||||
const me: GHUser = { __typename: "User", id: "U1", login: "me", url: "", avatarUrl: "", name: null };
|
||||
|
||||
let commentSeq = 0;
|
||||
const comment = (body: string, over: Partial<GHPullRequestReviewComment> = {}): GHPullRequestReviewComment =>
|
||||
({
|
||||
id: `C${++commentSeq}`,
|
||||
author: { __typename: "User", login: "alice", url: "", avatarUrl: "" },
|
||||
body,
|
||||
createdAt: "2026-10-01T10:00:00Z",
|
||||
url: "",
|
||||
state: "SUBMITTED",
|
||||
commit: { oid: "head", abbreviatedOid: "head" },
|
||||
originalCommit: { oid: "head", abbreviatedOid: "head" },
|
||||
diffHunk: "",
|
||||
pullRequestReview: { id: "R0" },
|
||||
viewerCanDelete: false,
|
||||
viewerCanUpdate: false,
|
||||
viewerCanReact: false,
|
||||
reactions: { nodes: [] },
|
||||
...over,
|
||||
}) as GHPullRequestReviewComment;
|
||||
|
||||
const thread = (id: string, line: number, bodies: string[], over: Partial<GHPullRequestReviewThread> = {}) =>
|
||||
({
|
||||
id,
|
||||
isResolved: false,
|
||||
isOutdated: false,
|
||||
path: PATH,
|
||||
subjectType: "LINE",
|
||||
diffSide: "RIGHT",
|
||||
line,
|
||||
originalLine: line,
|
||||
startDiffSide: null,
|
||||
startLine: null,
|
||||
originalStartLine: null,
|
||||
comments: { nodes: bodies.map((b) => comment(b)) },
|
||||
viewerCanReply: true,
|
||||
viewerCanResolve: true,
|
||||
viewerCanUnresolve: false,
|
||||
...over,
|
||||
}) as GHPullRequestReviewThread;
|
||||
|
||||
const file: GHCommitFile = {
|
||||
sha: null,
|
||||
filename: PATH,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
changes: 2,
|
||||
patch: PATCH,
|
||||
previousFilename: null,
|
||||
};
|
||||
|
||||
const input = (commit: string | null = null) =>
|
||||
({
|
||||
kind: "diff",
|
||||
key: `diff:${commit}`,
|
||||
path: `/p/${PATH}`,
|
||||
data: {
|
||||
path: `/p/${PATH}`,
|
||||
left: { type: "rev", rev: commit ? `${commit}^` : "base" },
|
||||
right: { type: "rev", rev: commit ?? "head" },
|
||||
context: { kind: PULL_REQUEST_DIFF, number: N, path: PATH, commit },
|
||||
},
|
||||
}) as unknown as EditorInput;
|
||||
|
||||
/** The GraphQL / REST calls made, by operation. */
|
||||
function calls(op: RegExp) {
|
||||
return ghApi.mock.calls
|
||||
.map(([req]) => req as { endpoint: string; body?: string })
|
||||
.filter((r) => op.test(r.endpoint === "graphql" ? (JSON.parse(r.body!) as { query: string }).query : r.endpoint))
|
||||
.map((r) =>
|
||||
r.endpoint === "graphql" ? (JSON.parse(r.body!) as { variables: Record<string, unknown> }).variables : r,
|
||||
);
|
||||
}
|
||||
|
||||
function respond(req: { endpoint: string; body?: string }): string {
|
||||
if (req.endpoint.includes("/compare/")) return JSON.stringify({ files: [{ ...file, patch: PATCH }] });
|
||||
const q = req.endpoint === "graphql" ? (JSON.parse(req.body!) as { query: string }).query : "";
|
||||
const t = thread("T-new", 4, ["new"]);
|
||||
if (/addPullRequestReview\s*\(/.test(q))
|
||||
return JSON.stringify({
|
||||
data: {
|
||||
addPullRequestReview: { pullRequestReview: { id: "PRR1", state: "PENDING", comments: { totalCount: 1 } } },
|
||||
},
|
||||
});
|
||||
if (/addPullRequestReviewThread\s*\(/.test(q))
|
||||
return JSON.stringify({ data: { addPullRequestReviewThread: { thread: t } } });
|
||||
if (/addPullRequestReviewComment\s*\(/.test(q))
|
||||
return JSON.stringify({ data: { addPullRequestReviewComment: { comment: comment("c") } } });
|
||||
if (/resolveReviewThread\s*\(/.test(q))
|
||||
return JSON.stringify({ data: { resolveReviewThread: { thread: { ...t, isResolved: true } } } });
|
||||
// Reloads fail: the cached data stays.
|
||||
return JSON.stringify({ errors: [{ message: "not stubbed" }] });
|
||||
}
|
||||
|
||||
let deactivate: () => void = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useReviewDiffSettings.setState({ threadsView: "all", reviewCommentsPreferred: true, autoMarkViewed: false });
|
||||
texts.base = BASE;
|
||||
texts.head = HEAD;
|
||||
texts["c1^"] = BASE;
|
||||
texts.c1 = HEAD;
|
||||
ghApi.mockReset();
|
||||
ghApi.mockImplementation((req: { endpoint: string; body?: string }) =>
|
||||
Promise.resolve({ status: "ok", data: respond(req) }),
|
||||
);
|
||||
setCurrentRepository({ owner: "o", name: "r", id: "R1" });
|
||||
useGhRepository.setState({
|
||||
connection: {
|
||||
coordinates: { owner: "o", name: "r" },
|
||||
repository: { id: "R1", viewerPermission: "READ" } as GHRepository,
|
||||
currentUser: me,
|
||||
},
|
||||
});
|
||||
setPullRequestData(
|
||||
N,
|
||||
"details",
|
||||
() => ({ id: "PR1", number: N, headRefOid: "head", baseRefOid: "base" }) as GHPullRequest,
|
||||
);
|
||||
setPullRequestData(N, "changes", () => [file]);
|
||||
setPullRequestData(N, "viewedState", () => ({ [PATH]: "UNVIEWED" }));
|
||||
setPullRequestData(N, "pendingReview", () => null);
|
||||
setPullRequestData(N, "threads", () => [
|
||||
thread("T1", 3, ["first", "second", "third", "fourth"]),
|
||||
thread("T2", 5, ["done"], { isResolved: true }),
|
||||
]);
|
||||
deactivate = activateReviewDiff();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
deactivate();
|
||||
clearNewComments();
|
||||
clearPullRequests();
|
||||
useGhRepository.setState({ connection: null });
|
||||
});
|
||||
|
||||
const view = (commit: string | null = null) =>
|
||||
render(
|
||||
<>
|
||||
<DiffEditor input={input(commit)} />
|
||||
<ContextMenuHost />
|
||||
</>,
|
||||
);
|
||||
|
||||
/** The new comment's inlay, once shown. */
|
||||
const newCommentBox = () =>
|
||||
waitFor(() => {
|
||||
const el = document.querySelector<HTMLElement>("[data-new-comment]");
|
||||
if (!el) throw new Error("no new comment inlay");
|
||||
return el;
|
||||
});
|
||||
|
||||
const inlay = (id: string) => document.querySelector<HTMLElement>(`[data-thread-inlay="${id}"]`);
|
||||
|
||||
describe("review threads in a pull request diff", () => {
|
||||
it("shows threads as compact inlays under their lines, filtered by Show Review Threads", async () => {
|
||||
view();
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
const t1 = inlay("T1")!;
|
||||
// More than 3 comments: the first, "… 2 more replies", the last.
|
||||
expect(t1.textContent).toContain("first");
|
||||
expect(t1.textContent).toContain("2 more replies");
|
||||
expect(t1.textContent).not.toContain("second");
|
||||
expect(t1.textContent).toContain("fourth");
|
||||
// Under line 3 of the right side.
|
||||
const right = document.querySelector(".cm-merge-b")!;
|
||||
expect(right.contains(t1)).toBe(true);
|
||||
expect(t1.closest(".cm-lineInlay")!.previousElementSibling?.textContent).toBe("line three");
|
||||
expect(right.querySelectorAll(".cm-reviewControl-toggle")).toHaveLength(2);
|
||||
expect(inlay("T2")).not.toBeNull();
|
||||
|
||||
act(() => setReviewDiffSettings({ threadsView: "unresolved" }));
|
||||
await waitFor(() => expect(inlay("T2")).toBeNull());
|
||||
expect(inlay("T1")).not.toBeNull();
|
||||
act(() => setReviewDiffSettings({ threadsView: "none" }));
|
||||
await waitFor(() => expect(inlay("T1")).toBeNull());
|
||||
});
|
||||
|
||||
it("follows the threads when the data reloads", async () => {
|
||||
view();
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
act(() => setPullRequestData(N, "threads", () => [thread("T9", 1, ["fresh"])]));
|
||||
await waitFor(() => expect(inlay("T9")).not.toBeNull());
|
||||
expect(inlay("T1")).toBeNull();
|
||||
});
|
||||
|
||||
it("replies to and resolves a thread", async () => {
|
||||
setPullRequestData(N, "pendingReview", () => ({ id: "PRR0", state: "PENDING", commentsCount: 0 }));
|
||||
view();
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
const t1 = within(inlay("T1")!);
|
||||
fireEvent.click(t1.getByRole("button", { name: "Reply" }));
|
||||
fireEvent.change(t1.getByRole("textbox"), { target: { value: "thanks" } });
|
||||
const replies = t1.getAllByRole("button", { name: "Reply" });
|
||||
fireEvent.click(replies[replies.length - 1]!);
|
||||
await waitFor(() =>
|
||||
expect(calls(/addPullRequestReviewComment/)).toEqual([
|
||||
expect.objectContaining({ reviewId: "PRR0", body: "thanks", inReplyTo: expect.stringMatching(/^C/) }),
|
||||
]),
|
||||
);
|
||||
fireEvent.click(within(inlay("T1")!).getAllByRole("button", { name: "Resolve" })[0]!);
|
||||
await waitFor(() => expect(calls(/resolveReviewThread/)).toEqual([{ threadId: "T1" }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("new comments", () => {
|
||||
it("Start Review creates a pending review with the comment's thread and becomes the preferred action", async () => {
|
||||
view();
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
act(() => void requestNewComment(N, PATH, null, newCommentLocation("right", 1, 3)));
|
||||
const box = await newCommentBox();
|
||||
// Without a pending review: Add Single Comment, then the preferred Start Review (primary).
|
||||
const buttons = within(box).getAllByRole("button");
|
||||
expect(buttons.map((b) => b.textContent)).toEqual(["Add Single Comment", "Start Review"]);
|
||||
// The range is outlined; on the changes it can be adjusted.
|
||||
expect(document.querySelectorAll(".cm-merge-b .cm-reviewRange")).toHaveLength(3);
|
||||
expect(document.querySelector<HTMLElement>(".cm-reviewHandle")?.title).toBe(
|
||||
"Drag to select several lines for commenting",
|
||||
);
|
||||
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "Looks odd" } });
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Start Review" }));
|
||||
await waitFor(() => expect(useNewComments.getState().comments).toHaveLength(0));
|
||||
expect(calls(/addPullRequestReview\s*\(/)).toEqual([
|
||||
{
|
||||
pullRequestId: "PR1",
|
||||
commitOid: "head",
|
||||
threads: [{ body: "Looks odd", path: PATH, line: 4, side: "RIGHT", startLine: 2, startSide: "RIGHT" }],
|
||||
},
|
||||
]);
|
||||
expect(useReviewDiffSettings.getState().reviewCommentsPreferred).toBe(true);
|
||||
});
|
||||
|
||||
it("Add Single Comment posts the comment at once and becomes the primary button", async () => {
|
||||
view();
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
act(() => void requestNewComment(N, PATH, null, newCommentLocation("left", 4, 4)));
|
||||
const box = await newCommentBox();
|
||||
// A left-side comment goes into the left pane.
|
||||
expect(document.querySelector(".cm-merge-a")!.contains(box)).toBe(true);
|
||||
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "hm" } });
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Add Single Comment" }));
|
||||
await waitFor(() => expect(calls(/addPullRequestReview\s*\(/)).toHaveLength(1));
|
||||
expect(calls(/addPullRequestReview\s*\(/)[0]).toMatchObject({
|
||||
event: "COMMENT",
|
||||
threads: [{ body: "hm", path: PATH, line: 5, side: "LEFT", startLine: null, startSide: null }],
|
||||
});
|
||||
await waitFor(() => expect(useReviewDiffSettings.getState().reviewCommentsPreferred).toBe(false));
|
||||
});
|
||||
|
||||
it("Add Review Comment adds a thread to the pending review", async () => {
|
||||
setPullRequestData(N, "pendingReview", () => ({ id: "PRR0", state: "PENDING", commentsCount: 2 }));
|
||||
view();
|
||||
await screen.findByRole("button", { name: "Submit Review (2)…" });
|
||||
act(() => void requestNewComment(N, PATH, null, newCommentLocation("right", 0, 0)));
|
||||
const box = await newCommentBox();
|
||||
expect(
|
||||
within(box)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(["Add Review Comment"]);
|
||||
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "nit" } });
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Add Review Comment" }));
|
||||
await waitFor(() =>
|
||||
expect(calls(/addPullRequestReviewThread/)).toEqual([
|
||||
{
|
||||
pullRequestReviewId: "PRR0",
|
||||
body: "nit",
|
||||
path: PATH,
|
||||
line: 1,
|
||||
side: "RIGHT",
|
||||
startLine: null,
|
||||
startSide: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("comments on a single commit's diff at the line's diff position, with a fixed range", async () => {
|
||||
setPullRequestData(N, "pendingReview", () => ({ id: "PRR0", state: "PENDING", commentsCount: 0 }));
|
||||
view("c1");
|
||||
await waitFor(() => expect(inlay("T1")).toBeNull());
|
||||
act(() => void requestNewComment(N, PATH, "c1", newCommentLocation("right", 2, 2)));
|
||||
const box = await newCommentBox();
|
||||
expect(document.querySelector<HTMLElement>(".cm-reviewHandle")?.title).toBe(
|
||||
"Comment range cannot be adjusted while reviewing a single commit",
|
||||
);
|
||||
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "here" } });
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Add Review Comment" }));
|
||||
await waitFor(() =>
|
||||
expect(calls(/addPullRequestReviewComment/)).toEqual([
|
||||
{ reviewId: "PRR0", body: "here", commit: "c1", file: PATH, position: 4 },
|
||||
]),
|
||||
);
|
||||
expect(calls(/compare\/base\.\.\.c1/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("discards an empty new comment on Escape", async () => {
|
||||
view();
|
||||
act(() => void requestNewComment(N, PATH, null, newCommentLocation("right", 1, 1)));
|
||||
const box = await newCommentBox();
|
||||
fireEvent.keyDown(within(box).getByRole("textbox"), { key: "Escape" });
|
||||
await waitFor(() => expect(document.querySelector("[data-new-comment]")).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewed state", () => {
|
||||
it("marks the file viewed from the editor menu", async () => {
|
||||
view();
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
const line = [...document.querySelectorAll(".cm-merge-b .cm-line")].find((l) => l.textContent === "line 2")!;
|
||||
fireEvent.contextMenu(line, { clientX: 5, clientY: 5 });
|
||||
const item = await screen.findByRole("menuitem", { name: /Mark File as Viewed/ });
|
||||
expect(screen.getByRole("menuitem", { name: "Add Review Comment" })).toBeTruthy();
|
||||
fireEvent.click(item);
|
||||
await waitFor(() => expect(calls(/markFileAsViewed/)).toEqual([{ pullRequestId: "PR1", path: PATH }]));
|
||||
});
|
||||
|
||||
it("marks opened files viewed when Automatically mark opened files as viewed is on", async () => {
|
||||
useReviewDiffSettings.setState({ autoMarkViewed: true });
|
||||
view();
|
||||
await waitFor(() => expect(calls(/markFileAsViewed/)).toEqual([{ pullRequestId: "PR1", path: PATH }]));
|
||||
});
|
||||
|
||||
it("does not mark a single commit's file viewed", async () => {
|
||||
useReviewDiffSettings.setState({ autoMarkViewed: true });
|
||||
view("c1");
|
||||
await screen.findByRole("button", { name: "Submit Review…" });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(calls(/markFileAsViewed/)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Review in a pull request's diff (IntelliJ's `GHPRReviewDiffExtension` with its
|
||||
* `DiffEditorModel`): a diff viewer contribution for diffs whose context is a pull request's
|
||||
* file (`PULL_REQUEST_DIFF`). For each pane it keeps
|
||||
*
|
||||
* - the inlays: the visible review threads under their lines (on their side; the unified
|
||||
* viewer maps left lines to the right side) and the new comments being written;
|
||||
* - the gutter controls: comment icons, the "+" on commentable lines (the patch's hunks of
|
||||
* that side, `canComment` = read access), new comments' range outlines (adjustable on the
|
||||
* pull request's changes unless the text has a suggestion).
|
||||
*
|
||||
* It follows the pull request's data, the new comments and the settings, and adds the
|
||||
* toolbar (Refresh Review Data, Submit Review…, Previous / Next Comment), "Show Review
|
||||
* Threads" to the viewer settings and the editor menu items (Add Review Comment, Add
|
||||
* Multiline Review Comment, Mark File as Viewed). Opening it marks the file viewed when
|
||||
* "Automatically mark opened files as viewed" is on.
|
||||
*/
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin } from "@codemirror/view";
|
||||
import type { DiffData } from "@/features/vcs/diffInput";
|
||||
import type {
|
||||
DiffPaneInfo,
|
||||
DiffPaneSide,
|
||||
DiffViewerContribution,
|
||||
DiffViewerHost,
|
||||
DiffViewerPane,
|
||||
} from "@/features/vcs/diffViewerProviders";
|
||||
import { lineInlays, setLineInlays, type LineInlay } from "@/features/vcs/lineInlays";
|
||||
import {
|
||||
EMPTY_REVIEW_GUTTER,
|
||||
canCreateComment,
|
||||
isLineCommentable,
|
||||
reviewGutter,
|
||||
setReviewGutterState,
|
||||
type LineRange,
|
||||
type ReviewGutterState,
|
||||
type ReviewOutline,
|
||||
} from "@/features/vcs/reviewGutter";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { commitChangedFiles } from "../api/requests";
|
||||
import type { GHCommitFile, GHPullRequestReviewThread } from "../api/types";
|
||||
import { openPullRequestFileDiff, PULL_REQUEST_DIFF } from "../data/local";
|
||||
import { setViewed } from "../data/operations";
|
||||
import {
|
||||
loadPullRequestData,
|
||||
pullRequestData,
|
||||
reloadPullRequestData,
|
||||
subscribePullRequestData,
|
||||
} from "../data/pullRequest";
|
||||
import { currentUserHasPermission, useGhRepository } from "../data/repository";
|
||||
import { containsSuggestedChange } from "../timeline/model";
|
||||
import { discardNewComment, NewCommentInlay, ThreadInlay } from "./Inlays";
|
||||
import {
|
||||
adjacentThread,
|
||||
commentableRanges,
|
||||
isThreadVisible,
|
||||
mapAcrossPatch,
|
||||
newCommentLocation,
|
||||
parsePatch,
|
||||
threadLocation,
|
||||
threadOrder,
|
||||
THREADS_VIEW_OPTIONS,
|
||||
type CommentLocation,
|
||||
type PatchHunk,
|
||||
type Side,
|
||||
type Span,
|
||||
} from "./model";
|
||||
import { newCommentsOf, requestNewComment, updateNewComment, useNewComments } from "./newComments";
|
||||
import { setReviewDiffSettings, useReviewDiffSettings } from "./settings";
|
||||
import { ReviewToolbar } from "./ReviewToolbar";
|
||||
|
||||
export interface ReviewDiffContext {
|
||||
number: number;
|
||||
/** Repository-relative path. */
|
||||
path: string;
|
||||
/** The commit whose diff this is; null for the pull request's changes. */
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
/** The pull request file a diff shows, from its context; null for other diffs. */
|
||||
export function reviewDiffContext(data: DiffData): ReviewDiffContext | null {
|
||||
const c = data.context;
|
||||
if (c?.kind !== PULL_REQUEST_DIFF || typeof c.number !== "number" || typeof c.path !== "string") return null;
|
||||
return { number: c.number, path: c.path, commit: typeof c.commit === "string" ? c.commit : null };
|
||||
}
|
||||
|
||||
/** Tooltips of a new comment's range outline (`AdjustmentDisabledReason`). */
|
||||
const outlineTooltip = (reason: "suggestion" | "commit" | null) =>
|
||||
reason === "commit"
|
||||
? tx("Comment range cannot be adjusted while reviewing a single commit")
|
||||
: reason === "suggestion"
|
||||
? tx("The range of this comment is not adjustable as it contains suggestion")
|
||||
: tx("Drag to select several lines for commenting");
|
||||
|
||||
/** The files of single commits (their patches give the commentable lines), per commit. */
|
||||
const commitFiles = new Map<string, Promise<GHCommitFile[]>>();
|
||||
|
||||
function filesOfCommit(sha: string): Promise<GHCommitFile[]> {
|
||||
let p = commitFiles.get(sha);
|
||||
if (!p) {
|
||||
p = commitChangedFiles(sha);
|
||||
p.catch(() => commitFiles.delete(sha));
|
||||
commitFiles.set(sha, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Parsed patches, by patch text. */
|
||||
const hunkCache = new Map<string, PatchHunk[]>();
|
||||
|
||||
function hunksOf(patch: string | null | undefined): PatchHunk[] {
|
||||
if (!patch) return [];
|
||||
let h = hunkCache.get(patch);
|
||||
if (!h) {
|
||||
h = parsePatch(patch);
|
||||
if (hunkCache.size > 500) hunkCache.clear();
|
||||
hunkCache.set(patch, h);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The thread Previous / Next Comment moved to last (navigation goes on from it while the caret stays). */
|
||||
let navigated: { number: number; id: string; path: string; side: Side; line: number } | null = null;
|
||||
|
||||
/** The review diff focused last (the editor actions apply to it). */
|
||||
let active: ReviewDiff | null = null;
|
||||
|
||||
export const activeReviewDiff = () => active;
|
||||
|
||||
function setActive(diff: ReviewDiff | null) {
|
||||
active = diff;
|
||||
setContext("githubReviewDiff", !!diff);
|
||||
}
|
||||
|
||||
export class ReviewDiff implements DiffViewerContribution {
|
||||
private host: DiffViewerHost | null = null;
|
||||
/** Threads hidden with the gutter's comment icon (`Hideable`). */
|
||||
private hidden = new Set<string>();
|
||||
private commitPatch: string | null | undefined = undefined;
|
||||
private gutters = new Map<EditorView, ReviewGutterState>();
|
||||
private disposers: Array<() => void> = [];
|
||||
readonly Toolbar = () => <ReviewToolbar diff={this} />;
|
||||
|
||||
constructor(readonly ctx: ReviewDiffContext) {
|
||||
const n = ctx.number;
|
||||
for (const key of ["details", "threads", "pendingReview", "changes", "viewedState"] as const)
|
||||
void loadPullRequestData(n, key).catch(() => undefined);
|
||||
if (ctx.commit)
|
||||
void filesOfCommit(ctx.commit).then(
|
||||
(files) => {
|
||||
this.commitPatch = files.find((f) => f.filename === ctx.path)?.patch ?? null;
|
||||
this.push();
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
const push = () => this.push();
|
||||
this.disposers.push(
|
||||
subscribePullRequestData(n, push),
|
||||
useNewComments.subscribe(push),
|
||||
useReviewDiffSettings.subscribe(push),
|
||||
useGhRepository.subscribe(push),
|
||||
);
|
||||
void this.autoMarkViewed();
|
||||
}
|
||||
|
||||
get number() {
|
||||
return this.ctx.number;
|
||||
}
|
||||
|
||||
/** Previous / Next Comment work on the pull request's changes only (`canNavigate`). */
|
||||
get canNavigate() {
|
||||
return this.ctx.commit === null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposers.forEach((d) => d());
|
||||
this.disposers = [];
|
||||
if (active === this) setActive(null);
|
||||
}
|
||||
|
||||
extensions(pane: DiffPaneInfo): Extension {
|
||||
const side: Side = pane.unified ? "right" : pane.side;
|
||||
const focus = ViewPlugin.define((view) => {
|
||||
const onFocus = () => setActive(this);
|
||||
view.dom.addEventListener("focusin", onFocus);
|
||||
return { destroy: () => view.dom.removeEventListener("focusin", onFocus) };
|
||||
});
|
||||
return [
|
||||
lineInlays(),
|
||||
reviewGutter({
|
||||
requestNewComment: (range) => this.request(side, range),
|
||||
cancelNewComment: (line) => this.cancelAt(side, line),
|
||||
toggleComments: (line) => this.toggleAt(side, line),
|
||||
adjustRange: (key, range) =>
|
||||
updateNewComment(key, { location: newCommentLocation(side, range.start, range.end) }),
|
||||
}),
|
||||
focus,
|
||||
];
|
||||
}
|
||||
|
||||
attach(host: DiffViewerHost) {
|
||||
this.host = host;
|
||||
this.push();
|
||||
return () => {
|
||||
if (this.host === host) this.host = null;
|
||||
for (const p of host.panes()) this.gutters.delete(p.view);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
|
||||
private threads(): GHPullRequestReviewThread[] {
|
||||
return (pullRequestData(this.number, "threads").value ?? []).filter((t) => t.path === this.ctx.path);
|
||||
}
|
||||
|
||||
/** The commentable lines of each side, once the patch is known. */
|
||||
private commentable(): Record<Side, Span[]> | null {
|
||||
if (!currentUserHasPermission("READ")) return null;
|
||||
if (this.ctx.commit) return this.commitPatch === undefined ? null : commentableRanges(hunksOf(this.commitPatch));
|
||||
const files = pullRequestData(this.number, "changes").value;
|
||||
if (!files) return null;
|
||||
return commentableRanges(hunksOf(files.find((f) => f.filename === this.ctx.path)?.patch));
|
||||
}
|
||||
|
||||
/** `line` of `side` in `pane`, or null when it shows elsewhere. */
|
||||
private lineIn(pane: DiffViewerPane, side: Side, line: number): number | null {
|
||||
const at = this.host?.locate(side, line);
|
||||
return at && at.pane.view === pane.view ? at.line : null;
|
||||
}
|
||||
|
||||
private outlineOf(pane: DiffViewerPane, key: string, loc: CommentLocation, text: string): ReviewOutline | null {
|
||||
const end = this.lineIn(pane, loc.side, loc.line);
|
||||
const start = this.lineIn(pane, loc.startSide, loc.startLine);
|
||||
if (end === null || start === null) return null;
|
||||
const reason = this.ctx.commit ? "commit" : containsSuggestedChange(text) ? "suggestion" : null;
|
||||
return {
|
||||
key,
|
||||
range: { start: Math.min(start, end), end: Math.max(start, end) },
|
||||
adjustable: reason === null,
|
||||
tooltip: outlineTooltip(reason),
|
||||
};
|
||||
}
|
||||
|
||||
/** Recomputes and pushes every pane's inlays and gutter state. */
|
||||
push() {
|
||||
const host = this.host;
|
||||
if (!host) return;
|
||||
const n = this.number;
|
||||
const option = useReviewDiffSettings.getState().threadsView;
|
||||
const threads = this.threads();
|
||||
const drafts = newCommentsOf(n, this.ctx.path, this.ctx.commit);
|
||||
const ranges = this.commentable();
|
||||
for (const pane of host.panes()) {
|
||||
const inlays: LineInlay[] = [];
|
||||
const withComments = new Set<number>();
|
||||
const withInlays = new Set<number>();
|
||||
const withNew = new Set<number>();
|
||||
const outlines: ReviewOutline[] = [];
|
||||
for (const t of threads) {
|
||||
const loc = isThreadVisible(t, option) ? threadLocation(t, this.ctx) : null;
|
||||
const line = loc && this.lineIn(pane, loc.side, loc.line);
|
||||
if (line == null) continue;
|
||||
withComments.add(line);
|
||||
if (this.hidden.has(t.id)) continue;
|
||||
withInlays.add(line);
|
||||
inlays.push({ key: t.id, line, content: <ThreadInlay number={n} threadId={t.id} /> });
|
||||
}
|
||||
for (const c of drafts) {
|
||||
const line = this.lineIn(pane, c.location.side, c.location.line);
|
||||
if (line === null) continue;
|
||||
withNew.add(line);
|
||||
withInlays.add(line);
|
||||
inlays.push({ key: c.id, line, content: <NewCommentInlay id={c.id} /> });
|
||||
const outline = this.outlineOf(pane, c.id, c.location, c.text);
|
||||
if (outline) outlines.push(outline);
|
||||
}
|
||||
const gutter: ReviewGutterState = {
|
||||
linesWithComments: withComments,
|
||||
linesWithNewComments: withNew,
|
||||
linesWithInlays: withInlays,
|
||||
// The unified viewer's lines are the right side's.
|
||||
commentable: ranges ? ranges[host.mode === "unified" ? "right" : pane.side] : [],
|
||||
outlines,
|
||||
};
|
||||
this.gutters.set(pane.view, gutter);
|
||||
pane.view.dispatch({ effects: [setLineInlays.of(inlays), setReviewGutterState.of(gutter)] });
|
||||
}
|
||||
}
|
||||
|
||||
private gutterOf(view: EditorView): ReviewGutterState {
|
||||
return this.gutters.get(view) ?? EMPTY_REVIEW_GUTTER;
|
||||
}
|
||||
|
||||
private paneOf(side: Side): DiffViewerPane | null {
|
||||
const panes = this.host?.panes() ?? [];
|
||||
return panes.find((p) => p.side === side) ?? (this.host?.mode === "unified" ? (panes[0] ?? null) : null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gutter requests (lines of the pane showing `side`)
|
||||
|
||||
private request(side: Side, range: LineRange) {
|
||||
requestNewComment(this.number, this.ctx.path, this.ctx.commit, newCommentLocation(side, range.start, range.end));
|
||||
}
|
||||
|
||||
private cancelAt(side: Side, line: number) {
|
||||
const c = newCommentsOf(this.number, this.ctx.path, this.ctx.commit).find(
|
||||
(x) => x.location.side === side && x.location.line === line,
|
||||
);
|
||||
if (c) void discardNewComment(c);
|
||||
}
|
||||
|
||||
/** Shows the threads of the line, or hides them when any is shown (`syncOrToggleAll`). */
|
||||
private toggleAt(side: Side, line: number) {
|
||||
const pane = this.paneOf(side);
|
||||
if (!pane) return;
|
||||
const ids = this.threads()
|
||||
.filter((t) => {
|
||||
const loc = threadLocation(t, this.ctx);
|
||||
return !!loc && this.lineIn(pane, loc.side, loc.line) === line;
|
||||
})
|
||||
.map((t) => t.id);
|
||||
const anyShown = ids.some((id) => !this.hidden.has(id));
|
||||
for (const id of ids) {
|
||||
if (anyShown) this.hidden.add(id);
|
||||
else this.hidden.delete(id);
|
||||
}
|
||||
this.push();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
|
||||
/** "Add Review Comment" on the caret's line, "Add Multiline Review Comment" on the selected lines. */
|
||||
addComment(multiline: boolean, at?: { pane: DiffViewerPane; line: number }) {
|
||||
const pane = at?.pane ?? this.host?.focusedPane();
|
||||
if (!pane) return;
|
||||
const range = this.commentRange(pane, multiline, at?.line);
|
||||
if (range) this.request(this.host?.mode === "unified" ? "right" : pane.side, range);
|
||||
}
|
||||
|
||||
/** The range the actions would comment on, when they can. */
|
||||
commentRange(pane: DiffViewerPane, multiline: boolean, line?: number): LineRange | null {
|
||||
const state = pane.view.state;
|
||||
const sel = state.selection.main;
|
||||
const gutter = this.gutterOf(pane.view);
|
||||
if (multiline) {
|
||||
const range = { start: state.doc.lineAt(sel.from).number - 1, end: state.doc.lineAt(sel.to).number - 1 };
|
||||
return range.start < range.end && canCreateComment(gutter, range) ? range : null;
|
||||
}
|
||||
const l = line ?? state.doc.lineAt(sel.head).number - 1;
|
||||
return isLineCommentable(gutter, l) ? { start: l, end: l } : null;
|
||||
}
|
||||
|
||||
/** "Mark File as Viewed" / "Mark File as Not Viewed": the current state, null without one (a commit's diff). */
|
||||
viewed(): boolean | null {
|
||||
if (this.ctx.commit) return null;
|
||||
const state = pullRequestData(this.number, "viewedState").value;
|
||||
return state ? state[this.ctx.path] === "VIEWED" : null;
|
||||
}
|
||||
|
||||
toggleViewed() {
|
||||
const viewed = this.viewed();
|
||||
if (viewed !== null) void setViewed(this.number, [this.ctx.path], !viewed).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async autoMarkViewed() {
|
||||
if (this.ctx.commit || !useReviewDiffSettings.getState().autoMarkViewed) return;
|
||||
try {
|
||||
const state = await loadPullRequestData(this.number, "viewedState");
|
||||
if (state[this.ctx.path] !== "VIEWED") await setViewed(this.number, [this.ctx.path], true);
|
||||
} catch {
|
||||
/* IntelliJ ignores it too */
|
||||
}
|
||||
}
|
||||
|
||||
/** "Refresh Review Data": the pending review and the threads. */
|
||||
refresh() {
|
||||
void reloadPullRequestData(this.number, "pendingReview").catch(() => undefined);
|
||||
void reloadPullRequestData(this.number, "threads").catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Previous / Next Comment: the visible thread before / after the current one or the caret, in any file. */
|
||||
async gotoComment(dir: 1 | -1): Promise<boolean> {
|
||||
const host = this.host;
|
||||
if (!host || !this.canNavigate) return false;
|
||||
const n = this.number;
|
||||
const [files, threads] = await Promise.all([loadPullRequestData(n, "changes"), loadPullRequestData(n, "threads")]);
|
||||
const option = useReviewDiffSettings.getState().threadsView;
|
||||
const order = threadOrder(
|
||||
files.map((f) => ({ path: f.filename, hunks: hunksOf(f.patch) })),
|
||||
threads,
|
||||
);
|
||||
const visible = (id: string) => {
|
||||
const t = threads.find((x) => x.id === id);
|
||||
return !!t && isThreadVisible(t, option);
|
||||
};
|
||||
const pane = host.focusedPane();
|
||||
const side: Side = host.mode === "unified" ? "right" : pane.side;
|
||||
const line = pane.view.state.doc.lineAt(pane.view.state.selection.main.head).number - 1;
|
||||
const onLast =
|
||||
navigated?.number === n && navigated.path === this.ctx.path && navigated.side === side && navigated.line === line;
|
||||
const file = files.findIndex((f) => f.filename === this.ctx.path);
|
||||
const other = host.mapLine(side, line);
|
||||
const from = onLast
|
||||
? { id: navigated!.id }
|
||||
: { file, left: side === "left" ? line : other, right: side === "right" ? line : other };
|
||||
const target = adjacentThread(order, from, dir, visible);
|
||||
if (!target) return false;
|
||||
// Where the caret lands: the unified viewer shows left lines at their right-side line.
|
||||
const mapped = host.mode === "unified" && target.side === "left";
|
||||
const caretAt = (rightLine: number) => (mapped ? { side: "right" as const, line: rightLine } : target);
|
||||
if (target.path === this.ctx.path) {
|
||||
const at = caretAt(host.mapLine("left", target.line));
|
||||
navigated = { number: n, id: target.id, path: target.path, side: at.side, line: at.line };
|
||||
this.hidden.delete(target.id);
|
||||
this.push();
|
||||
host.reveal(target.side, target.line);
|
||||
} else {
|
||||
const hunks = hunksOf(files.find((f) => f.filename === target.path)?.patch);
|
||||
const at = caretAt(mapAcrossPatch(hunks, "left", target.line));
|
||||
navigated = { number: n, id: target.id, path: target.path, side: at.side, line: at.line };
|
||||
await openPullRequestFileDiff(n, target.path, { line: target.line, side: target.side });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewer settings and menus
|
||||
|
||||
settingsItems(): MenuItem[] {
|
||||
const current = useReviewDiffSettings.getState().threadsView;
|
||||
return [
|
||||
{
|
||||
id: "github.reviewThreads",
|
||||
label: "Show Review Threads",
|
||||
icon: "eye",
|
||||
submenu: THREADS_VIEW_OPTIONS.map((o) => ({
|
||||
id: o.value,
|
||||
label: o.label,
|
||||
checked: current === o.value,
|
||||
run: () => setReviewDiffSettings({ threadsView: o.value }),
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
contextMenuItems(at: { side: DiffPaneSide; line: number }): MenuItem[] {
|
||||
const pane = this.host?.panes().find((p) => p.side === at.side);
|
||||
if (!pane) return [];
|
||||
const viewed = this.viewed();
|
||||
return [
|
||||
{
|
||||
id: "github.addReviewComment",
|
||||
label: "Add Review Comment",
|
||||
disabled: !this.commentRange(pane, false, at.line),
|
||||
run: () => this.addComment(false, { pane, line: at.line }),
|
||||
},
|
||||
{
|
||||
id: "github.addMultilineReviewComment",
|
||||
label: "Add Multiline Review Comment",
|
||||
disabled: !this.commentRange(pane, true),
|
||||
run: () => this.addComment(true, { pane, line: at.line }),
|
||||
},
|
||||
...(viewed === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "github.toggleViewed",
|
||||
label: viewed ? "Mark File as Not Viewed" : "Mark File as Viewed",
|
||||
shortcut: shortcutLabel("github.pullRequest.toggleViewed"),
|
||||
run: () => this.toggleViewed(),
|
||||
},
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function createReviewDiff(data: DiffData): ReviewDiff | null {
|
||||
const ctx = reviewDiffContext(data);
|
||||
return ctx ? new ReviewDiff(ctx) : null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Review-in-diff settings, persisted in local storage: "Show Review Threads"
|
||||
* (`GithubPullRequestsProjectUISettings.editorReviewViewOption`), the new comment's preferred
|
||||
* button (`reviewCommentsPreferred`) and "Automatically mark opened files as viewed"
|
||||
* (`GithubSettings.isAutomaticallyMarkAsViewed`, on Settings › Version Control › GitHub).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { THREADS_VIEW_OPTIONS, type ThreadsViewOption } from "./model";
|
||||
|
||||
export interface ReviewDiffSettings {
|
||||
threadsView: ThreadsViewOption;
|
||||
/** "Start Review" is the primary button of a new comment (else "Add Single Comment"). */
|
||||
reviewCommentsPreferred: boolean;
|
||||
autoMarkViewed: boolean;
|
||||
}
|
||||
|
||||
const KEY = "openidea.github.reviewDiff.v1";
|
||||
|
||||
/** IntelliJ's defaults. */
|
||||
const DEFAULTS: ReviewDiffSettings = {
|
||||
threadsView: "unresolved",
|
||||
reviewCommentsPreferred: true,
|
||||
autoMarkViewed: false,
|
||||
};
|
||||
|
||||
export function parseReviewDiffSettings(raw: unknown): ReviewDiffSettings {
|
||||
const o = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
||||
const bool = (v: unknown, d: boolean) => (typeof v === "boolean" ? v : d);
|
||||
return {
|
||||
threadsView: THREADS_VIEW_OPTIONS.some((x) => x.value === o.threadsView)
|
||||
? (o.threadsView as ThreadsViewOption)
|
||||
: DEFAULTS.threadsView,
|
||||
reviewCommentsPreferred: bool(o.reviewCommentsPreferred, DEFAULTS.reviewCommentsPreferred),
|
||||
autoMarkViewed: bool(o.autoMarkViewed, DEFAULTS.autoMarkViewed),
|
||||
};
|
||||
}
|
||||
|
||||
function load(): ReviewDiffSettings {
|
||||
try {
|
||||
const raw = globalThis.localStorage?.getItem(KEY);
|
||||
return parseReviewDiffSettings(raw ? JSON.parse(raw) : null);
|
||||
} catch {
|
||||
return DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
export const useReviewDiffSettings = create<ReviewDiffSettings>(load);
|
||||
|
||||
export function setReviewDiffSettings(patch: Partial<ReviewDiffSettings>) {
|
||||
useReviewDiffSettings.setState(patch);
|
||||
try {
|
||||
globalThis.localStorage?.setItem(KEY, JSON.stringify(useReviewDiffSettings.getState()));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrCheck, PrReviewComment, PullRequestDetails } from "@/platform/ipc";
|
||||
import { checksSummary, checksText, diffNotes, fileDiffData, relativeTime, reviewThreads, stateLabel } from "./model";
|
||||
|
||||
const comment = (
|
||||
id: number,
|
||||
path: string,
|
||||
line: number | null,
|
||||
inReplyTo: number | null = null,
|
||||
side = "RIGHT",
|
||||
): PrReviewComment => ({
|
||||
id,
|
||||
inReplyTo,
|
||||
path,
|
||||
line,
|
||||
originalLine: line ?? 3,
|
||||
side,
|
||||
body: `c${id}`,
|
||||
author: "ann",
|
||||
createdAt: "2026-10-21T10:00:00Z",
|
||||
url: "",
|
||||
diffHunk: "",
|
||||
});
|
||||
|
||||
const summary = {
|
||||
number: 7,
|
||||
title: "Fix",
|
||||
author: "ann",
|
||||
state: "open" as const,
|
||||
isDraft: false,
|
||||
headRef: "fix",
|
||||
baseRef: "main",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
labels: [],
|
||||
reviewDecision: null,
|
||||
url: "https://github.com/o/r/pull/7",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
};
|
||||
|
||||
describe("pull request model", () => {
|
||||
it("labels states", () => {
|
||||
expect(stateLabel(summary)).toBe("Open");
|
||||
expect(stateLabel({ ...summary, isDraft: true })).toBe("Draft");
|
||||
expect(stateLabel({ ...summary, state: "merged" })).toBe("Merged");
|
||||
});
|
||||
|
||||
it("summarises checks", () => {
|
||||
const c = (status: PrCheck["status"]): PrCheck => ({ name: status, workflow: null, status, url: null });
|
||||
const s = checksSummary([c("failure"), c("success"), c("success"), c("pending"), c("skipped")]);
|
||||
expect(s).toEqual({ failed: 1, pending: 1, passed: 2, other: 1 });
|
||||
expect(checksText(s)).toBe("1 failing, 1 pending, 2 passed, 1 skipped");
|
||||
expect(checksText(checksSummary([]))).toBe("No checks");
|
||||
});
|
||||
|
||||
it("formats relative times", () => {
|
||||
const now = Date.parse("2026-10-21T12:00:00Z");
|
||||
expect(relativeTime("2026-10-21T11:59:30Z", now)).toBe("just now");
|
||||
expect(relativeTime("2026-10-21T10:00:00Z", now)).toBe("2 hours ago");
|
||||
expect(relativeTime("2026-10-20T12:00:00Z", now)).toBe("1 day ago");
|
||||
expect(relativeTime("nonsense", now)).toBe("");
|
||||
});
|
||||
|
||||
it("threads replies under their root, ordered by file and line", () => {
|
||||
const threads = reviewThreads([
|
||||
comment(1, "b.ts", 9),
|
||||
comment(2, "a.ts", 5),
|
||||
comment(3, "b.ts", 9, 1),
|
||||
comment(4, "b.ts", 9, 3),
|
||||
]);
|
||||
expect(threads.map((t) => [t.root.id, t.replies.map((r) => r.id)])).toEqual([
|
||||
[2, []],
|
||||
[1, [3, 4]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns current comments into diff notes", () => {
|
||||
const notes = diffNotes("a.ts", [
|
||||
comment(1, "a.ts", 5),
|
||||
comment(2, "a.ts", null),
|
||||
comment(3, "a.ts", 2, null, "LEFT"),
|
||||
comment(4, "b.ts", 1),
|
||||
]);
|
||||
expect(notes.map((n) => [n.side, n.line])).toEqual([
|
||||
["right", 4],
|
||||
["left", 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds diff data for renamed and added files", () => {
|
||||
const pr = { summary, files: [] } as unknown as PullRequestDetails;
|
||||
const revs = {
|
||||
base: "b".repeat(40),
|
||||
head: "h".repeat(40),
|
||||
root: "/repo",
|
||||
files: [{ path: "src/new.ts", kind: "renamed" as const, oldPath: "src/old.ts" }],
|
||||
};
|
||||
const renamed = fileDiffData(
|
||||
pr,
|
||||
{ path: "src/new.ts", additions: 1, deletions: 0, changeType: "RENAMED" },
|
||||
revs,
|
||||
[],
|
||||
);
|
||||
expect(renamed.path).toBe("/repo/src/new.ts");
|
||||
expect(renamed.left).toMatchObject({ type: "rev", rev: revs.base, path: "/repo/src/old.ts" });
|
||||
expect(renamed.right).toMatchObject({ type: "rev", rev: revs.head });
|
||||
expect(renamed.title).toBe("#7: new.ts");
|
||||
const added = fileDiffData(pr, { path: "x.md", additions: 1, deletions: 0, changeType: "ADDED" }, revs, []);
|
||||
expect(added.left).toMatchObject({ type: "text", text: "" });
|
||||
});
|
||||
});
|
||||
+328
-59
@@ -1,19 +1,308 @@
|
||||
/**
|
||||
* GitHub pull requests through the gh CLI: the Pull Requests tool window (list,
|
||||
* details, files diffed in the diff viewer with review comments, commits,
|
||||
* conversation), checkout, create, review and comment.
|
||||
* GitHub pull requests, ported from IntelliJ's GitHub plugin: the Pull Requests tool window
|
||||
* (list, `#N` details tabs, New Pull Request), the timeline editor, review in the diff
|
||||
* viewer and review mode. GitHub is reached through gh (`api/`), cached per repository
|
||||
* and pull request (`data/`); shared components live in `ui/`, the shell in `toolwindow/`.
|
||||
*
|
||||
* Action ids map IntelliJ's (`Github.PullRequest.Show` → `github.pullRequest.show`);
|
||||
* `github.pullRequests`, `.createPullRequest`, `.checkoutPullRequest`, `.openInBrowser`
|
||||
* and `.refresh` are the Git › GitHub menu's.
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import "./ui/palette.css";
|
||||
import { registerActions, type Action } from "@/platform/actions";
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
|
||||
import { GithubDialogHost } from "./dialogs";
|
||||
import { checkoutAction, createPullRequest, openInBrowser, showPullRequests, WINDOW } from "./ops";
|
||||
import { PullRequestsHeader, PullRequestsView } from "./PullRequestsView";
|
||||
import { refreshAll, resetGithub } from "./store";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { maybeEditorService, registerEditorKind, registerOverlay, registerToolWindow } from "@/workbench/contributions";
|
||||
import { activateReviewDiff, toggleReviewDiffViewed } from "./diff";
|
||||
import { reloadList } from "./data/list";
|
||||
import { checkoutPullRequest, clearDiffCache, copyUrl, openInBrowser, openInWorktree } from "./data/local";
|
||||
import { deleteMergedBranch } from "./data/operations";
|
||||
import { cachedDetails, loadPullRequestData, reloadPullRequest } from "./data/pullRequest";
|
||||
import { refreshConnection, resetConnection, useGhRepository } from "./data/repository";
|
||||
import { showSubmitReview } from "./details/SubmitReviewPopup";
|
||||
import { activateReviewMode } from "./reviewMode";
|
||||
import { githubHandler, handlerContextKey } from "./toolwindow/handlers";
|
||||
import {
|
||||
openCreatePullRequest,
|
||||
openPullRequest,
|
||||
selectOpenedPullRequest,
|
||||
showPullRequests,
|
||||
WINDOW,
|
||||
} from "./toolwindow/open";
|
||||
import {
|
||||
changeRepository,
|
||||
PullRequestsHeaderActions,
|
||||
PullRequestsTabs,
|
||||
PullRequestsToolWindow,
|
||||
usePullRequestsHeaderMenu,
|
||||
} from "./toolwindow/PullRequestsToolWindow";
|
||||
import { contextPullRequest, resetTabs } from "./toolwindow/store";
|
||||
import {
|
||||
activeTimelinePullRequest,
|
||||
openTimeline,
|
||||
TIMELINE_KIND,
|
||||
timelineIcon,
|
||||
timelineTitle,
|
||||
} from "./toolwindow/timelineInput";
|
||||
import { GithubDialogHost, numberDialog } from "./ui/dialogs";
|
||||
|
||||
const WHEN = "projectOpen";
|
||||
const CONNECTED = "projectOpen && githubConnected";
|
||||
const IN_WINDOW = `activeToolWindow == '${WINDOW}'`;
|
||||
|
||||
/** The pull request an action applies to: its argument, the timeline editor, the active tab or the list selection. */
|
||||
function targetPullRequest(arg: unknown): number | null {
|
||||
if (typeof arg === "number") return arg;
|
||||
return activeTimelinePullRequest() ?? contextPullRequest();
|
||||
}
|
||||
|
||||
async function withPullRequest(arg: unknown, run: (number: number) => unknown) {
|
||||
const n = targetPullRequest(arg);
|
||||
if (n !== null) await run(n);
|
||||
}
|
||||
|
||||
async function prUrl(number: number): Promise<string> {
|
||||
return cachedDetails(number)?.url ?? (await loadPullRequestData(number, "details")).url;
|
||||
}
|
||||
|
||||
function actions(): Action[] {
|
||||
return [
|
||||
// Git › GitHub
|
||||
{
|
||||
// Github.View.Pull.Request
|
||||
id: "github.pullRequests",
|
||||
title: "View Pull Requests",
|
||||
category: "GitHub",
|
||||
icon: "toolwindow-pull-requests",
|
||||
keywords: ["github", "pr", "review", "merge request", "code review"],
|
||||
when: WHEN,
|
||||
run: () => void showPullRequests(),
|
||||
},
|
||||
{
|
||||
// Github.Create.Pull.Request
|
||||
id: "github.createPullRequest",
|
||||
title: "Create Pull Request…",
|
||||
category: "GitHub",
|
||||
icon: "plus",
|
||||
keywords: ["github", "pr", "new pull request", "start pull request", "merge request", "code review"],
|
||||
when: WHEN,
|
||||
keyWhen: IN_WINDOW,
|
||||
run: () => openCreatePullRequest(),
|
||||
},
|
||||
{
|
||||
id: "github.checkoutPullRequest",
|
||||
title: "Checkout Pull Request…",
|
||||
description: "Checks out the selected pull request, a number given as argument, or asks for one.",
|
||||
category: "GitHub",
|
||||
icon: "branch",
|
||||
keywords: ["github", "pr"],
|
||||
when: WHEN,
|
||||
run: async ({ arg }) => {
|
||||
if (!(await showPullRequests(false))) return;
|
||||
const n = targetPullRequest(arg) ?? (await numberDialog("Checkout Pull Request"));
|
||||
if (n) await checkoutPullRequest(n);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Github.Open.In.Browser
|
||||
id: "github.openInBrowser",
|
||||
title: "Open on GitHub",
|
||||
category: "GitHub",
|
||||
icon: "external-link",
|
||||
when: WHEN,
|
||||
run: async () => {
|
||||
if (!(await showPullRequests(false))) return;
|
||||
const n = targetPullRequest(undefined);
|
||||
const url = n !== null ? await prUrl(n) : useGhRepository.getState().connection?.repository.url;
|
||||
if (url) await openInBrowser(url);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.List.Reload
|
||||
id: "github.refresh",
|
||||
title: "Refresh Pull Requests List",
|
||||
category: "GitHub",
|
||||
icon: "refresh",
|
||||
keywords: ["refresh list", "reload"],
|
||||
when: WHEN,
|
||||
keyWhen: `${IN_WINDOW} && githubPrTab == 'list'`,
|
||||
run: async () => {
|
||||
await refreshConnection();
|
||||
if (useGhRepository.getState().connection) await reloadList();
|
||||
},
|
||||
},
|
||||
// Tool window
|
||||
{
|
||||
// GHPRSwitchRemoteAction
|
||||
id: "github.changeRepository",
|
||||
title: "Change Repository or Account…",
|
||||
category: "GitHub",
|
||||
when: CONNECTED,
|
||||
run: () => changeRepository(),
|
||||
},
|
||||
{
|
||||
// pull.request.select.action
|
||||
id: "github.selectOpenedPullRequest",
|
||||
title: "Select Opened Pull Request",
|
||||
category: "GitHub",
|
||||
icon: "locate",
|
||||
when: `${CONNECTED} && githubTimelineActive`,
|
||||
run: () => selectOpenedPullRequest(),
|
||||
},
|
||||
// A pull request
|
||||
{
|
||||
// Github.PullRequest.Show
|
||||
id: "github.pullRequest.show",
|
||||
title: "View Pull Request",
|
||||
description: "View pull request information and timeline",
|
||||
category: "GitHub",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, (n) => openPullRequest(n)),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Timeline.Show
|
||||
id: "github.pullRequest.showTimeline",
|
||||
title: "View Timeline",
|
||||
description: "View pull request timeline",
|
||||
category: "GitHub",
|
||||
icon: "github-timeline",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, (n) => openTimeline(n, { focus: true })),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Details.Reload
|
||||
id: "github.pullRequest.refresh",
|
||||
title: "Refresh Pull Request",
|
||||
category: "GitHub",
|
||||
icon: "refresh",
|
||||
when: CONNECTED,
|
||||
keyWhen: `${IN_WINDOW} && githubPrTab == 'pr'`,
|
||||
run: ({ arg }) => withPullRequest(arg, reloadPullRequest),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Timeline.Update
|
||||
id: "github.pullRequest.timeline.refresh",
|
||||
title: "Refresh Timeline",
|
||||
description: "Check for new timeline events",
|
||||
category: "GitHub",
|
||||
icon: "refresh",
|
||||
when: `${CONNECTED} && githubTimelineActive`,
|
||||
keyWhen: "editorAreaFocus",
|
||||
run: () => withPullRequest(undefined, reloadPullRequest),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Open.Link
|
||||
id: "github.pullRequest.openInBrowser",
|
||||
title: "View Pull Request in Browser",
|
||||
category: "GitHub",
|
||||
icon: "external-link",
|
||||
keywords: ["open pull request in github"],
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, async (n) => openInBrowser(await prUrl(n))),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Copy.Link
|
||||
id: "github.pullRequest.copyUrl",
|
||||
title: "Copy Pull Request URL",
|
||||
category: "GitHub",
|
||||
icon: "copy",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, async (n) => copyUrl(await prUrl(n))),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.CheckoutBranch
|
||||
id: "github.pullRequest.checkoutBranch",
|
||||
title: "Checkout Branch",
|
||||
description: "Fetch and checkout the pull request branch",
|
||||
category: "GitHub",
|
||||
icon: "branch",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, checkoutPullRequest),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.OpenInWorktree
|
||||
id: "github.pullRequest.openInWorktree",
|
||||
title: "Open in Worktree",
|
||||
description: "Fetch and open the pull request branch in a Git worktree",
|
||||
category: "GitHub",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, openInWorktree),
|
||||
},
|
||||
{
|
||||
// GitHub.Timeline.DeleteMergedBranch
|
||||
id: "github.pullRequest.deleteMergedBranch",
|
||||
title: "Delete Branch",
|
||||
description: "Delete the merged branch on GitHub",
|
||||
category: "GitHub",
|
||||
icon: "trash",
|
||||
hidden: true,
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) =>
|
||||
withPullRequest(arg, async (n) => {
|
||||
const ref = (await loadPullRequestData(n, "details")).headRef;
|
||||
if (ref) await deleteMergedBranch(n, ref.id);
|
||||
}),
|
||||
},
|
||||
// Implemented by other parts (`provideGithubHandler`)
|
||||
{
|
||||
// Github.PullRequest.Review.Submit
|
||||
id: "github.pullRequest.submitReview",
|
||||
title: "Submit Review…",
|
||||
category: "GitHub",
|
||||
// The details panel's popup; without it (the branch popup in review mode) one of its own.
|
||||
when: `${CONNECTED} && (${handlerContextKey("submitReview")} || githubCurrentBranchPullRequest)`,
|
||||
run: ({ arg }) =>
|
||||
withPullRequest(arg, (n) => {
|
||||
const handler = githubHandler("submitReview");
|
||||
if (handler) handler(n);
|
||||
else void showSubmitReview(n);
|
||||
}),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Changed.MarkViewed.Toggle
|
||||
id: "github.pullRequest.toggleViewed",
|
||||
title: "Toggle viewed/not viewed state",
|
||||
description: "Toggle viewed/not viewed state for the selection or file",
|
||||
category: "GitHub",
|
||||
// The file of the focused review diff, else the details panel's selection.
|
||||
when: `${CONNECTED} && (${handlerContextKey("toggleViewed")} || githubReviewDiff)`,
|
||||
run: () => {
|
||||
if (!(getContext().diffEditorFocus && toggleReviewDiffViewed())) githubHandler("toggleViewed")?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
// Github.Pull.Request.Show.In.Toolwindow
|
||||
id: "github.pullRequest.showInToolWindow",
|
||||
title: "Show Pull Request in the Tool Window",
|
||||
category: "GitHub",
|
||||
icon: "toolwindow-pull-requests",
|
||||
when: `${CONNECTED} && ${handlerContextKey("showCurrentBranchPullRequest")}`,
|
||||
run: () => githubHandler("showCurrentBranchPullRequest")?.(),
|
||||
},
|
||||
{
|
||||
// Github.Pull.Request.Branch.Update
|
||||
id: "github.pullRequest.updateBranch",
|
||||
title: "Update to Enable Review Mode…",
|
||||
category: "GitHub",
|
||||
icon: "github-warning",
|
||||
when: `${CONNECTED} && ${handlerContextKey("updateReviewBranch")}`,
|
||||
run: () => githubHandler("updateReviewBranch")?.(),
|
||||
},
|
||||
{
|
||||
// Github.Pull.Request.Review.In.Editor.Toggle
|
||||
id: "github.pullRequest.reviewMode",
|
||||
title: "Review Mode",
|
||||
category: "GitHub",
|
||||
when: `${CONNECTED} && ${handlerContextKey("toggleReviewMode")}`,
|
||||
toggled: () => githubHandler("isReviewModeOn")?.() ?? false,
|
||||
run: () => githubHandler("toggleReviewMode")?.(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
setContext("githubPrTab", "list");
|
||||
const disposers = [
|
||||
registerToolWindow({
|
||||
id: WINDOW,
|
||||
@@ -22,59 +311,39 @@ export function activate(): () => void {
|
||||
anchor: "left",
|
||||
order: 2,
|
||||
stripe: "onUse",
|
||||
component: PullRequestsView,
|
||||
headerContent: PullRequestsHeader,
|
||||
component: PullRequestsToolWindow,
|
||||
headerContent: PullRequestsTabs,
|
||||
headerActions: PullRequestsHeaderActions,
|
||||
headerMenu: usePullRequestsHeaderMenu,
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: TIMELINE_KIND,
|
||||
component: lazyComponent(() => import("./timeline/TimelineEditor").then((m) => m.TimelineEditor)),
|
||||
title: timelineTitle,
|
||||
icon: timelineIcon,
|
||||
}),
|
||||
registerOverlay({ id: "github.dialogs", component: GithubDialogHost }),
|
||||
registerActions([
|
||||
{
|
||||
id: "github.pullRequests",
|
||||
title: "View Pull Requests",
|
||||
category: "GitHub",
|
||||
icon: "toolwindow-pull-requests",
|
||||
keywords: ["github", "pr", "review"],
|
||||
when: WHEN,
|
||||
run: () => showPullRequests(),
|
||||
},
|
||||
{
|
||||
id: "github.createPullRequest",
|
||||
title: "Create Pull Request…",
|
||||
category: "GitHub",
|
||||
icon: "plus",
|
||||
keywords: ["github", "pr"],
|
||||
when: WHEN,
|
||||
run: () => createPullRequest(),
|
||||
},
|
||||
{
|
||||
id: "github.checkoutPullRequest",
|
||||
title: "Checkout Pull Request…",
|
||||
description: "Checks out the selected pull request, a number given as argument, or asks for one.",
|
||||
category: "GitHub",
|
||||
icon: "branch",
|
||||
keywords: ["github", "pr"],
|
||||
when: WHEN,
|
||||
run: ({ arg }) => checkoutAction(arg),
|
||||
},
|
||||
{
|
||||
id: "github.openInBrowser",
|
||||
title: "Open on GitHub",
|
||||
category: "GitHub",
|
||||
icon: "external-link",
|
||||
when: WHEN,
|
||||
run: () => openInBrowser(),
|
||||
},
|
||||
{
|
||||
id: "github.refresh",
|
||||
title: "Refresh Pull Requests",
|
||||
category: "GitHub",
|
||||
icon: "refresh",
|
||||
when: WHEN,
|
||||
run: () => refreshAll(),
|
||||
},
|
||||
]),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) resetGithub();
|
||||
registerActions(actions()),
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
setContext("githubConnected", !!s.connection);
|
||||
setContext("githubTimelineActive", activeTimelinePullRequest() !== null);
|
||||
const a = s.connection?.coordinates;
|
||||
const b = prev.connection?.coordinates;
|
||||
if (a?.owner !== b?.owner || a?.name !== b?.name) {
|
||||
resetTabs();
|
||||
clearDiffCache();
|
||||
}
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) resetConnection();
|
||||
}),
|
||||
activateReviewDiff(),
|
||||
activateReviewMode(),
|
||||
];
|
||||
const editors = maybeEditorService();
|
||||
const stopEditors = editors?.onActiveInputChange?.(() =>
|
||||
setContext("githubTimelineActive", activeTimelinePullRequest() !== null),
|
||||
);
|
||||
if (stopEditors) disposers.push(stopEditors);
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The row's hover balloons (`ReviewListCellRenderer.LazyIdeToolTip`): after the tooltip
|
||||
* delay, a balloon to the right of the labels icon or an avatar stack, titled with the
|
||||
* collection's name ("Labels", "Reviewers") over a list of icon + text rows. It stays
|
||||
* open while the pointer is over the trigger or the balloon.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Popup, TOOLTIP_DELAY } from "@/ui";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
export interface BalloonItem {
|
||||
key: string;
|
||||
icon: ReactNode;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const HIDE_GRACE_MS = 120;
|
||||
|
||||
export function HoverBalloon({
|
||||
title,
|
||||
items,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
items: readonly BalloonItem[];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const anchor = useRef<HTMLSpanElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
const later = (next: boolean, ms: number) => {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setOpen(next), ms);
|
||||
};
|
||||
const enter = () => (open ? clearTimeout(timer.current) : later(true, TOOLTIP_DELAY));
|
||||
const leave = () => (open ? later(false, HIDE_GRACE_MS) : clearTimeout(timer.current));
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={anchor}
|
||||
className={className}
|
||||
onPointerEnter={enter}
|
||||
onPointerLeave={leave}
|
||||
onPointerDown={() => {
|
||||
clearTimeout(timer.current);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<Popup
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
placement="right"
|
||||
offset={6}
|
||||
role="tooltip"
|
||||
autoFocus={false}
|
||||
restoreFocus={false}
|
||||
onClose={() => setOpen(false)}
|
||||
className={styles.balloon}
|
||||
>
|
||||
<div onPointerEnter={enter} onPointerLeave={leave}>
|
||||
<div className={styles.balloonTitle}>{title}</div>
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className={styles.balloonRow}>
|
||||
{item.icon}
|
||||
<span>{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* The pull request list (IntelliJ's `GHPRListPanelFactory`, `ReviewListCellRenderer`,
|
||||
* `ReviewListSearchPanelFactory`). Sizes are IntelliJ's at 100% scale.
|
||||
*/
|
||||
.panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Search panel: 0 / 10px (the tab adds 8px above). */
|
||||
.search {
|
||||
flex: none;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.filterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.quickFilter {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
}
|
||||
/* BadgeIconSupplier's live indicator. */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--gh-unread);
|
||||
box-shadow: 0 0 0 1.5px var(--bg-elev);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Chips: a horizontally scrolling row, 4px apart, faded 10px at the clipped edges, thin overlay scrollbar. */
|
||||
.chips {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 2px 10px 4px;
|
||||
margin-bottom: -2px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.chips::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.chips::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
.chips:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
}
|
||||
.chips::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.chips[data-fade="left"] {
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 10px);
|
||||
}
|
||||
.chips[data-fade="right"] {
|
||||
mask-image: linear-gradient(to left, transparent 0, #000 10px);
|
||||
}
|
||||
.chips[data-fade="both"] {
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 10px, #000 calc(100% - 10px), transparent 100%);
|
||||
}
|
||||
|
||||
/* FilterComponent: the name and a chevron, or the value and a reset ✕. */
|
||||
.chip {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-small);
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:hover {
|
||||
background: var(--bg-toolbar-hover);
|
||||
}
|
||||
.chip[data-set] {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.chipMain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
max-width: 200px;
|
||||
padding: 0 4px 0 6px;
|
||||
overflow: hidden;
|
||||
font-size: var(--fs-ui);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip[data-set] .chipMain {
|
||||
padding-right: 2px;
|
||||
}
|
||||
.chipText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chipChevron,
|
||||
.chipReset {
|
||||
flex: none;
|
||||
color: var(--fg-icon);
|
||||
}
|
||||
.chipReset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
.chipReset:hover {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.chipMain:focus-visible,
|
||||
.chipReset:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-small);
|
||||
}
|
||||
|
||||
/* List area with the progress stripe over its top edge. */
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stripe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
/* New UI selection: rounded, inset 13px; the row's text 19px from the edges. */
|
||||
.list {
|
||||
--list-inset: 13px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0 -2px;
|
||||
padding: 4px 0 5px;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.left {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.right {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.unread {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--gh-unread);
|
||||
}
|
||||
.title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 30px;
|
||||
overflow: hidden;
|
||||
color: var(--fg-strong);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
}
|
||||
.tintedIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
--icon-color: currentColor;
|
||||
}
|
||||
.comments {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-right: 1px;
|
||||
color: var(--fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.info {
|
||||
overflow: hidden;
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emptyLink {
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
}
|
||||
.emptyLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.error {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.errorBody {
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Hover balloons (labels, assignees, reviewers). */
|
||||
.balloon {
|
||||
padding: 8px 10px 6px;
|
||||
background: var(--bg-tooltip);
|
||||
color: var(--fg-tooltip);
|
||||
}
|
||||
.balloonTitle {
|
||||
padding: 0 10px 4px 0;
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.balloonRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* The list tab's panel (IntelliJ's `GHPRListPanelFactory` / `GHPRListPanelController`):
|
||||
* the search panel, a progress stripe while loading, and the pull requests, 50 per page,
|
||||
* the next page requested once the scroll passes 70% (checked again after each page).
|
||||
* Enter or a double-click opens the selected pull request; the context menu is
|
||||
* `Github.PullRequest.ToolWindow.List.Popup`. Load errors replace the list with
|
||||
* "Can't load pull requests" and Retry.
|
||||
*
|
||||
* The search starts as the last one used, else "Assigned to you"; every change is saved
|
||||
* as the last search and, by IntelliJ's rules, added to the history (`model.ts`).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, type MouseEvent } from "react";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { EmptyState, List, ProgressBar, showContextMenu, T, type MenuItem } from "@/ui";
|
||||
import type { GHPullRequestShort } from "../api/types";
|
||||
import { loadMore, reloadList, setListSearch, useGhPrList } from "../data/list";
|
||||
import { filterCount, type PrSearchValue } from "../data/model";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { initialSearch, rememberSearch, saveLastSearch } from "../data/searchHistory";
|
||||
import { openPullRequest } from "../toolwindow/open";
|
||||
import { setListSelection, useGhTabs } from "../toolwindow/store";
|
||||
import { createSearchHistoryRecorder, defaultFilter, pastThreshold, type SearchHistoryRecorder } from "./model";
|
||||
import { PullRequestRow } from "./PullRequestRow";
|
||||
import { SearchPanel } from "./SearchPanel";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
/** Row height: 4px + the 24px first line + the 16px info line + 5px. */
|
||||
const ROW_HEIGHT = 49;
|
||||
|
||||
/** `Github.PullRequest.ToolWindow.List.Popup`. */
|
||||
function contextMenu(number: number): MenuItem[] {
|
||||
return [
|
||||
{ kind: "action", action: "github.pullRequest.show", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.checkoutBranch", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.openInWorktree", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.openInBrowser", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.copyUrl", arg: number },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "github.refresh", label: "Refresh List" },
|
||||
];
|
||||
}
|
||||
|
||||
/** Applies searches to the list, saving them as IntelliJ does; the first call is the initial search. */
|
||||
function useApplySearch(login: string | undefined): (v: PrSearchValue) => void {
|
||||
const recorder = useRef<SearchHistoryRecorder | null>(null);
|
||||
useEffect(() => {
|
||||
if (!login) return;
|
||||
const defaultSearch = defaultFilter(login);
|
||||
const r = createSearchHistoryRecorder({
|
||||
defaultSearch,
|
||||
saveLast: (v) => saveLastSearch(v),
|
||||
remember: (v) => rememberSearch(v, undefined, defaultSearch),
|
||||
});
|
||||
recorder.current = r;
|
||||
r.onChange(initialSearch(login));
|
||||
void setListSearch(initialSearch(login));
|
||||
return () => {
|
||||
r.dispose();
|
||||
if (recorder.current === r) recorder.current = null;
|
||||
};
|
||||
}, [login]);
|
||||
return useCallback((v: PrSearchValue) => {
|
||||
recorder.current?.onChange(v);
|
||||
void setListSearch(v);
|
||||
}, []);
|
||||
}
|
||||
|
||||
function ErrorState({ error }: { error: string }) {
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<EmptyState
|
||||
title="Can't load pull requests"
|
||||
actions={
|
||||
<button type="button" className={styles.emptyLink} onClick={() => void reloadList()}>
|
||||
<T>Retry</T>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className={styles.errorBody}>{error}</div>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListPanel() {
|
||||
const t = useTx();
|
||||
const connection = useGhRepository((s) => s.connection);
|
||||
const login = connection?.currentUser.login;
|
||||
const { search, items, loading, error, loaded, hasMore } = useGhPrList();
|
||||
const selected = useGhTabs((s) => s.listSelection);
|
||||
const apply = useApplySearch(login);
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
const list = body.current?.querySelector<HTMLElement>("[role=listbox]");
|
||||
if (!list || list.offsetParent === null) return;
|
||||
if (pastThreshold(list.scrollTop, list.clientHeight, list.scrollHeight)) void loadMore();
|
||||
}, []);
|
||||
// After each page: the list may still not fill the view (`ReviewListUtil.wrapWithLazyVerticalScroll`).
|
||||
useEffect(() => {
|
||||
if (loaded && !loading && hasMore && !error) {
|
||||
const id = requestAnimationFrame(checkScroll);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
}, [items.length, loaded, loading, hasMore, error, checkScroll]);
|
||||
|
||||
if (!connection || !login) return null;
|
||||
const index = items.findIndex((p) => p.number === selected);
|
||||
const onContextMenu = (pr: GHPullRequestShort, _i: number, e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setListSelection(pr.number);
|
||||
showContextMenu(e, contextMenu(pr.number));
|
||||
};
|
||||
|
||||
const empty = loading ? (
|
||||
<EmptyState title="Loading…" />
|
||||
) : filterCount(search) === 0 ? (
|
||||
<EmptyState title={t`No pull requests in ${connection.coordinates.name}`} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No pull requests matching filters"
|
||||
actions={
|
||||
<button type="button" className={styles.emptyLink} onClick={() => apply({})}>
|
||||
<T>Clear filters</T>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<SearchPanel search={search} apply={apply} login={login} />
|
||||
<div ref={body} className={styles.body} onScrollCapture={checkScroll}>
|
||||
{loading && <ProgressBar className={styles.stripe} aria-label="Loading…" />}
|
||||
{error ? (
|
||||
<ErrorState error={error} />
|
||||
) : (
|
||||
<List<GHPullRequestShort>
|
||||
items={items}
|
||||
getKey={(p) => p.id}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
activeIndex={index}
|
||||
onActiveChange={(i) => setListSelection(items[i]?.number ?? null)}
|
||||
onActivate={(p, _i, e) => {
|
||||
// Enter or a double-click (`CommonShortcuts.ENTER`, `DOUBLE_CLICK_1`).
|
||||
if (e.type === "click" && (e as MouseEvent).detail !== 2) return;
|
||||
void openPullRequest(p.number);
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
aria-label="Pull requests"
|
||||
className={styles.list}
|
||||
empty={empty}
|
||||
renderItem={(p) => <PullRequestRow pr={p} currentUser={connection.currentUser} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* A list row (`ReviewListCellRenderer` with `GHPRListComponentFactory.presentPR`).
|
||||
* First line, 6px apart: the unread dot, the title (full title as tooltip when cut off)
|
||||
* and the labels icon on the left; the state tag, the conflict icon, up to 2 assignee
|
||||
* and 2 reviewer avatars and the unresolved comment counter on the right. Second line,
|
||||
* one size smaller and grey: `#N · created {date}, by {login}`.
|
||||
*/
|
||||
import { memo, useMemo, type PointerEvent } from "react";
|
||||
import { Icon } from "@/ui";
|
||||
import type { GHPullRequestShort, GHUser } from "../api/types";
|
||||
import { usePullRequestSeen } from "../data/interaction";
|
||||
import { unresolvedThreadsCount } from "../data/model";
|
||||
import { Avatar, AvatarStack, AVATAR_SIZE, reviewerRingColor } from "../ui/Avatar";
|
||||
import { labelColor, StateTag } from "../ui/chips";
|
||||
import { prettyDate } from "../ui/prettyDate";
|
||||
import { HoverBalloon, type BalloonItem } from "./HoverBalloon";
|
||||
import {
|
||||
assigneesTitle,
|
||||
infoLine,
|
||||
labelsTitle,
|
||||
reviewersTitle,
|
||||
rowReviewers,
|
||||
unresolvedCommentsTooltip,
|
||||
} from "./model";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
/** The participant icons a row shows per group (`MAX_PARTICIPANT_ICONS`). */
|
||||
const MAX_AVATARS = 2;
|
||||
|
||||
/** Sets the full title as the tooltip only while it is cut off. */
|
||||
function titleTooltip(e: PointerEvent<HTMLElement>) {
|
||||
const el = e.currentTarget;
|
||||
if (el.scrollWidth > el.clientWidth) el.dataset.tooltip = el.textContent ?? "";
|
||||
else delete el.dataset.tooltip;
|
||||
}
|
||||
|
||||
export const PullRequestRow = memo(function PullRequestRow({
|
||||
pr,
|
||||
currentUser,
|
||||
}: {
|
||||
pr: GHPullRequestShort;
|
||||
currentUser: GHUser | null;
|
||||
}) {
|
||||
const seen = usePullRequestSeen(pr, currentUser);
|
||||
const reviewers = useMemo(() => rowReviewers(pr), [pr]);
|
||||
const labels = pr.labels.nodes;
|
||||
const assignees = pr.assignees.nodes;
|
||||
const comments = unresolvedThreadsCount(pr);
|
||||
|
||||
const labelItems: BalloonItem[] = labels.map((l) => ({
|
||||
key: l.id || l.name,
|
||||
icon: (
|
||||
<span className={styles.tintedIcon} style={{ color: labelColor(l) }}>
|
||||
<Icon name="branch-label" size={16} />
|
||||
</span>
|
||||
),
|
||||
text: l.name,
|
||||
}));
|
||||
const assigneeItems: BalloonItem[] = assignees.map((u) => ({
|
||||
key: u.id || u.login,
|
||||
icon: <Avatar url={u.avatarUrl} name={u.login} size={AVATAR_SIZE.BASE} title={null} />,
|
||||
text: u.login,
|
||||
}));
|
||||
const reviewerItems: BalloonItem[] = reviewers.map((r) => ({
|
||||
key: r.key,
|
||||
icon: (
|
||||
<Avatar
|
||||
url={r.avatarUrl}
|
||||
name={r.name}
|
||||
size={AVATAR_SIZE.OUTLINED}
|
||||
ring={reviewerRingColor(r.state)}
|
||||
title={null}
|
||||
/>
|
||||
),
|
||||
text: r.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={styles.row} data-pr={pr.number}>
|
||||
<div className={styles.line}>
|
||||
<div className={styles.left}>
|
||||
{!seen && <span className={styles.unread} aria-hidden data-unread />}
|
||||
<span className={styles.title} onPointerEnter={titleTooltip}>
|
||||
{pr.title}
|
||||
</span>
|
||||
{labels.length > 0 && (
|
||||
<HoverBalloon title={labelsTitle(labels.length)} items={labelItems} className={styles.icon}>
|
||||
<Icon name="branch-label" size={16} />
|
||||
</HoverBalloon>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<StateTag state={pr.state} isDraft={pr.isDraft} />
|
||||
{pr.mergeable === "CONFLICTING" && (
|
||||
<span className={styles.icon} data-tooltip="Pull request cannot be merged">
|
||||
<Icon name="review-non-mergeable" size={16} />
|
||||
</span>
|
||||
)}
|
||||
{assignees.length > 0 && (
|
||||
<HoverBalloon title={assigneesTitle(assignees.length)} items={assigneeItems} className={styles.icon}>
|
||||
<AvatarStack
|
||||
max={MAX_AVATARS}
|
||||
size={AVATAR_SIZE.BASE}
|
||||
items={assignees.map((u) => ({ key: u.id || u.login, url: u.avatarUrl, name: u.login }))}
|
||||
title={null}
|
||||
/>
|
||||
</HoverBalloon>
|
||||
)}
|
||||
{reviewers.length > 0 && (
|
||||
<HoverBalloon title={reviewersTitle(reviewers.length)} items={reviewerItems} className={styles.icon}>
|
||||
<AvatarStack
|
||||
max={MAX_AVATARS}
|
||||
size={AVATAR_SIZE.OUTLINED}
|
||||
items={reviewers.map((r) => ({
|
||||
key: r.key,
|
||||
url: r.avatarUrl,
|
||||
name: r.name,
|
||||
ring: reviewerRingColor(r.state),
|
||||
}))}
|
||||
title={null}
|
||||
/>
|
||||
</HoverBalloon>
|
||||
)}
|
||||
<span className={styles.comments} data-tooltip={unresolvedCommentsTooltip(comments)}>
|
||||
<Icon name="review-comment" size={16} />
|
||||
{comments}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.info}>{infoLine(pr.number, prettyDate(pr.createdAt), pr.author?.login)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* The list's search panel (`ReviewListSearchPanelFactory` / `GHPRSearchPanelFactory`):
|
||||
* the search field (Enter applies the text; the history icon or ⌥↓ opens the last 10
|
||||
* searches), then the Quick Filters funnel and a horizontally scrolling row of chips —
|
||||
* State, Author, Label, Assignee, Review, Sort.
|
||||
*
|
||||
* Chips are IntelliJ's `FilterComponent`: the filter's name and a chevron while unset,
|
||||
* else the value and a reset ✕; Delete / Backspace resets. The popup opens 4px below.
|
||||
* As in IntelliJ, closing a chip's popup without choosing resets the chip.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { tx, useTx } from "@/platform/i18n";
|
||||
import { Icon, IconButton, Menu, SearchField, showContextMenu, type MenuItem } from "@/ui";
|
||||
import type { GHLabel, GHUser } from "../api/types";
|
||||
import { filterCount, type PrSearchValue } from "../data/model";
|
||||
import { useAuthorCandidates, useRepositoryList } from "../data/repository";
|
||||
import { loadSearchHistory } from "../data/searchHistory";
|
||||
import type { BatchesState } from "../data/batches";
|
||||
import { LabelSwatch } from "../ui/chips";
|
||||
import { ChooserPopup, type ChooserItemPresentation } from "../ui/ChooserPopup";
|
||||
import {
|
||||
isQuickFilterSelected,
|
||||
quickFilters,
|
||||
REVIEW_OPTIONS,
|
||||
reviewText,
|
||||
searchHistoryText,
|
||||
SORT_OPTIONS,
|
||||
sortText,
|
||||
STATE_OPTIONS,
|
||||
stateText,
|
||||
withQuery,
|
||||
type ChipOption,
|
||||
} from "./model";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
type Apply = (v: PrSearchValue) => void;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search field
|
||||
|
||||
function SearchTextField({ search, apply }: { search: PrSearchValue; apply: Apply }) {
|
||||
const applied = search.searchQuery ?? "";
|
||||
const [text, setText] = useState(applied);
|
||||
// The text follows the search when it changes elsewhere (history, Quick Filters, Clear).
|
||||
const [shown, setShown] = useState(applied);
|
||||
if (shown !== applied) {
|
||||
setShown(applied);
|
||||
setText(applied);
|
||||
}
|
||||
const field = useRef<HTMLDivElement>(null);
|
||||
const showHistory = () => {
|
||||
const r = field.current?.getBoundingClientRect();
|
||||
const history = loadSearchHistory();
|
||||
const items: MenuItem[] = history.length
|
||||
? history.map((v, i) => ({
|
||||
id: `h${i}`,
|
||||
label: searchHistoryText(v),
|
||||
verbatim: true,
|
||||
run: () => apply(v),
|
||||
}))
|
||||
: [{ label: "Nothing to show", disabled: true }];
|
||||
showContextMenu({ x: (r?.left ?? 0) + 2, y: r?.bottom ?? 0 }, items);
|
||||
};
|
||||
return (
|
||||
<div ref={field}>
|
||||
<SearchField
|
||||
value={text}
|
||||
onChange={setText}
|
||||
onHistory={showHistory}
|
||||
clearable={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
apply(withQuery(search, text));
|
||||
}}
|
||||
aria-label="Search pull requests"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Filters
|
||||
|
||||
function QuickFilterButton({ search, apply, login }: { search: PrSearchValue; apply: Apply; login: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchor = useRef<HTMLSpanElement>(null);
|
||||
const count = filterCount(search);
|
||||
const items: MenuItem[] = [
|
||||
{ kind: "header", label: "Quick Filters" },
|
||||
...quickFilters(login).map((q): MenuItem => ({
|
||||
id: q.id,
|
||||
label: q.title,
|
||||
checked: isQuickFilterSelected(search, q),
|
||||
run: () => apply(q.filter),
|
||||
})),
|
||||
{ kind: "separator" },
|
||||
...(count > 0 ? [{ id: "clear", label: tx`Clear ${count} Filters`, verbatim: true, run: () => apply({}) }] : []),
|
||||
];
|
||||
return (
|
||||
<span ref={anchor} className={styles.quickFilter}>
|
||||
<IconButton icon="filter" title="Quick Filters" pressed={open} onClick={() => setOpen((o) => !o)} />
|
||||
{count > 0 && <span className={styles.badge} aria-hidden data-badge />}
|
||||
<Menu open={open} onClose={() => setOpen(false)} anchor={anchor} items={items} aria-label="Quick Filters" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chips
|
||||
|
||||
type PopupRender = (anchor: HTMLElement, done: (value: string | undefined) => void) => ReactNode;
|
||||
|
||||
function FilterChip({
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
popup,
|
||||
}: {
|
||||
title: string;
|
||||
/** The chip's text for the current value; undefined when unset. */
|
||||
value: string | undefined;
|
||||
onChange: (value: string | undefined) => void;
|
||||
popup: PopupRender;
|
||||
}) {
|
||||
const t = useTx();
|
||||
const chip = useRef<HTMLSpanElement>(null);
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const set = value !== undefined;
|
||||
const name = t(title);
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
onChange(undefined);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span ref={chip} className={styles.chip} data-set={set || undefined} data-tooltip={title}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipMain}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={!!anchor}
|
||||
onClick={() => setAnchor(chip.current)}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span className={styles.chipText}>{set ? value : name}</span>
|
||||
{!set && <Icon name="chevron-down" size={12} className={styles.chipChevron} />}
|
||||
</button>
|
||||
{set && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipReset}
|
||||
aria-label={t`Reset ${name}`}
|
||||
onClick={() => onChange(undefined)}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
)}
|
||||
{anchor &&
|
||||
popup(anchor, (v) => {
|
||||
setAnchor(null);
|
||||
onChange(v);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ROW = 28;
|
||||
/** List popups show up to 7 rows under the search field. */
|
||||
const optionsHeight = (n: number) => 44 + Math.min(n, 7) * ROW + 4;
|
||||
|
||||
/** `ChooserPopupUtil.showChooserPopup` / `showChooserPopupWithIncrementalLoading`: the chosen value, or undefined when closed without choosing. */
|
||||
function Chooser<T>({
|
||||
anchor,
|
||||
done,
|
||||
title,
|
||||
items,
|
||||
getKey,
|
||||
present,
|
||||
state,
|
||||
height,
|
||||
}: {
|
||||
anchor: HTMLElement;
|
||||
done: (item: T | undefined) => void;
|
||||
title: string;
|
||||
items: readonly T[];
|
||||
getKey: (item: T) => string;
|
||||
present: (item: T) => ChooserItemPresentation;
|
||||
state?: Pick<BatchesState<T>, "loading" | "error">;
|
||||
height?: number;
|
||||
}) {
|
||||
const chosen = useRef<{ item: T } | null>(null);
|
||||
return (
|
||||
<ChooserPopup<T>
|
||||
mode="single"
|
||||
anchor={anchor}
|
||||
items={items}
|
||||
getKey={getKey}
|
||||
present={present}
|
||||
loading={state?.loading}
|
||||
error={state?.error}
|
||||
height={height}
|
||||
aria-label={title}
|
||||
onChoose={(item) => (chosen.current = { item })}
|
||||
onClose={() => done(chosen.current?.item)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionChip<V extends string>({
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
text,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
options: readonly ChipOption<V>[];
|
||||
value: V | undefined;
|
||||
text: (v: V) => string;
|
||||
onChange: (v: V | undefined) => void;
|
||||
}) {
|
||||
return (
|
||||
<FilterChip
|
||||
title={title}
|
||||
value={value === undefined ? undefined : text(value)}
|
||||
onChange={(v) => onChange(v as V | undefined)}
|
||||
popup={(anchor, done) => (
|
||||
<Chooser<ChipOption<V>>
|
||||
anchor={anchor}
|
||||
title={title}
|
||||
items={options}
|
||||
getKey={(o) => o.value}
|
||||
present={(o) => ({ text: tx(o.label ?? o.text) })}
|
||||
height={optionsHeight(options.length)}
|
||||
done={(o) => done(o?.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const presentUser = (u: GHUser): ChooserItemPresentation => ({ text: u.login, detail: u.name, avatarUrl: u.avatarUrl });
|
||||
const userKey = (u: GHUser) => u.id || u.login;
|
||||
|
||||
function AuthorChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) {
|
||||
const authors = useAuthorCandidates();
|
||||
return (
|
||||
<Chooser<GHUser>
|
||||
anchor={anchor}
|
||||
title="Author"
|
||||
items={authors.items}
|
||||
state={authors}
|
||||
getKey={userKey}
|
||||
present={presentUser}
|
||||
done={(u) => done(u?.login)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneeChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) {
|
||||
const assignees = useRepositoryList("assignees");
|
||||
return (
|
||||
<Chooser<GHUser>
|
||||
anchor={anchor}
|
||||
title="Assignee"
|
||||
items={assignees.items}
|
||||
state={assignees}
|
||||
getKey={userKey}
|
||||
present={presentUser}
|
||||
done={(u) => done(u?.login)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) {
|
||||
const labels = useRepositoryList("labels");
|
||||
return (
|
||||
<Chooser<GHLabel>
|
||||
anchor={anchor}
|
||||
title="Label"
|
||||
items={labels.items}
|
||||
state={labels}
|
||||
getKey={(l) => l.id || l.name}
|
||||
present={(l) => ({ text: l.name, leading: <LabelSwatch label={l} /> })}
|
||||
done={(l) => done(l?.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The chips' row: scrolls horizontally, fading the clipped edges (`GradientViewport`). */
|
||||
function ChipsRow({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [fade, setFade] = useState<"left" | "right" | "both" | undefined>(undefined);
|
||||
const update = () => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const left = el.scrollLeft > 0;
|
||||
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||||
setFade(left && right ? "both" : left ? "left" : right ? "right" : undefined);
|
||||
};
|
||||
useEffect(() => {
|
||||
update();
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.chips}
|
||||
data-fade={fade}
|
||||
onScroll={update}
|
||||
onWheel={(e) => {
|
||||
// A vertical wheel scrolls the row sideways, as JBScrollPane's FORCE_HORIZONTAL_SCROLL.
|
||||
const el = ref.current;
|
||||
if (el && Math.abs(e.deltaY) > Math.abs(e.deltaX)) el.scrollLeft += e.deltaY;
|
||||
}}
|
||||
role="toolbar"
|
||||
aria-label="Filters"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SearchPanel({ search, apply, login }: { search: PrSearchValue; apply: Apply; login: string }) {
|
||||
const patch = (p: Partial<PrSearchValue>) => {
|
||||
const next: PrSearchValue = { ...search, ...p };
|
||||
for (const k of Object.keys(p) as (keyof PrSearchValue)[]) if (next[k] === undefined) delete next[k];
|
||||
apply(next);
|
||||
};
|
||||
return (
|
||||
<div className={styles.search}>
|
||||
<SearchTextField search={search} apply={apply} />
|
||||
<div className={styles.filterRow}>
|
||||
<QuickFilterButton search={search} apply={apply} login={login} />
|
||||
<ChipsRow>
|
||||
<OptionChip
|
||||
title="State"
|
||||
options={STATE_OPTIONS}
|
||||
value={search.state}
|
||||
text={stateText}
|
||||
onChange={(state) => patch({ state })}
|
||||
/>
|
||||
<FilterChip
|
||||
title="Author"
|
||||
value={search.author}
|
||||
onChange={(author) => patch({ author })}
|
||||
popup={(anchor, done) => <AuthorChooser anchor={anchor} done={done} />}
|
||||
/>
|
||||
<FilterChip
|
||||
title="Label"
|
||||
value={search.label}
|
||||
onChange={(label) => patch({ label })}
|
||||
popup={(anchor, done) => <LabelChooser anchor={anchor} done={done} />}
|
||||
/>
|
||||
<FilterChip
|
||||
title="Assignee"
|
||||
value={search.assignee}
|
||||
onChange={(assignee) => patch({ assignee })}
|
||||
popup={(anchor, done) => <AssigneeChooser anchor={anchor} done={done} />}
|
||||
/>
|
||||
<OptionChip
|
||||
title="Review"
|
||||
options={REVIEW_OPTIONS}
|
||||
value={search.reviewState}
|
||||
text={reviewText}
|
||||
onChange={(reviewState) => patch({ reviewState })}
|
||||
/>
|
||||
<OptionChip
|
||||
title="Sort"
|
||||
options={SORT_OPTIONS}
|
||||
value={search.sort}
|
||||
text={sortText}
|
||||
onChange={(sort) => patch({ sort })}
|
||||
/>
|
||||
</ChipsRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import * as ipc from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useContextMenuStore, type MenuItem } from "@/ui";
|
||||
import { setCurrentRepository } from "../api/client";
|
||||
import type { GHPullRequestShort, GHRepository, GHUser } from "../api/types";
|
||||
import { clearList, useGhPrList } from "../data/list";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { loadLastSearch, loadSearchHistory } from "../data/searchHistory";
|
||||
import { resetTabs, useGhTabs } from "../toolwindow/store";
|
||||
import { ListPanel } from "./ListPanel";
|
||||
import {
|
||||
createSearchHistoryRecorder,
|
||||
infoLine,
|
||||
isQuickFilterSelected,
|
||||
pastThreshold,
|
||||
quickFilters,
|
||||
rowReviewers,
|
||||
searchHistoryText,
|
||||
unresolvedCommentsTooltip,
|
||||
withQuery,
|
||||
} from "./model";
|
||||
|
||||
const me: GHUser = { __typename: "User", id: "U1", login: "me", url: "", avatarUrl: "", name: null };
|
||||
const user = (login: string, name: string | null = null): GHUser => ({
|
||||
__typename: "User",
|
||||
id: `id-${login}`,
|
||||
login,
|
||||
url: "",
|
||||
avatarUrl: "",
|
||||
name,
|
||||
});
|
||||
const nodes = <T,>(n: T[]) => ({ nodes: n });
|
||||
|
||||
function pr(number: number, over: Partial<GHPullRequestShort> = {}): GHPullRequestShort {
|
||||
return {
|
||||
id: `PR${number}`,
|
||||
url: `https://github.com/org/app/pull/${number}`,
|
||||
number,
|
||||
title: `Pull request ${number}`,
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
author: user("ann"),
|
||||
createdAt: "2020-01-02T10:00:00Z",
|
||||
updatedAt: "2020-01-02T10:00:00Z",
|
||||
isReadByViewer: true,
|
||||
assignees: nodes([]),
|
||||
labels: nodes([]),
|
||||
reviewRequests: nodes([]),
|
||||
reviewThreads: nodes([]),
|
||||
reviews: nodes([]),
|
||||
reactions: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } as never,
|
||||
mergeable: "MERGEABLE",
|
||||
viewerCanUpdate: false,
|
||||
viewerCanReact: false,
|
||||
viewerDidAuthor: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("search model", () => {
|
||||
it("offers IntelliJ's quick filters, checked only on an exact match", () => {
|
||||
const q = quickFilters("me");
|
||||
expect(q.map((f) => f.title)).toEqual(["Open", "Your pull requests", "Assigned to you", "Review requests"]);
|
||||
expect(q[1]!.filter).toEqual({ state: "OPEN", author: "me" });
|
||||
expect(q[3]!.filter).toEqual({ state: "OPEN", reviewState: "AWAITING_REVIEW" });
|
||||
expect(isQuickFilterSelected({ state: "OPEN", assignee: "me" }, q[2]!)).toBe(true);
|
||||
expect(isQuickFilterSelected({ state: "OPEN", assignee: "me", sort: "NEWEST" }, q[2]!)).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the history text like getShortText", () => {
|
||||
expect(
|
||||
searchHistoryText({
|
||||
searchQuery: "foo",
|
||||
state: "OPEN",
|
||||
label: "bug",
|
||||
assignee: "me",
|
||||
reviewState: "AWAITING_REVIEW",
|
||||
author: "x",
|
||||
sort: "NEWEST",
|
||||
}),
|
||||
).toBe('"foo" state:"Open" label:bug assignee:me reviewState:"Awaiting review" author:x sort:"Newest"');
|
||||
expect(searchHistoryText({ reviewState: "REVIEWED_BY_ME" })).toBe('reviewState:"Reviewed"');
|
||||
});
|
||||
|
||||
it("sets or clears the text", () => {
|
||||
expect(withQuery({ state: "OPEN" }, "fix")).toEqual({ state: "OPEN", searchQuery: "fix" });
|
||||
expect(withQuery({ state: "OPEN", searchQuery: "fix" }, "")).toEqual({ state: "OPEN" });
|
||||
});
|
||||
|
||||
it("saves searches by IntelliJ's rules, waiting 10 seconds for chip-only changes", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const saveLast = vi.fn();
|
||||
const remember = vi.fn();
|
||||
const r = createSearchHistoryRecorder({ defaultSearch: { state: "OPEN", assignee: "me" }, saveLast, remember });
|
||||
r.onChange({ state: "OPEN", assignee: "me" });
|
||||
expect(saveLast).toHaveBeenCalledTimes(1);
|
||||
r.onChange({ state: "OPEN", searchQuery: "a" });
|
||||
expect(remember).toHaveBeenLastCalledWith({ state: "OPEN", searchQuery: "a" });
|
||||
r.onChange({ state: "CLOSED", searchQuery: "a" });
|
||||
vi.advanceTimersByTime(5_000);
|
||||
r.onChange({ state: "MERGED", searchQuery: "a" });
|
||||
vi.advanceTimersByTime(9_999);
|
||||
expect(remember).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(remember).toHaveBeenLastCalledWith({ state: "MERGED", searchQuery: "a" });
|
||||
r.onChange({});
|
||||
r.onChange({ state: "OPEN", assignee: "me" });
|
||||
vi.advanceTimersByTime(20_000);
|
||||
expect(remember).toHaveBeenCalledTimes(2);
|
||||
expect(saveLast).toHaveBeenCalledTimes(6);
|
||||
r.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("row model", () => {
|
||||
it("orders reviewers approved → changes requested → commented → re-requested → needs review", () => {
|
||||
const review = (author: GHUser, state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED", at: number) =>
|
||||
({
|
||||
__typename: "PullRequestReview",
|
||||
id: `${author.login}${at}`,
|
||||
url: "",
|
||||
author,
|
||||
body: "",
|
||||
state,
|
||||
createdAt: new Date(at * 1000).toISOString(),
|
||||
viewerCanUpdate: false,
|
||||
}) as const;
|
||||
const p = pr(1, {
|
||||
reviews: nodes([
|
||||
review(user("c"), "COMMENTED", 1),
|
||||
review(user("a", "Ada"), "APPROVED", 2),
|
||||
review(user("r"), "COMMENTED", 3),
|
||||
review(user("x"), "CHANGES_REQUESTED", 4),
|
||||
]),
|
||||
reviewRequests: nodes([{ requestedReviewer: user("n") }, { requestedReviewer: user("r") }]),
|
||||
});
|
||||
expect(rowReviewers(p).map((r) => [r.name, r.state])).toEqual([
|
||||
["Ada", "APPROVED"],
|
||||
["x", "CHANGES_REQUESTED"],
|
||||
["c", "COMMENTED"],
|
||||
["r", "RE_REQUESTED"],
|
||||
["n", "NEEDS_REVIEW"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats the info line and the comment tooltip", () => {
|
||||
expect(infoLine(12, "Today", "ann")).toBe("#12 · created Today, by ann");
|
||||
expect(infoLine(12, "Today", null)).toBe("#12 · created Today");
|
||||
expect(unresolvedCommentsTooltip(0)).toBe("No unresolved comments");
|
||||
expect(unresolvedCommentsTooltip(1)).toBe("1 unresolved comment");
|
||||
expect(unresolvedCommentsTooltip(3)).toBe("3 unresolved comments");
|
||||
});
|
||||
|
||||
it("requests the next page past 70% of the scroll", () => {
|
||||
expect(pastThreshold(0, 300, 1000)).toBe(false);
|
||||
expect(pastThreshold(400, 300, 1000)).toBe(true);
|
||||
expect(pastThreshold(0, 300, 200)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const repository: GHRepository = {
|
||||
id: "R1",
|
||||
owner: { __typename: "User", id: "O1", login: "org" },
|
||||
nameWithOwner: "org/app",
|
||||
viewerPermission: "READ",
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
defaultBranchRef: { name: "main" },
|
||||
isFork: false,
|
||||
url: "https://github.com/org/app",
|
||||
sshUrl: "",
|
||||
};
|
||||
|
||||
describe("ListPanel", () => {
|
||||
let pages: GHPullRequestShort[][] = [];
|
||||
let queries: string[] = [];
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
queries = [];
|
||||
vi.spyOn(ipc.commands, "ghApi").mockImplementation(async (req) => {
|
||||
const body = JSON.parse(req.body!) as { variables: { query: string } };
|
||||
queries.push(body.variables.query);
|
||||
const page = pages.shift() ?? [];
|
||||
return {
|
||||
status: "ok",
|
||||
data: JSON.stringify({
|
||||
data: {
|
||||
search: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: page,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as never;
|
||||
});
|
||||
setCurrentRepository({ owner: "org", name: "app", id: "R1" });
|
||||
useGhRepository.setState({
|
||||
availability: { kind: "ready", repo: {} as never, login: "me" },
|
||||
remotes: [{ owner: "org", name: "app", remote: "origin", url: "" }],
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearList();
|
||||
useGhPrList.setState({ search: {} });
|
||||
useGhRepository.setState({ connection: null, availability: null, remotes: [] });
|
||||
setCurrentRepository(null);
|
||||
resetTabs();
|
||||
useContextMenuStore.setState({ menu: null });
|
||||
});
|
||||
|
||||
const mount = async () => {
|
||||
await act(async () => {
|
||||
render(<ListPanel />);
|
||||
});
|
||||
};
|
||||
|
||||
it("starts with Assigned to you and renders the rows", async () => {
|
||||
pages = [
|
||||
[
|
||||
pr(7, {
|
||||
title: "Fix the thing",
|
||||
isDraft: true,
|
||||
mergeable: "CONFLICTING",
|
||||
author: me,
|
||||
labels: nodes([{ id: "L", url: "", name: "bug", color: "ff0000" }]),
|
||||
reviewThreads: nodes([
|
||||
{ isResolved: false, isOutdated: false },
|
||||
{ isResolved: true, isOutdated: false },
|
||||
{ isResolved: false, isOutdated: true },
|
||||
]),
|
||||
assignees: nodes([user("a"), user("b"), user("c")]),
|
||||
}),
|
||||
pr(8, { state: "MERGED", author: null }),
|
||||
],
|
||||
];
|
||||
await mount();
|
||||
expect(queries[0]).toBe("type:pr repo:org/app is:open assignee:me");
|
||||
const rows = screen.getAllByRole("option");
|
||||
expect(rows).toHaveLength(2);
|
||||
const first = within(rows[0]!);
|
||||
expect(first.getByText("Fix the thing")).toBeTruthy();
|
||||
expect(first.getByText("DRAFT")).toBeTruthy();
|
||||
expect(rows[0]!.querySelector("[data-tooltip='Pull request cannot be merged']")).toBeTruthy();
|
||||
expect(rows[0]!.querySelector("[data-tooltip='1 unresolved comment']")?.textContent).toBe("1");
|
||||
expect(rows[0]!.querySelectorAll("[data-avatar]")).toHaveLength(2);
|
||||
// Authored by me, never seen: unread.
|
||||
expect(rows[0]!.querySelector("[data-unread]")).toBeTruthy();
|
||||
expect(first.getByText(/^#7 · created .+, by me$/)).toBeTruthy();
|
||||
expect(within(rows[1]!).getByText("MERGED")).toBeTruthy();
|
||||
expect(within(rows[1]!).getByText(/^#8 · created /).textContent).not.toContain(", by");
|
||||
expect(rows[1]!.querySelector("[data-unread]")).toBeNull();
|
||||
expect(loadLastSearch()).toEqual({ state: "OPEN", assignee: "me" });
|
||||
});
|
||||
|
||||
it("opens the context menu with IntelliJ's items on the clicked row", async () => {
|
||||
pages = [[pr(1), pr(2)]];
|
||||
await mount();
|
||||
fireEvent.contextMenu(screen.getAllByRole("option")[1]!);
|
||||
expect(useGhTabs.getState().listSelection).toBe(2);
|
||||
const items = useContextMenuStore.getState().menu!.items;
|
||||
expect(items.map((i: MenuItem) => ("action" in i ? i.action : i.kind))).toEqual([
|
||||
"github.pullRequest.show",
|
||||
"github.pullRequest.checkoutBranch",
|
||||
"github.pullRequest.openInWorktree",
|
||||
"github.pullRequest.openInBrowser",
|
||||
"github.pullRequest.copyUrl",
|
||||
"separator",
|
||||
"github.refresh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the empty states and clears filters", async () => {
|
||||
pages = [[], []];
|
||||
await mount();
|
||||
expect(screen.getByText("No pull requests matching filters")).toBeTruthy();
|
||||
await act(async () => fireEvent.click(screen.getByText("Clear filters")));
|
||||
expect(queries[1]).toBe("type:pr repo:org/app");
|
||||
expect(screen.getByText("No pull requests in app")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows load errors with Retry", async () => {
|
||||
vi.mocked(ipc.commands.ghApi).mockRejectedValueOnce(new Error("HTTP 502"));
|
||||
await mount();
|
||||
expect(screen.getByText("Can't load pull requests")).toBeTruthy();
|
||||
pages = [[pr(3)]];
|
||||
await act(async () => fireEvent.click(screen.getByText("Retry")));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies the text on Enter and keeps it in the history", async () => {
|
||||
pages = [[], []];
|
||||
await mount();
|
||||
const field = screen.getByRole("textbox", { name: "Search pull requests" });
|
||||
fireEvent.change(field, { target: { value: "crash" } });
|
||||
await act(async () => fireEvent.keyDown(field, { key: "Enter" }));
|
||||
expect(queries[1]).toBe("type:pr repo:org/app (crash) is:open assignee:me");
|
||||
expect(loadSearchHistory()).toEqual([{ state: "OPEN", assignee: "me", searchQuery: "crash" }]);
|
||||
});
|
||||
|
||||
it("resets a chip with its ✕ and with Delete, and shows the quick filter badge", async () => {
|
||||
pages = [[], [], []];
|
||||
await mount();
|
||||
expect(document.querySelector("[data-badge]")).toBeTruthy();
|
||||
const assignee = screen.getByText("me").closest("button")!;
|
||||
await act(async () => fireEvent.keyDown(assignee, { key: "Delete" }));
|
||||
expect(queries[1]).toBe("type:pr repo:org/app is:open");
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Reset State" })));
|
||||
expect(queries[2]).toBe("type:pr repo:org/app");
|
||||
expect(document.querySelector("[data-badge]")).toBeNull();
|
||||
expect(screen.getByText("State")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("chooses a chip value from its popup", async () => {
|
||||
pages = [[], []];
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByText("Sort")));
|
||||
await act(async () => fireEvent.click(screen.getByText("Least recently updated")));
|
||||
expect(queries[1]).toBe("type:pr repo:org/app is:open assignee:me sort:updated-asc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Pure rules of the pull request list, ported from IntelliJ: the Quick Filters
|
||||
* (`GHPRListQuickFilter`), the chips' option lists and texts (`GHPRSearchPanelFactory`),
|
||||
* the search history's text (`getShortText`) and save rule
|
||||
* (`ReviewListSearchPanelViewModelBase`), the row's presentation
|
||||
* (`GHPRListComponentFactory`) and the 70% paging threshold (`ReviewListUtil`).
|
||||
*/
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { GHPRReviewerState, GHPullRequestRequestedReviewer, GHPullRequestShort } from "../api/types";
|
||||
import {
|
||||
filterCount,
|
||||
isTeam,
|
||||
sameSearch,
|
||||
sortedReviewersStates,
|
||||
type PrSearchReviewState,
|
||||
type PrSearchSort,
|
||||
type PrSearchState,
|
||||
type PrSearchValue,
|
||||
} from "../data/model";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Filters
|
||||
|
||||
export type QuickFilterId = "OPEN" | "YOURS" | "ASSIGNED" | "REVIEW_REQUESTS";
|
||||
|
||||
export interface QuickFilter {
|
||||
id: QuickFilterId;
|
||||
title: string;
|
||||
filter: PrSearchValue;
|
||||
}
|
||||
|
||||
/** The Quick Filters of the signed-in user, in menu order. */
|
||||
export function quickFilters(login: string): QuickFilter[] {
|
||||
return [
|
||||
{ id: "OPEN", title: "Open", filter: { state: "OPEN" } },
|
||||
{ id: "YOURS", title: "Your pull requests", filter: { state: "OPEN", author: login } },
|
||||
{ id: "ASSIGNED", title: "Assigned to you", filter: { state: "OPEN", assignee: login } },
|
||||
{ id: "REVIEW_REQUESTS", title: "Review requests", filter: { state: "OPEN", reviewState: "AWAITING_REVIEW" } },
|
||||
];
|
||||
}
|
||||
|
||||
/** The list's first-run filter and the one the history never keeps: "Assigned to you". */
|
||||
export const defaultFilter = (login: string): PrSearchValue => ({ state: "OPEN", assignee: login });
|
||||
|
||||
/** A quick filter is checked only when the search is exactly it. */
|
||||
export const isQuickFilterSelected = (search: PrSearchValue, q: QuickFilter) => sameSearch(search, q.filter);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chips
|
||||
|
||||
export interface ChipOption<V extends string> {
|
||||
value: V;
|
||||
/** The chip's text (and the history's). */
|
||||
text: string;
|
||||
/** The popup's text, when it differs. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const STATE_OPTIONS: ChipOption<PrSearchState>[] = [
|
||||
{ value: "OPEN", text: "Open" },
|
||||
{ value: "CLOSED", text: "Closed" },
|
||||
{ value: "MERGED", text: "Merged" },
|
||||
];
|
||||
|
||||
export const REVIEW_OPTIONS: ChipOption<PrSearchReviewState>[] = [
|
||||
{ value: "NO_REVIEW", text: "No reviews" },
|
||||
{ value: "REQUIRED", text: "Review required" },
|
||||
{ value: "APPROVED", text: "Approved review" },
|
||||
{ value: "CHANGES_REQUESTED", text: "Changes requested" },
|
||||
{ value: "REVIEWED_BY_ME", text: "Reviewed", label: "Reviewed by you" },
|
||||
{ value: "NOT_REVIEWED_BY_ME", text: "Not reviewed", label: "Not reviewed by you" },
|
||||
{ value: "AWAITING_REVIEW", text: "Awaiting review", label: "Awaiting review from you" },
|
||||
];
|
||||
|
||||
export const SORT_OPTIONS: ChipOption<PrSearchSort>[] = [
|
||||
{ value: "NEWEST", text: "Newest" },
|
||||
{ value: "OLDEST", text: "Oldest" },
|
||||
{ value: "MOST_COMMENTED", text: "Most commented" },
|
||||
{ value: "LEAST_COMMENTED", text: "Least commented" },
|
||||
{ value: "RECENTLY_UPDATED", text: "Recently updated" },
|
||||
{ value: "LEAST_RECENTLY_UPDATED", text: "Least recently updated" },
|
||||
];
|
||||
|
||||
const optionText = <V extends string>(options: readonly ChipOption<V>[], v: V) =>
|
||||
tx(options.find((o) => o.value === v)?.text ?? v);
|
||||
|
||||
export const stateText = (v: PrSearchState) => optionText(STATE_OPTIONS, v);
|
||||
export const reviewText = (v: PrSearchReviewState) => optionText(REVIEW_OPTIONS, v);
|
||||
export const sortText = (v: PrSearchSort) => optionText(SORT_OPTIONS, v);
|
||||
|
||||
/**
|
||||
* The history popup's text of a search (`getShortText`), e.g.
|
||||
* `"foo" state:"Open" label:bug assignee:me reviewState:"Awaiting review" author:x sort:"Newest"`.
|
||||
*/
|
||||
export function searchHistoryText(v: PrSearchValue): string {
|
||||
const parts: string[] = [];
|
||||
if (v.searchQuery !== undefined) parts.push(`"${v.searchQuery}"`);
|
||||
if (v.state) parts.push(`state:"${stateText(v.state)}"`);
|
||||
if (v.label !== undefined) parts.push(`label:${v.label}`);
|
||||
if (v.assignee !== undefined) parts.push(`assignee:${v.assignee}`);
|
||||
if (v.reviewState) parts.push(`reviewState:"${reviewText(v.reviewState)}"`);
|
||||
if (v.author !== undefined) parts.push(`author:${v.author}`);
|
||||
if (v.sort) parts.push(`sort:"${sortText(v.sort)}"`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** `v` with the text set (empty text clears it, as Enter in the field). */
|
||||
export function withQuery(v: PrSearchValue, text: string): PrSearchValue {
|
||||
const { searchQuery: _, ...rest } = v;
|
||||
return text ? { ...rest, searchQuery: text } : rest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History saving
|
||||
|
||||
/** IntelliJ waits this long before keeping a search whose text did not change (chip-only changes). */
|
||||
export const CHIP_ONLY_SAVE_DELAY_MS = 10_000;
|
||||
|
||||
export interface SearchHistoryRecorder {
|
||||
/** The search changed to `v` (the first call is the initial value). */
|
||||
onChange(v: PrSearchValue): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ReviewListSearchPanelViewModelBase.updateHistoryOnSearchChanges`: every value is saved
|
||||
* as the last filter; after the first, a value with filters that is not `defaultSearch` is
|
||||
* added to the history, at once when its text changed, else after 10 seconds unless the
|
||||
* search changes again in the meantime.
|
||||
*/
|
||||
export function createSearchHistoryRecorder(opts: {
|
||||
defaultSearch: PrSearchValue;
|
||||
saveLast: (v: PrSearchValue) => void;
|
||||
remember: (v: PrSearchValue) => void;
|
||||
delayMs?: number;
|
||||
}): SearchHistoryRecorder {
|
||||
let previous: PrSearchValue | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return {
|
||||
onChange(v) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
const old = previous;
|
||||
previous = v;
|
||||
opts.saveLast(v);
|
||||
if (!old) return;
|
||||
if (filterCount(v) === 0 || sameSearch(v, opts.defaultSearch)) return;
|
||||
if (old.searchQuery === v.searchQuery)
|
||||
timer = setTimeout(() => opts.remember(v), opts.delayMs ?? CHIP_ONLY_SAVE_DELAY_MS);
|
||||
else opts.remember(v);
|
||||
},
|
||||
dispose() {
|
||||
clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rows
|
||||
|
||||
/** `NamedCollection` titles: "Label" / "Labels" and so on, by count. */
|
||||
export function labelsTitle(n: number) {
|
||||
return n === 1 ? tx("Label") : tx("Labels");
|
||||
}
|
||||
export function assigneesTitle(n: number) {
|
||||
return n === 1 ? tx("Assignee") : tx("Assignees");
|
||||
}
|
||||
export function reviewersTitle(n: number) {
|
||||
return n === 1 ? tx("Reviewer") : tx("Reviewers");
|
||||
}
|
||||
|
||||
/** The comment counter's tooltip (`pull.request.unresolved.comments`). */
|
||||
export function unresolvedCommentsTooltip(count: number): string {
|
||||
if (count === 0) return tx("No unresolved comments");
|
||||
return count === 1 ? tx`${count} unresolved comment` : tx`${count} unresolved comments`;
|
||||
}
|
||||
|
||||
/** The second line: `#N · created {date}, by {login}`, or without the author. */
|
||||
export function infoLine(number: number, date: string, author: string | null | undefined): string {
|
||||
const id = `#${number}`;
|
||||
return author ? tx`${id} · created ${date}, by ${author}` : tx`${id} · created ${date}`;
|
||||
}
|
||||
|
||||
export interface ReviewerPresentation {
|
||||
key: string;
|
||||
/** Avatar tooltip / list text: the full name, else the login (team: name, else slug). */
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
state: GHPRReviewerState;
|
||||
}
|
||||
|
||||
function reviewerPresentation(r: GHPullRequestRequestedReviewer, state: GHPRReviewerState): ReviewerPresentation {
|
||||
if (isTeam(r)) return { key: r.id || r.combinedSlug, name: r.teamName || r.slug, avatarUrl: r.teamAvatarUrl, state };
|
||||
return { key: r.id || r.login, name: r.name || r.login, avatarUrl: r.avatarUrl, state };
|
||||
}
|
||||
|
||||
/** The row's reviewers in display order: approved → changes requested → commented → re-requested → needs review. */
|
||||
export function rowReviewers(pr: Pick<GHPullRequestShort, "author" | "reviews" | "reviewRequests">) {
|
||||
return sortedReviewersStates(pr).map((r) => reviewerPresentation(r.reviewer, r.state));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paging
|
||||
|
||||
/** `BoundedRangeModelThresholdListener(0.7)`: the visible part reaches 70% of the content (or there is no scroll). */
|
||||
export function pastThreshold(scrollTop: number, clientHeight: number, scrollHeight: number, threshold = 0.7): boolean {
|
||||
if (scrollHeight <= 0) return true;
|
||||
return (scrollTop + clientHeight) / scrollHeight >= threshold;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/** Pure helpers of the Pull Requests view (labels, check summaries, review threads, diff inputs). */
|
||||
import type {
|
||||
ChangedFile,
|
||||
PrCheck,
|
||||
PrDiffRevisions,
|
||||
PrFile,
|
||||
PrReviewComment,
|
||||
PullRequestDetails,
|
||||
PullRequestSummary,
|
||||
} from "@/platform/ipc";
|
||||
import { t, tx } from "@/platform/i18n";
|
||||
|
||||
/** Keyed, not source text: "Open" as a state reads differently from the "Open" action in some locales. */
|
||||
export function stateLabel(pr: PullRequestSummary): string {
|
||||
if (pr.state === "merged") return t("github.prState.merged");
|
||||
if (pr.state === "closed") return t("github.prState.closed");
|
||||
return pr.isDraft ? t("github.prState.draft") : t("github.prState.open");
|
||||
}
|
||||
|
||||
export function reviewDecisionLabel(decision: string | null): string | null {
|
||||
switch (decision) {
|
||||
case "APPROVED":
|
||||
return tx("Approved");
|
||||
case "CHANGES_REQUESTED":
|
||||
return tx("Changes requested");
|
||||
case "REVIEW_REQUIRED":
|
||||
return tx("Review required");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function reviewStateLabel(state: string): string {
|
||||
switch (state) {
|
||||
case "APPROVED":
|
||||
return tx("approved");
|
||||
case "CHANGES_REQUESTED":
|
||||
return tx("requested changes");
|
||||
case "COMMENTED":
|
||||
return tx("reviewed");
|
||||
case "DISMISSED":
|
||||
return tx("review dismissed");
|
||||
case "REQUESTED":
|
||||
return tx("review requested");
|
||||
default:
|
||||
return state.toLowerCase().replace(/_/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChecksSummary {
|
||||
failed: number;
|
||||
pending: number;
|
||||
passed: number;
|
||||
/** Skipped / neutral / cancelled. */
|
||||
other: number;
|
||||
}
|
||||
|
||||
export function checksSummary(checks: readonly PrCheck[]): ChecksSummary {
|
||||
const s: ChecksSummary = { failed: 0, pending: 0, passed: 0, other: 0 };
|
||||
for (const c of checks) {
|
||||
if (c.status === "failure") s.failed++;
|
||||
else if (c.status === "pending") s.pending++;
|
||||
else if (c.status === "success") s.passed++;
|
||||
else s.other++;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function checksText(s: ChecksSummary): string {
|
||||
const parts: string[] = [];
|
||||
if (s.failed) parts.push(tx`${s.failed} failing`);
|
||||
if (s.pending) parts.push(tx`${s.pending} pending`);
|
||||
if (s.passed) parts.push(tx`${s.passed} passed`);
|
||||
if (s.other) parts.push(tx`${s.other} skipped`);
|
||||
return parts.length ? parts.join(", ") : tx`No checks`;
|
||||
}
|
||||
|
||||
/** "3 minutes ago"-style text for an ISO date. */
|
||||
export function relativeTime(iso: string, now = Date.now()): string {
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return "";
|
||||
const s = Math.max(0, Math.round((now - t) / 1000));
|
||||
const units: Array<[number, (n: number) => string]> = [
|
||||
[60 * 60 * 24 * 365, (n) => (n === 1 ? tx`${n} year ago` : tx`${n} years ago`)],
|
||||
[60 * 60 * 24 * 30, (n) => (n === 1 ? tx`${n} month ago` : tx`${n} months ago`)],
|
||||
[60 * 60 * 24, (n) => (n === 1 ? tx`${n} day ago` : tx`${n} days ago`)],
|
||||
[60 * 60, (n) => (n === 1 ? tx`${n} hour ago` : tx`${n} hours ago`)],
|
||||
[60, (n) => (n === 1 ? tx`${n} minute ago` : tx`${n} minutes ago`)],
|
||||
];
|
||||
for (const [size, text] of units) {
|
||||
const n = Math.floor(s / size);
|
||||
if (n >= 1) return text(n);
|
||||
}
|
||||
return tx("just now");
|
||||
}
|
||||
|
||||
export interface ReviewThread {
|
||||
root: PrReviewComment;
|
||||
replies: PrReviewComment[];
|
||||
}
|
||||
|
||||
/** Groups inline comments into threads (replies under their root), roots in file / line order. */
|
||||
export function reviewThreads(comments: readonly PrReviewComment[]): ReviewThread[] {
|
||||
const byId = new Map<number, ReviewThread>();
|
||||
const threads: ReviewThread[] = [];
|
||||
for (const c of comments) {
|
||||
const parent = c.inReplyTo != null ? byId.get(c.inReplyTo) : undefined;
|
||||
if (parent) {
|
||||
parent.replies.push(c);
|
||||
byId.set(c.id, parent);
|
||||
} else {
|
||||
const t = { root: c, replies: [] };
|
||||
threads.push(t);
|
||||
byId.set(c.id, t);
|
||||
}
|
||||
}
|
||||
return threads.sort(
|
||||
(a, b) =>
|
||||
a.root.path.localeCompare(b.root.path) ||
|
||||
(a.root.line ?? a.root.originalLine ?? 0) - (b.root.line ?? b.root.originalLine ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
/** Annotation shape of the diff viewer (`vcs.openDiff` → DiffData.annotations). */
|
||||
export interface DiffNote {
|
||||
side: "left" | "right";
|
||||
line: number;
|
||||
author: string;
|
||||
body: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/** Current (not outdated) review comments of `path`, as diff notes. */
|
||||
export function diffNotes(path: string, comments: readonly PrReviewComment[]): DiffNote[] {
|
||||
return comments
|
||||
.filter((c) => c.path === path && c.line != null)
|
||||
.map((c) => ({
|
||||
side: c.side === "LEFT" ? "left" : "right",
|
||||
line: c.line! - 1,
|
||||
author: c.author,
|
||||
body: c.body,
|
||||
date: c.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
type Side = { type: "rev"; rev: string; path?: string; label?: string } | { type: "text"; text: string; label: string };
|
||||
|
||||
/** `DiffData` for one changed file of a pull request (merge base ↔ head), with its review comments. */
|
||||
export function fileDiffData(
|
||||
pr: PullRequestDetails,
|
||||
file: PrFile,
|
||||
revs: PrDiffRevisions,
|
||||
comments: readonly PrReviewComment[],
|
||||
) {
|
||||
const root = revs.root;
|
||||
const changed: ChangedFile | undefined = revs.files.find((f) => f.path === file.path);
|
||||
const added = file.changeType === "ADDED" || changed?.kind === "added";
|
||||
const deleted = file.changeType === "DELETED" || changed?.kind === "deleted";
|
||||
const n = pr.summary.number;
|
||||
const left: Side = added
|
||||
? { type: "text", text: "", label: "(does not exist)" }
|
||||
: {
|
||||
type: "rev",
|
||||
rev: revs.base,
|
||||
path: changed?.oldPath ? `${root}/${changed.oldPath}` : undefined,
|
||||
label: `${pr.summary.baseRef} (${revs.base.slice(0, 8)})`,
|
||||
};
|
||||
const right: Side = deleted
|
||||
? { type: "text", text: "", label: "(deleted)" }
|
||||
: { type: "rev", rev: revs.head, label: `#${n} ${pr.summary.headRef}` };
|
||||
const name = file.path.slice(file.path.lastIndexOf("/") + 1);
|
||||
return {
|
||||
path: `${root}/${file.path}`,
|
||||
left,
|
||||
right,
|
||||
title: `#${n}: ${name}`,
|
||||
annotations: diffNotes(file.path, comments),
|
||||
};
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/** Pull request operations shared by the view and the `github.*` actions. */
|
||||
import type { PrFile, PullRequestDetails, ReviewEvent } from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { createPullRequestDialog, numberDialog, reviewDialog } from "./dialogs";
|
||||
import { fileDiffData } from "./model";
|
||||
import {
|
||||
diffRevisions,
|
||||
errorMessage,
|
||||
loadDetails,
|
||||
loadList,
|
||||
readyRepo,
|
||||
refreshAll,
|
||||
useGithub,
|
||||
withBusy,
|
||||
} from "./store";
|
||||
|
||||
export const WINDOW = "pullRequests";
|
||||
|
||||
export async function openUrl(url: string) {
|
||||
try {
|
||||
const { openUrl: open } = await import("@tauri-apps/plugin-opener");
|
||||
await open(url);
|
||||
} catch {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the tool window, (re)checking gh first when needed. */
|
||||
export async function showPullRequests() {
|
||||
toolWindowService().show(WINDOW, true);
|
||||
if (useGithub.getState().availability?.kind !== "ready") await refreshAll();
|
||||
}
|
||||
|
||||
function notReady(): boolean {
|
||||
const a = useGithub.getState().availability;
|
||||
if (a?.kind === "ready") return false;
|
||||
void showPullRequests();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function checkoutPullRequest(number: number) {
|
||||
const done = await withBusy(`Checking out #${number}…`, (r, out) => call(commands.ghPrCheckout(r, number, out)));
|
||||
if (done !== null)
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
title: `Checked out pull request #${number}`,
|
||||
key: "github-checkout",
|
||||
});
|
||||
}
|
||||
|
||||
/** Git › GitHub › Checkout Pull Request…: the selected one, else asks for a number. */
|
||||
export async function checkoutAction(arg: unknown) {
|
||||
if (useGithub.getState().availability?.kind !== "ready") await refreshAll();
|
||||
if (notReady()) return;
|
||||
const n =
|
||||
typeof arg === "number" ? arg : (useGithub.getState().selected ?? (await numberDialog("Checkout Pull Request")));
|
||||
if (n) await checkoutPullRequest(n);
|
||||
}
|
||||
|
||||
export async function openFileDiff(pr: PullRequestDetails, file: PrFile) {
|
||||
const revs = await diffRevisions(pr);
|
||||
if (!revs) return;
|
||||
await executeAction("vcs.openDiff", fileDiffData(pr, file, revs, useGithub.getState().reviewComments));
|
||||
}
|
||||
|
||||
/** Opens a review comment's file at its line in the PR diff. */
|
||||
export async function openCommentLocation(pr: PullRequestDetails, path: string) {
|
||||
const file = pr.files.find((f) => f.path === path) ?? { path, additions: 0, deletions: 0, changeType: "MODIFIED" };
|
||||
await openFileDiff(pr, file);
|
||||
}
|
||||
|
||||
export async function submitReview(number: number, initial: ReviewEvent) {
|
||||
const r = await reviewDialog(number, initial);
|
||||
if (!r) return;
|
||||
try {
|
||||
await call(commands.ghPrReview(number, r.event, r.body));
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
title: `Review submitted on #${number}`,
|
||||
key: "github-review",
|
||||
});
|
||||
await loadDetails(number);
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
title: "Could not submit the review",
|
||||
body: errorMessage(e),
|
||||
key: "github-review",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function addComment(number: number, body: string): Promise<boolean> {
|
||||
try {
|
||||
await call(commands.ghPrComment(number, body));
|
||||
await loadDetails(number);
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
title: "Could not add the comment",
|
||||
body: errorMessage(e),
|
||||
key: "github-comment",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Git › GitHub › Create Pull Request…: from the current branch. */
|
||||
export async function createPullRequest() {
|
||||
if (useGithub.getState().availability?.kind !== "ready") await refreshAll();
|
||||
if (notReady()) return;
|
||||
const repo = readyRepo();
|
||||
let head: string | null = null;
|
||||
let needsPush = true;
|
||||
let title = "";
|
||||
try {
|
||||
const info = await call(commands.vcsBranchInfo(null));
|
||||
head = info.head;
|
||||
needsPush = !info.upstream || info.ahead > 0;
|
||||
if (head)
|
||||
title = head
|
||||
.replace(/^[^/]+\//, "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
} catch {
|
||||
// Not a git repository: gh will explain.
|
||||
}
|
||||
if (!head) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "warning",
|
||||
title: "Check out a branch to create a pull request from",
|
||||
key: "github-create",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (head === repo?.defaultBranch) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "warning",
|
||||
title: `'${head}' is the default branch`,
|
||||
body: "Create a branch for your changes first.",
|
||||
key: "github-create",
|
||||
actions: [{ label: "New Branch…", run: () => void executeAction("vcs.newBranch") }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const input = await createPullRequestDialog({ head, defaultBase: repo?.defaultBranch ?? null, title, needsPush });
|
||||
if (!input) return;
|
||||
const url = await withBusy(`Creating pull request…`, (r, out) => call(commands.ghPrCreate(r, input, out)));
|
||||
if (url === null) return;
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
title: "Pull request created",
|
||||
body: url || undefined,
|
||||
key: "github-create",
|
||||
actions: url ? [{ label: "Open in Browser", run: () => void openUrl(url) }] : undefined,
|
||||
});
|
||||
toolWindowService().show(WINDOW);
|
||||
await loadList();
|
||||
const m = /\/pull\/(\d+)/.exec(url);
|
||||
if (m) await loadDetails(Number(m[1]));
|
||||
}
|
||||
|
||||
/** Opens the selected pull request, else the repository, on GitHub. */
|
||||
export async function openInBrowser() {
|
||||
if (useGithub.getState().availability?.kind !== "ready") await refreshAll();
|
||||
if (notReady()) return;
|
||||
const { details } = useGithub.getState();
|
||||
const url = details?.summary.url ?? readyRepo()?.url;
|
||||
if (url) await openUrl(url);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* The review group in the editor's top-right row, styled like the inspection widget. */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
border-radius: var(--radius-small);
|
||||
background-color: var(--bg-editor, var(--bg-island, var(--bg-app)));
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
padding: 0 3px;
|
||||
border: none;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* "Review Mode" with its text: the toolbar's small font. */
|
||||
.text {
|
||||
padding: 0 5px;
|
||||
font-size: var(--fs-small, var(--fs-ui));
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: var(--bg-toolbar-hover);
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 1px solid var(--focus-ring);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The review group at the editor's top-right (`ReviewInEditorUtil.showReviewToolbarWithActions`
|
||||
* with `CodeReviewInEditorToolbarActionGroup`): Previous / Next Comment, then "Review Mode"
|
||||
* (a warning icon when the branch is out of sync; only the eye while review mode is off)
|
||||
* opening its menu: "Update Branch to Enable Review Mode…" (out of sync), "Disable Review
|
||||
* Mode", "Show Unresolved Comments Only", "Show All Comments".
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Icon, Menu, tooltipAttrs, type MenuItem } from "@/ui";
|
||||
import type { ReviewEditor } from "./editor";
|
||||
import { setEditorThreadsOption, setReviewModeEnabled, useReviewModeSettings } from "./settings";
|
||||
import { updateReviewBranch, useReviewBranch } from "./state";
|
||||
import styles from "./ReviewMode.module.css";
|
||||
|
||||
export function ReviewModeToolbar({ editor }: { editor: ReviewEditor }) {
|
||||
const { enabled, threads } = useReviewModeSettings();
|
||||
const updateRequired = useReviewBranch((s) => s.updateRequired);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const shown = enabled && !updateRequired;
|
||||
const items: MenuItem[] = [
|
||||
...(updateRequired
|
||||
? [
|
||||
{ label: "Update Branch to Enable Review Mode…", icon: "branch", run: () => void updateReviewBranch() },
|
||||
{ kind: "separator" as const },
|
||||
]
|
||||
: []),
|
||||
{ label: "Disable Review Mode", checked: !enabled, run: () => setReviewModeEnabled(false) },
|
||||
{
|
||||
label: "Show Unresolved Comments Only",
|
||||
checked: enabled && threads === "unresolved",
|
||||
run: () => {
|
||||
setReviewModeEnabled(true);
|
||||
setEditorThreadsOption("unresolved");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Show All Comments",
|
||||
checked: enabled && threads === "all",
|
||||
run: () => {
|
||||
setReviewModeEnabled(true);
|
||||
setEditorThreadsOption("all");
|
||||
},
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Review Mode")}>
|
||||
{shown && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
aria-label={tx("Previous Comment")}
|
||||
{...tooltipAttrs(tx("Previous Comment"))}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => void editor.gotoComment(-1)}
|
||||
>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
aria-label={tx("Next Comment")}
|
||||
{...tooltipAttrs(tx("Next Comment"))}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => void editor.gotoComment(1)}
|
||||
>
|
||||
<Icon name="arrow-down" size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={clsx(styles.button, enabled && styles.text)}
|
||||
aria-label={tx("Configure review mode")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
data-review-mode={enabled ? (updateRequired ? "outOfSync" : "on") : "off"}
|
||||
{...tooltipAttrs(tx("In Review Mode, you can see review comments and proposed changes right in the editor"))}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{enabled ? (
|
||||
<>
|
||||
{updateRequired && <Icon name="warning" size={14} className={styles.warning} />}
|
||||
{tx("Review Mode")}
|
||||
</>
|
||||
) : (
|
||||
<Icon name="eye" size={14} />
|
||||
)}
|
||||
</button>
|
||||
<Menu open={open} onClose={() => setOpen(false)} anchor={ref} items={items} placement="bottom-end" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Review in the regular editor (IntelliJ's `GHPRReviewInEditorController` with
|
||||
* `GHPRReviewFileEditorModel`): an editor contribution for the files of the current
|
||||
* branch's pull request. Such an editor gets the "Review Mode" group at its top-right
|
||||
* (`showReviewToolbarWithActions`: Previous / Next Comment and the mode's menu); while review
|
||||
* mode is on and the branch is in sync it also gets
|
||||
*
|
||||
* - the review threads (right side, current) as inlays under their lines,
|
||||
* - the gutter "+" on commentable lines (the patch's hunks) with the new-comment inlays the
|
||||
* diff uses (Start Review / Add Single Comment / Add Review Comment),
|
||||
* - change markers of the review's changes (vs the pull request's base) in the change lane,
|
||||
* under the local ones.
|
||||
*
|
||||
* Lines are carried from the review head to the document through the diff of the head's
|
||||
* text and the document (`trackDocumentDiffSync`), recomputed as the document changes.
|
||||
*/
|
||||
import { Compartment, RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { EditorView, GutterMarker, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { LaneMarker, editorCorner, gutterLaneMarkers, useEditorRegistry, type EditorDocContext } from "@/editor/api";
|
||||
import { lineInlays, setLineInlays, type LineInlay } from "@/features/vcs/lineInlays";
|
||||
import {
|
||||
EMPTY_REVIEW_GUTTER,
|
||||
canCreateComment,
|
||||
isLineCommentable,
|
||||
reviewGutter,
|
||||
setReviewGutterState,
|
||||
type LineRange,
|
||||
type ReviewGutterState,
|
||||
type ReviewOutline,
|
||||
} from "@/features/vcs/reviewGutter";
|
||||
import { currentRepoRoot } from "@/features/vcs/store";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { maybeEditorService } from "@/workbench/contributions";
|
||||
import type { GHCommitFile, GHPullRequestReviewThread } from "../api/types";
|
||||
import { openPullRequestFileDiff } from "../data/local";
|
||||
import {
|
||||
loadPullRequestData,
|
||||
pullRequestData,
|
||||
reloadPullRequestData,
|
||||
subscribePullRequestData,
|
||||
} from "../data/pullRequest";
|
||||
import { currentUserHasPermission, useGhRepository } from "../data/repository";
|
||||
import { discardNewComment, NewCommentInlay, ThreadInlay } from "../diff/Inlays";
|
||||
import { adjacentThread, newCommentLocation, parsePatch, threadOrder, type PatchHunk } from "../diff/model";
|
||||
import { newCommentsOf, requestNewComment, updateNewComment, useNewComments } from "../diff/newComments";
|
||||
import { containsSuggestedChange } from "../timeline/model";
|
||||
import {
|
||||
documentChanges,
|
||||
editorThreadRange,
|
||||
hunksToRanges,
|
||||
lineFromAfter,
|
||||
lineToAfter,
|
||||
patchChanges,
|
||||
patchRanges,
|
||||
shiftRanges,
|
||||
type ChangeKind,
|
||||
type DocumentChange,
|
||||
type Range,
|
||||
} from "./model";
|
||||
import { useReviewModeSettings } from "./settings";
|
||||
import { useReviewBranch } from "./state";
|
||||
import { ReviewModeToolbar } from "./Toolbar";
|
||||
|
||||
/** Parsed patches, by patch text. */
|
||||
const hunkCache = new Map<string, PatchHunk[]>();
|
||||
|
||||
function hunksOf(patch: string | null | undefined): PatchHunk[] {
|
||||
if (!patch) return [];
|
||||
let h = hunkCache.get(patch);
|
||||
if (!h) {
|
||||
h = parsePatch(patch);
|
||||
if (hunkCache.size > 200) hunkCache.clear();
|
||||
hunkCache.set(patch, h);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Path relative to the repository root; null outside it. */
|
||||
function repositoryPath(path: string): string | null {
|
||||
const root = currentRepoRoot();
|
||||
return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review change markers
|
||||
|
||||
const setReviewChanges = StateEffect.define<readonly DocumentChange[]>();
|
||||
|
||||
const LANE: Record<ChangeKind, LaneMarker> = {
|
||||
added: new LaneMarker("var(--gutter-added)"),
|
||||
modified: new LaneMarker("var(--gutter-modified)"),
|
||||
deleted: new LaneMarker("var(--gutter-deleted)", "deleted"),
|
||||
};
|
||||
|
||||
const reviewChanges = StateField.define<RangeSet<GutterMarker>>({
|
||||
create: () => RangeSet.empty,
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) {
|
||||
if (!e.is(setReviewChanges)) continue;
|
||||
const doc = tr.state.doc;
|
||||
const ranges = [];
|
||||
for (const c of e.value) {
|
||||
if (c.kind === "deleted" || c.end <= c.start) {
|
||||
ranges.push(LANE.deleted.range(doc.line(Math.min(Math.max(c.start, 0), doc.lines - 1) + 1).from));
|
||||
continue;
|
||||
}
|
||||
for (let l = c.start; l < c.end && l < doc.lines; l++) ranges.push(LANE[c.kind].range(doc.line(l + 1).from));
|
||||
}
|
||||
return RangeSet.of(ranges, true);
|
||||
}
|
||||
return tr.docChanged ? value.map(tr.changes) : value;
|
||||
},
|
||||
});
|
||||
|
||||
/** The review's extensions, installed while it is shown. */
|
||||
function reviewExtensions(editor: ReviewEditor): Extension {
|
||||
return [
|
||||
lineInlays(),
|
||||
reviewGutter({
|
||||
requestNewComment: (range) => editor.request(range),
|
||||
cancelNewComment: (line) => editor.cancelAt(line),
|
||||
toggleComments: (line) => editor.toggleAt(line),
|
||||
adjustRange: (key, range) => editor.adjust(key, range),
|
||||
}),
|
||||
reviewChanges,
|
||||
gutterLaneMarkers.from(reviewChanges),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const compartment = new Compartment();
|
||||
const editors = new Map<EditorView, ReviewEditor>();
|
||||
|
||||
/** The review in the active text editor, when it is shown. */
|
||||
export function activeReviewEditor(): ReviewEditor | null {
|
||||
const view = useEditorRegistry.getState().active?.view;
|
||||
const editor = view ? editors.get(view) : undefined;
|
||||
return editor?.shown ? editor : null;
|
||||
}
|
||||
|
||||
/** The review of the active text editor's file, shown or not (Show Review Diff). */
|
||||
export function activeReviewFile(): ReviewEditor | null {
|
||||
const view = useEditorRegistry.getState().active?.view;
|
||||
const editor = view ? editors.get(view) : undefined;
|
||||
return editor?.number != null ? editor : null;
|
||||
}
|
||||
|
||||
/** Keeps `githubReviewEditor` (the active editor shows a review) up to date. */
|
||||
export function trackActiveReviewEditor(): () => void {
|
||||
const sync = () => setContext("githubReviewEditor", !!activeReviewEditor());
|
||||
sync();
|
||||
const unsub = useEditorRegistry.subscribe((s, prev) => {
|
||||
if (s.active !== prev.active) sync();
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
setContext("githubReviewEditor", false);
|
||||
};
|
||||
}
|
||||
|
||||
/** The thread Previous / Next Comment moved to last (navigation goes on from it while the caret stays). */
|
||||
let navigated: { number: number; id: string; path: string; line: number } | null = null;
|
||||
|
||||
/** `GHPRReviewFileEditorModel` for one editor. */
|
||||
export class ReviewEditor {
|
||||
/** The pull request file this editor shows, when it is one. */
|
||||
number: number | null = null;
|
||||
file: GHCommitFile | null = null;
|
||||
/** The review is shown (review mode on, the branch in sync, the head's text known). */
|
||||
shown = false;
|
||||
/** The head revision's text, for `headOid`. */
|
||||
private head: { oid: string; text: string | null } | null = null;
|
||||
/** Document changes since the review head (`postReviewRanges`); null until computed. */
|
||||
private local: Range[] | null = null;
|
||||
private hidden = new Set<string>();
|
||||
private gutter: ReviewGutterState = EMPTY_REVIEW_GUTTER;
|
||||
private installed = false;
|
||||
private toolbar: { el: HTMLElement; root: Root } | null = null;
|
||||
private stopData: (() => void) | null = null;
|
||||
private dataFor: number | null = null;
|
||||
private disposers: Array<() => void> = [];
|
||||
private diffTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private diffSeq = 0;
|
||||
private scheduled = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(
|
||||
readonly view: EditorView,
|
||||
readonly path: string,
|
||||
) {
|
||||
editors.set(view, this);
|
||||
const schedule = () => this.schedule();
|
||||
this.disposers.push(
|
||||
useReviewBranch.subscribe(schedule),
|
||||
useReviewModeSettings.subscribe(schedule),
|
||||
useNewComments.subscribe(schedule),
|
||||
useGhRepository.subscribe(schedule),
|
||||
);
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
get relativePath(): string | null {
|
||||
return repositoryPath(this.path);
|
||||
}
|
||||
|
||||
/** Recomputes on the next microtask (state changes come in bursts). */
|
||||
schedule() {
|
||||
if (this.scheduled || this.destroyed) return;
|
||||
this.scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.scheduled = false;
|
||||
if (!this.destroyed) this.sync();
|
||||
});
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged && this.shown) {
|
||||
clearTimeout(this.diffTimer);
|
||||
this.diffTimer = setTimeout(() => void this.computeLocal(), 150);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
clearTimeout(this.diffTimer);
|
||||
editors.delete(this.view);
|
||||
this.disposers.forEach((d) => d());
|
||||
this.stopData?.();
|
||||
this.unmountToolbar();
|
||||
}
|
||||
|
||||
private followData(n: number | null) {
|
||||
if (this.dataFor === n) return;
|
||||
this.stopData?.();
|
||||
this.stopData = null;
|
||||
this.dataFor = n;
|
||||
if (n === null) return;
|
||||
this.stopData = subscribePullRequestData(n, () => this.schedule());
|
||||
void loadPullRequestData(n, "changes").catch(() => undefined);
|
||||
}
|
||||
|
||||
private sync() {
|
||||
const branch = useReviewBranch.getState();
|
||||
const n = useGhRepository.getState().connection ? branch.number : null;
|
||||
this.followData(n);
|
||||
const rel = this.relativePath;
|
||||
const files = n === null ? undefined : pullRequestData(n, "changes").value;
|
||||
const file = (rel && files?.find((f) => f.filename === rel && f.status !== "removed")) || null;
|
||||
this.number = file ? n : null;
|
||||
this.file = file;
|
||||
if (this.number === null) {
|
||||
this.unmountToolbar();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.mountToolbar();
|
||||
const settings = useReviewModeSettings.getState();
|
||||
if (!settings.enabled || branch.updateRequired) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
for (const key of ["details", "threads", "pendingReview"] as const)
|
||||
void loadPullRequestData(this.number, key).catch(() => undefined);
|
||||
const oid = pullRequestData(this.number, "details").value?.headRefOid;
|
||||
if (!oid) return;
|
||||
if (this.head?.oid !== oid) {
|
||||
void this.loadHead(oid);
|
||||
return;
|
||||
}
|
||||
if (this.head.text === null) return;
|
||||
if (!this.local) {
|
||||
void this.computeLocal();
|
||||
return;
|
||||
}
|
||||
this.show();
|
||||
}
|
||||
|
||||
private async loadHead(oid: string) {
|
||||
this.head = { oid, text: null };
|
||||
this.local = null;
|
||||
try {
|
||||
const text = await call(commands.vcsFileAt(this.path, oid));
|
||||
if (this.head?.oid !== oid) return;
|
||||
this.head = { oid, text: (text ?? "").replace(/\r\n?/g, "\n") };
|
||||
} catch {
|
||||
if (this.head?.oid === oid) this.head = null;
|
||||
return;
|
||||
}
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
/** The document's changes against the review head (`trackDocumentDiffSync`). */
|
||||
private async computeLocal() {
|
||||
const head = this.head?.text;
|
||||
if (head == null) return;
|
||||
const seq = ++this.diffSeq;
|
||||
const doc = this.view.state.doc.toString();
|
||||
let ranges: Range[];
|
||||
try {
|
||||
ranges = head === doc ? [] : hunksToRanges(await commands.vcsDiffHunks(head, doc));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (seq !== this.diffSeq || this.destroyed) return;
|
||||
this.local = ranges;
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private mountToolbar() {
|
||||
if (this.toolbar) {
|
||||
this.toolbar.root.render(<ReviewModeToolbar editor={this} />);
|
||||
return;
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
el.className = "cm-openidea-reviewMode";
|
||||
// Before the inspection widget (`Constraints.FIRST`).
|
||||
el.style.order = "-1";
|
||||
editorCorner(this.view).appendChild(el);
|
||||
const root = createRoot(el);
|
||||
root.render(<ReviewModeToolbar editor={this} />);
|
||||
this.toolbar = { el, root };
|
||||
}
|
||||
|
||||
private unmountToolbar() {
|
||||
const t = this.toolbar;
|
||||
if (!t) return;
|
||||
this.toolbar = null;
|
||||
t.el.remove();
|
||||
queueMicrotask(() => t.root.unmount());
|
||||
}
|
||||
|
||||
private hide() {
|
||||
if (!this.installed) return;
|
||||
this.installed = false;
|
||||
this.shown = false;
|
||||
this.gutter = EMPTY_REVIEW_GUTTER;
|
||||
this.view.dispatch({ effects: compartment.reconfigure([]) });
|
||||
if (useEditorRegistry.getState().active?.view === this.view) setContext("githubReviewEditor", false);
|
||||
}
|
||||
|
||||
/** The review head's lines in the document (`postReviewRanges`). */
|
||||
private get ranges(): Range[] {
|
||||
return this.local ?? [];
|
||||
}
|
||||
|
||||
private threads(): GHPullRequestReviewThread[] {
|
||||
if (this.number === null) return [];
|
||||
const option = useReviewModeSettings.getState().threads;
|
||||
return (pullRequestData(this.number, "threads").value ?? []).filter(
|
||||
(t) => t.path === this.file?.filename && (option === "all" || !t.isResolved),
|
||||
);
|
||||
}
|
||||
|
||||
/** The visible threads with their document line. */
|
||||
private placedThreads(): Array<{ thread: GHPullRequestReviewThread; line: number }> {
|
||||
const out = [];
|
||||
for (const thread of this.threads()) {
|
||||
const r = editorThreadRange(thread);
|
||||
if (r) out.push({ thread, line: lineToAfter(this.ranges, r.end) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private show() {
|
||||
const n = this.number;
|
||||
const file = this.file;
|
||||
if (n === null || !file) return;
|
||||
if (!this.installed) {
|
||||
this.installed = true;
|
||||
this.view.dispatch({ effects: compartment.reconfigure(reviewExtensions(this)) });
|
||||
}
|
||||
this.shown = true;
|
||||
if (useEditorRegistry.getState().active?.view === this.view) setContext("githubReviewEditor", true);
|
||||
const ranges = this.ranges;
|
||||
const hunks = hunksOf(file.patch);
|
||||
const inlays: LineInlay[] = [];
|
||||
const withComments = new Set<number>();
|
||||
const withInlays = new Set<number>();
|
||||
const withNew = new Set<number>();
|
||||
const outlines: ReviewOutline[] = [];
|
||||
for (const { thread, line } of this.placedThreads()) {
|
||||
withComments.add(line);
|
||||
if (this.hidden.has(thread.id)) continue;
|
||||
withInlays.add(line);
|
||||
inlays.push({ key: thread.id, line, content: <ThreadInlay number={n} threadId={thread.id} /> });
|
||||
}
|
||||
for (const c of newCommentsOf(n, file.filename, null)) {
|
||||
const loc = c.location;
|
||||
if (loc.side !== "right" || loc.startSide !== "right") continue;
|
||||
const line = lineToAfter(ranges, loc.line);
|
||||
const start = lineToAfter(ranges, loc.startLine);
|
||||
withNew.add(line);
|
||||
withInlays.add(line);
|
||||
inlays.push({ key: c.id, line, content: <NewCommentInlay id={c.id} /> });
|
||||
const suggestion = containsSuggestedChange(c.text);
|
||||
outlines.push({
|
||||
key: c.id,
|
||||
range: { start: Math.min(start, line), end: Math.max(start, line) },
|
||||
adjustable: !suggestion,
|
||||
tooltip: suggestion
|
||||
? tx("The range of this comment is not adjustable as it contains suggestion")
|
||||
: tx("Drag to select several lines for commenting"),
|
||||
});
|
||||
}
|
||||
const commentable = currentUserHasPermission("READ")
|
||||
? shiftRanges(patchRanges(hunks), ranges).map((r) => ({ start: r.start2, end: r.end2 }))
|
||||
: [];
|
||||
this.gutter = {
|
||||
linesWithComments: withComments,
|
||||
linesWithNewComments: withNew,
|
||||
linesWithInlays: withInlays,
|
||||
commentable,
|
||||
outlines,
|
||||
};
|
||||
this.view.dispatch({
|
||||
effects: [
|
||||
setLineInlays.of(inlays),
|
||||
setReviewGutterState.of(this.gutter),
|
||||
setReviewChanges.of(documentChanges(shiftRanges(patchChanges(hunks), ranges))),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gutter requests (document lines)
|
||||
|
||||
/** Document lines as review head lines; null when one was added locally. */
|
||||
private toHead(range: LineRange): LineRange | null {
|
||||
const start = lineFromAfter(this.ranges, range.start);
|
||||
const end = lineFromAfter(this.ranges, range.end);
|
||||
return start === null || end === null || start < 0 || end < 0 ? null : { start, end };
|
||||
}
|
||||
|
||||
request(range: LineRange) {
|
||||
const head = this.toHead(range);
|
||||
if (this.number === null || !this.file || !head) return;
|
||||
requestNewComment(this.number, this.file.filename, null, newCommentLocation("right", head.start, head.end));
|
||||
}
|
||||
|
||||
cancelAt(line: number) {
|
||||
const head = lineFromAfter(this.ranges, line);
|
||||
if (this.number === null || !this.file || head === null) return;
|
||||
const c = newCommentsOf(this.number, this.file.filename, null).find(
|
||||
(x) => x.location.side === "right" && x.location.line === head,
|
||||
);
|
||||
if (c) void discardNewComment(c);
|
||||
}
|
||||
|
||||
adjust(key: string, range: LineRange) {
|
||||
const head = this.toHead(range);
|
||||
if (head) updateNewComment(key, { location: newCommentLocation("right", head.start, head.end) });
|
||||
}
|
||||
|
||||
/** Shows the threads of the line, or hides them when any is shown (`syncOrToggleAll`). */
|
||||
toggleAt(line: number) {
|
||||
const ids = this.placedThreads()
|
||||
.filter((p) => p.line === line)
|
||||
.map((p) => p.thread.id);
|
||||
const anyShown = ids.some((id) => !this.hidden.has(id));
|
||||
for (const id of ids) {
|
||||
if (anyShown) this.hidden.add(id);
|
||||
else this.hidden.delete(id);
|
||||
}
|
||||
this.show();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
|
||||
private caretLine(): number {
|
||||
const s = this.view.state;
|
||||
return s.doc.lineAt(s.selection.main.head).number - 1;
|
||||
}
|
||||
|
||||
/** "Add Review Comment" on the caret's line, "Add Multiline Review Comment" on the selected lines. */
|
||||
addComment(multiline: boolean) {
|
||||
const s = this.view.state;
|
||||
const sel = s.selection.main;
|
||||
const range = multiline
|
||||
? { start: s.doc.lineAt(sel.from).number - 1, end: s.doc.lineAt(sel.to).number - 1 }
|
||||
: { start: this.caretLine(), end: this.caretLine() };
|
||||
if (
|
||||
multiline
|
||||
? range.start < range.end && canCreateComment(this.gutter, range)
|
||||
: isLineCommentable(this.gutter, range.start)
|
||||
)
|
||||
this.request(range);
|
||||
}
|
||||
|
||||
/** "Refresh Review Data": the pending review and the threads. */
|
||||
refresh() {
|
||||
if (this.number === null) return;
|
||||
void reloadPullRequestData(this.number, "pendingReview").catch(() => undefined);
|
||||
void reloadPullRequestData(this.number, "threads").catch(() => undefined);
|
||||
}
|
||||
|
||||
/** "Show Review Diff": the pull request's diff of this file at the caret's line. */
|
||||
showDiff() {
|
||||
if (this.number === null || !this.file) return;
|
||||
const line = this.shown ? lineFromAfter(this.ranges, this.caretLine(), true) : null;
|
||||
void openPullRequestFileDiff(this.number, this.file.filename, line === null ? {} : { line, side: "right" });
|
||||
}
|
||||
|
||||
/** Previous / Next Comment: the visible thread before / after the current one or the caret, in any file. */
|
||||
async gotoComment(dir: 1 | -1): Promise<boolean> {
|
||||
const n = this.number;
|
||||
const file = this.file;
|
||||
if (n === null || !file || !this.shown) return false;
|
||||
const [files, threads] = await Promise.all([loadPullRequestData(n, "changes"), loadPullRequestData(n, "threads")]);
|
||||
const option = useReviewModeSettings.getState().threads;
|
||||
const order = threadOrder(
|
||||
files.map((f) => ({ path: f.filename, hunks: hunksOf(f.patch) })),
|
||||
threads,
|
||||
);
|
||||
const visible = (id: string) => {
|
||||
const t = threads.find((x) => x.id === id);
|
||||
return !!t && (option === "all" || !t.isResolved) && !!editorThreadRange(t);
|
||||
};
|
||||
const caret = this.caretLine();
|
||||
const onLast = navigated?.number === n && navigated.path === file.filename && navigated.line === caret;
|
||||
// `lineToUnified`: the left line doesn't matter, but at the start so the line's first thread is next.
|
||||
const right = lineFromAfter(this.ranges, caret, true) ?? 0;
|
||||
const from = onLast
|
||||
? { id: navigated!.id }
|
||||
: { file: files.findIndex((f) => f.filename === file.filename), left: -1, right };
|
||||
const target = adjacentThread(order, from, dir, visible);
|
||||
if (!target) return false;
|
||||
if (target.path === file.filename) {
|
||||
const line = lineToAfter(this.ranges, target.line);
|
||||
navigated = { number: n, id: target.id, path: target.path, line };
|
||||
this.hidden.delete(target.id);
|
||||
this.show();
|
||||
const pos = this.view.state.doc.line(Math.min(line + 1, this.view.state.doc.lines)).from;
|
||||
this.view.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }) });
|
||||
this.view.focus();
|
||||
} else {
|
||||
// Another file's local changes are not known here: its head line is the best guess.
|
||||
navigated = { number: n, id: target.id, path: target.path, line: target.line };
|
||||
const root = currentRepoRoot();
|
||||
if (root)
|
||||
await maybeEditorService()?.openFile(`${root}/${target.path}`, {
|
||||
selection: { line: target.line, col: 0 },
|
||||
focus: true,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The editor contribution: every file editor gets the review when its file is in the pull request. */
|
||||
export function reviewModeEditorExtension(ctx: EditorDocContext): Extension {
|
||||
if (ctx.virtual || ctx.doc < 0) return [];
|
||||
return [compartment.of([]), ViewPlugin.define((view) => new ReviewEditor(view, ctx.path))];
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Review mode (IntelliJ's `GHPROnCurrentBranchService` with `GHPRReviewInEditorController`):
|
||||
* when the checked-out branch tracks the head of an open pull request of the connected
|
||||
* repository,
|
||||
*
|
||||
* - the branch widget shows "#{n} on {branch}" (a warning when the local branch is out of
|
||||
* sync) and its popup gets "Show Pull Request in the Tool Window", "Submit Review…",
|
||||
* "Update to Enable Review Mode…" and the "Review Mode" toggle (`vcs/branchWidgetExtensions`);
|
||||
* - editors of the pull request's files show its review (`editor.tsx`) with the "Review Mode"
|
||||
* group at their top-right;
|
||||
* - "Show Review Diff" opens the pull request's diff of the file.
|
||||
*
|
||||
* Provides the `showCurrentBranchPullRequest`, `updateReviewBranch`, `toggleReviewMode` and
|
||||
* `isReviewModeOn` handlers behind the `github.pullRequest.showInToolWindow`,
|
||||
* `.updateBranch` and `.reviewMode` actions.
|
||||
*/
|
||||
import { registerEditorContribution } from "@/editor/api";
|
||||
import { provideBranchWidgetExtension } from "@/features/vcs/branchWidgetExtensions";
|
||||
import type { PopupAction } from "@/features/vcs/branches";
|
||||
import { useVcs } from "@/features/vcs/store";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerMenuGroup } from "@/workbench/contributions";
|
||||
import { subscribePullRequestData } from "../data/pullRequest";
|
||||
import { refreshConnection, useGhRepository } from "../data/repository";
|
||||
import { provideGithubHandler } from "../toolwindow/handlers";
|
||||
import { openPullRequest } from "../toolwindow/open";
|
||||
import { activeReviewFile, reviewModeEditorExtension, trackActiveReviewEditor } from "./editor";
|
||||
import { reloadReviewModeSettings, setReviewModeEnabled, useReviewModeSettings } from "./settings";
|
||||
import { changesState, trackCurrentBranchPullRequest, updateReviewBranch, useReviewBranch } from "./state";
|
||||
|
||||
/** The branch widget's presentation and popup actions (`BranchPresenter`, `Github.Pull.Request.Branch.Popup.Actions`). */
|
||||
function branchExtension(): () => void {
|
||||
let dispose: (() => void) | null = null;
|
||||
let stopData: (() => void) | null = null;
|
||||
let dataFor: number | null = null;
|
||||
const sync = () => {
|
||||
const { number: n, branch, updateRequired } = useReviewBranch.getState();
|
||||
if (dataFor !== n) {
|
||||
stopData?.();
|
||||
stopData = n === null ? null : subscribePullRequestData(n, sync);
|
||||
dataFor = n;
|
||||
}
|
||||
if (n === null || !branch) {
|
||||
dispose?.();
|
||||
dispose = null;
|
||||
return;
|
||||
}
|
||||
const text = tx`#${n} on ${branch}`;
|
||||
const state = changesState(n);
|
||||
const tooltip = updateRequired
|
||||
? tx`Pull request #${n} exists for current branch ${branch}, but local branch is out of sync with remote`
|
||||
: state === "loading"
|
||||
? tx`Loading changes for pull request #${n}`
|
||||
: state === "error"
|
||||
? tx`Pull request #${n} exists for current branch ${branch}, but pull request details loading failed`
|
||||
: tx`Pull request #${n} exists for current branch ${branch}`;
|
||||
const actions: PopupAction[] = [
|
||||
{
|
||||
id: "github.pullRequest.showInToolWindow",
|
||||
label: "Show Pull Request in the Tool Window",
|
||||
icon: "toolwindow-pull-requests",
|
||||
},
|
||||
{ id: "github.pullRequest.submitReview", label: "Submit Review…", icon: "github-review", arg: n },
|
||||
...(updateRequired
|
||||
? [{ id: "github.pullRequest.updateBranch", label: "Update to Enable Review Mode…", icon: "github-warning" }]
|
||||
: [
|
||||
{
|
||||
id: "github.pullRequest.reviewMode",
|
||||
label: "Review Mode",
|
||||
checked: useReviewModeSettings.getState().enabled,
|
||||
},
|
||||
]),
|
||||
];
|
||||
const next = provideBranchWidgetExtension({
|
||||
id: "github.pullRequest",
|
||||
presentation: { icon: updateRequired ? "github-warning" : "toolwindow-pull-requests", text, tooltip },
|
||||
actions,
|
||||
});
|
||||
dispose?.();
|
||||
dispose = next;
|
||||
};
|
||||
const unsubs = [useReviewBranch.subscribe(sync), useReviewModeSettings.subscribe(sync)];
|
||||
sync();
|
||||
return () => {
|
||||
unsubs.forEach((u) => u());
|
||||
stopData?.();
|
||||
dispose?.();
|
||||
};
|
||||
}
|
||||
|
||||
/** IntelliJ connects on start (`loginIfPossible`): the current branch's pull request is looked up without the tool window. */
|
||||
function connectEagerly(): () => void {
|
||||
const tryConnect = () => {
|
||||
const g = useGhRepository.getState();
|
||||
if (useVcs.getState().available && useProject.getState().project && !g.availability && !g.checking)
|
||||
void refreshConnection();
|
||||
};
|
||||
const unsubs = [
|
||||
useVcs.subscribe((s, prev) => {
|
||||
if (s.available !== prev.available) tryConnect();
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) {
|
||||
reloadReviewModeSettings();
|
||||
// The connection is reset by the feature's own subscription first.
|
||||
queueMicrotask(tryConnect);
|
||||
}
|
||||
}),
|
||||
];
|
||||
tryConnect();
|
||||
return () => unsubs.forEach((u) => u());
|
||||
}
|
||||
|
||||
export function activateReviewMode(): () => void {
|
||||
const disposers = [
|
||||
trackCurrentBranchPullRequest(),
|
||||
branchExtension(),
|
||||
connectEagerly(),
|
||||
trackActiveReviewEditor(),
|
||||
registerEditorContribution({ id: "github.reviewMode", order: 58, extensions: reviewModeEditorExtension }),
|
||||
useReviewBranch.subscribe((s, prev) => {
|
||||
if (s.number === prev.number) return;
|
||||
setContext("githubCurrentBranchPullRequest", s.number !== null);
|
||||
}),
|
||||
provideGithubHandler("showCurrentBranchPullRequest", () => {
|
||||
const n = useReviewBranch.getState().number;
|
||||
if (n !== null) void openPullRequest(n);
|
||||
}),
|
||||
provideGithubHandler("updateReviewBranch", () => void updateReviewBranch()),
|
||||
provideGithubHandler("toggleReviewMode", () => setReviewModeEnabled(!useReviewModeSettings.getState().enabled)),
|
||||
provideGithubHandler("isReviewModeOn", () => useReviewModeSettings.getState().enabled),
|
||||
registerActions([
|
||||
{
|
||||
// Code.Review.Editor.Show.Diff
|
||||
id: "github.pullRequest.reviewMode.showDiff",
|
||||
title: "Show Review Diff",
|
||||
category: "GitHub",
|
||||
icon: "diff",
|
||||
when: "projectOpen && githubConnected && githubCurrentBranchPullRequest",
|
||||
run: () => activeReviewFile()?.showDiff(),
|
||||
},
|
||||
]),
|
||||
registerMenuGroup({
|
||||
id: "github.reviewMode",
|
||||
menus: ["editor"],
|
||||
order: 60,
|
||||
items: () => (activeReviewFile() ? [{ kind: "action", action: "github.pullRequest.reviewMode.showDiff" }] : []),
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
disposers.reverse().forEach((d) => d());
|
||||
setContext("githubCurrentBranchPullRequest", false);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Pure logic of review mode (IntelliJ's `GHPRConnectedProjectViewModel.prOnCurrentBranch`,
|
||||
* `GHPRReviewBranchStateSharedViewModel`, `GHPRReviewFileEditorModel`, collaboration-tools'
|
||||
* `CodeReviewEditorDocumentUtil` and `ExcludingApproximateChangedRangesShifter`):
|
||||
*
|
||||
* - the remote branch the current branch tracks, from its git config, and the REST query of
|
||||
* the open pull request with that head;
|
||||
* - whether the local branch is in sync (the pull request's head is in its history);
|
||||
* - line ranges between revisions: the review's changes (the patch without context), the
|
||||
* local edits of the document against the review head, and lines and ranges carried from
|
||||
* the review head to the document and back;
|
||||
* - where a review thread sits in the editor (right side, current threads only).
|
||||
*
|
||||
* Lines are 0-based; ranges are half-open.
|
||||
*/
|
||||
import type { LineHunk } from "@/bindings";
|
||||
import type { GHPullRequestReviewThread, GHRepositoryCoordinates } from "../api/types";
|
||||
import type { PatchHunk } from "../diff/model";
|
||||
import { parseGitHubRemote, remoteOfRepository } from "../data/model";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The pull request of the current branch
|
||||
|
||||
/** What the current branch tracks (`branch.<name>.remote` / `.merge`). */
|
||||
export interface TrackedBranch {
|
||||
/** The repository the remote points to. */
|
||||
repository: GHRepositoryCoordinates;
|
||||
/** The branch name on the remote (`refs/heads/` stripped). */
|
||||
branch: string | null;
|
||||
/** `refs/pull/<n>/head` (gh checks out some pull requests so): the pull request itself. */
|
||||
pullRequest: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote branch the current branch tracks (`findHostedRemoteBranchTrackedByCurrent`):
|
||||
* `remote` is a remote's name or URL (gh sets URLs for forks), `merge` the remote ref.
|
||||
* Null when it tracks nothing or the remote is not on GitHub. `connected` is recognised by
|
||||
* the URL's path too, for a remote behind an SSH host alias.
|
||||
*/
|
||||
export function trackedBranch(
|
||||
remote: string | null,
|
||||
merge: string | null,
|
||||
remotes: ReadonlyArray<{ name: string; url: string }>,
|
||||
connected?: GHRepositoryCoordinates,
|
||||
): TrackedBranch | null {
|
||||
if (!remote || !merge || remote === ".") return null;
|
||||
const url = remotes.find((r) => r.name === remote)?.url ?? remote;
|
||||
const repository =
|
||||
parseGitHubRemote(url) ?? (connected && remoteOfRepository([{ name: remote, url }], connected) ? connected : null);
|
||||
if (!repository) return null;
|
||||
const pr = /^refs\/pull\/(\d+)\/head$/.exec(merge);
|
||||
if (pr) return { repository, branch: null, pullRequest: Number(pr[1]) };
|
||||
const branch = merge.startsWith("refs/heads/") ? merge.slice("refs/heads/".length) : merge;
|
||||
return branch ? { repository, branch, pullRequest: null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /repos/{base}/pulls` for the open pull request whose head is `tracked`
|
||||
* (`GHPRCreationService.findOpenPullRequest`: `head=<owner>:<branch>`, first match only).
|
||||
*/
|
||||
export function openPullRequestQuery(base: GHRepositoryCoordinates, tracked: TrackedBranch): string | null {
|
||||
if (!tracked.branch) return null;
|
||||
const head = encodeURIComponent(`${tracked.repository.owner}:${tracked.branch}`);
|
||||
return `repos/${base.owner}/${base.name}/pulls?state=open&head=${head}&per_page=1`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `updateRequired` (`isInCurrentHistory`): the pull request's head is not in the local
|
||||
* history, i.e. it is not the merge base of itself and HEAD (null when git doesn't know it).
|
||||
*/
|
||||
export function isUpdateRequired(headRefOid: string, mergeBase: string | null): boolean {
|
||||
return mergeBase?.toLowerCase() !== headRefOid.toLowerCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line ranges
|
||||
|
||||
/** A change between two texts: lines `[start1, end1)` of the first are `[start2, end2)` of the second. */
|
||||
export interface Range {
|
||||
start1: number;
|
||||
end1: number;
|
||||
start2: number;
|
||||
end2: number;
|
||||
}
|
||||
|
||||
/** The changes of a patch without its context lines (`withoutContext`): base → review head. */
|
||||
export function patchChanges(hunks: readonly PatchHunk[]): Range[] {
|
||||
const out: Range[] = [];
|
||||
for (const h of hunks) {
|
||||
let left = h.leftStart;
|
||||
let right = h.rightStart;
|
||||
let open: Range | null = null;
|
||||
const close = () => {
|
||||
if (open) out.push(open);
|
||||
open = null;
|
||||
};
|
||||
for (const row of h.rows) {
|
||||
const c = row[0];
|
||||
if (c === "\\") continue;
|
||||
if (c === "-" || c === "+") {
|
||||
open ??= { start1: left, end1: left, start2: right, end2: right };
|
||||
if (c === "-") open.end1 = ++left;
|
||||
else open.end2 = ++right;
|
||||
} else {
|
||||
close();
|
||||
left++;
|
||||
right++;
|
||||
}
|
||||
}
|
||||
close();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The commentable lines of a patch as ranges of both sides (`patch.ranges`: whole hunks). */
|
||||
export function patchRanges(hunks: readonly PatchHunk[]): Range[] {
|
||||
return hunks.map((h) => ({
|
||||
start1: h.leftStart,
|
||||
end1: h.leftStart + h.leftCount,
|
||||
start2: h.rightStart,
|
||||
end2: h.rightStart + h.rightCount,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Line hunks of a text diff (`vcsDiffHunks(old, new)`) as ranges old → new. */
|
||||
export function hunksToRanges(hunks: readonly Pick<LineHunk, "start" | "lines" | "oldStart" | "oldLines">[]): Range[] {
|
||||
return hunks
|
||||
.map((h) => ({ start1: h.oldStart, end1: h.oldStart + h.oldLines, start2: h.start, end2: h.start + h.lines }))
|
||||
.sort((a, b) => a.start1 - b.start1);
|
||||
}
|
||||
|
||||
/** `transferLineToAfter`: `line` of the first text in the second (a changed line goes to the change's last line). */
|
||||
export function lineToAfter(ranges: readonly Range[], line: number): number {
|
||||
let result = line;
|
||||
for (const r of ranges) {
|
||||
if (line >= r.start1 && line < r.end1) return Math.max(0, r.end2 - 1);
|
||||
if (r.end1 > line) return result;
|
||||
result += r.end2 - r.start2 - (r.end1 - r.start1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* `transferLineFromAfter`: `line` of the second text in the first; null when it was added or
|
||||
* changed there, unless `approximate` (then the line after the change).
|
||||
*/
|
||||
export function lineFromAfter(ranges: readonly Range[], line: number, approximate = false): number | null {
|
||||
let result = line;
|
||||
for (const r of ranges) {
|
||||
if (line < r.start2) return result;
|
||||
if (line < r.end2) return approximate ? r.end1 : null;
|
||||
result -= r.end2 - r.start2 - (r.end1 - r.start1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ExcludingApproximateChangedRangesShifter.shift`: `early` changes A → B and `later` ones
|
||||
* B → C as changes A → C, leaving out the parts of `early` that `later` changed again.
|
||||
*/
|
||||
export function shiftRanges(early: readonly Range[], later: readonly Range[]): Range[] {
|
||||
const result: Range[] = [];
|
||||
let i = 0;
|
||||
let shift = 0;
|
||||
for (const e of early) {
|
||||
let bStart = e.start2;
|
||||
let fullyMapped = false;
|
||||
while (i < later.length) {
|
||||
const l = later[i]!;
|
||||
const delta = l.end2 - l.start2 - (l.end1 - l.start1);
|
||||
if (l.end1 <= e.start2) {
|
||||
// Before.
|
||||
shift += delta;
|
||||
i++;
|
||||
} else if (l.start1 >= e.end2) {
|
||||
// After.
|
||||
break;
|
||||
} else if (l.start1 <= e.start2) {
|
||||
if (l.end1 >= e.end2) {
|
||||
// `early` inside `later`.
|
||||
fullyMapped = true;
|
||||
break;
|
||||
}
|
||||
// Overlapping its start.
|
||||
bStart = l.end1;
|
||||
shift += delta;
|
||||
i++;
|
||||
} else {
|
||||
result.push({ start1: e.start1, end1: e.end1, start2: shift + bStart, end2: l.start2 });
|
||||
if (l.end1 === e.end2) {
|
||||
shift += delta;
|
||||
i++;
|
||||
fullyMapped = true;
|
||||
break;
|
||||
} else if (l.end1 < e.end2) {
|
||||
// `later` inside `early`.
|
||||
bStart = l.end1;
|
||||
shift += delta;
|
||||
i++;
|
||||
} else {
|
||||
// Overlapping its end.
|
||||
fullyMapped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fullyMapped && bStart <= e.end2)
|
||||
result.push({ start1: e.start1, end1: e.end1, start2: shift + bStart, end2: shift + e.end2 });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type ChangeKind = "added" | "modified" | "deleted";
|
||||
|
||||
/** A review change in the document, for the gutter (`asLst`). */
|
||||
export interface DocumentChange {
|
||||
kind: ChangeKind;
|
||||
/** Document lines `[start, end)`; a deletion is empty at `start`. */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function documentChanges(ranges: readonly Range[]): DocumentChange[] {
|
||||
return ranges.map((r) => ({
|
||||
kind: r.start1 === r.end1 ? "added" : r.start2 === r.end2 ? "deleted" : "modified",
|
||||
start: r.start2,
|
||||
end: r.end2,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threads in the editor
|
||||
|
||||
/**
|
||||
* The review-head lines `[start, end]` of `thread` in the editor (`mapToInEditorRange`):
|
||||
* current line threads on the right side only (outdated ones and left-side ones are not
|
||||
* in the head's text).
|
||||
*/
|
||||
export function editorThreadRange(thread: GHPullRequestReviewThread): { start: number; end: number } | null {
|
||||
if (thread.subjectType === "FILE" || thread.isOutdated || thread.line == null) return null;
|
||||
if (thread.diffSide !== "RIGHT" || (thread.startDiffSide ?? "RIGHT") !== "RIGHT") return null;
|
||||
const end = thread.line - 1;
|
||||
const start = (thread.startLine ?? thread.line) - 1;
|
||||
return start > end ? null : { start, end };
|
||||
}
|
||||
|
||||
/** "Show Unresolved Comments Only" / "Show All Comments"; "Disable Review Mode" turns it off instead. */
|
||||
export type EditorThreadsOption = "unresolved" | "all";
|
||||
@@ -0,0 +1,596 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { LineHunk } from "@/bindings";
|
||||
|
||||
const ghApi = vi.fn();
|
||||
const config: Record<string, string | null> = {};
|
||||
let mergeBase: string | null = "head";
|
||||
let diffHunks: (oldText: string, newText: string) => LineHunk[] = () => [];
|
||||
const checkout = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", async (original) => ({
|
||||
...(await original<typeof import("@tauri-apps/api/core")>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
const ok = <T,>(data: T) => Promise.resolve({ status: "ok" as const, data });
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
ghApi: (req: unknown) => ghApi(req),
|
||||
vcsFileAt: () => ok(HEAD),
|
||||
vcsDiffHunks: (a: string, b: string) => Promise.resolve(diffHunks(a, b)),
|
||||
vcsConfigGet: (_repo: string | null, key: string) => ok(config[key] ?? null),
|
||||
vcsRemotes: () =>
|
||||
ok([
|
||||
{ name: "origin", url: "git@github.com:o/r.git" },
|
||||
{ name: "fork", url: "https://github.com/alice/r.git" },
|
||||
]),
|
||||
vcsMergeBase: () => ok(mergeBase),
|
||||
ghPrCheckout: (...args: unknown[]) => {
|
||||
checkout(...args);
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { useBranchWidgetExtensions } from "@/features/vcs/branchWidgetExtensions";
|
||||
import { useVcs } from "@/features/vcs/store";
|
||||
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
|
||||
import { setCurrentRepository } from "../api/client";
|
||||
import type {
|
||||
GHCommitFile,
|
||||
GHPullRequest,
|
||||
GHPullRequestReviewComment,
|
||||
GHPullRequestReviewThread,
|
||||
GHRepository,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import { clearNewComments, useNewComments } from "../diff/newComments";
|
||||
import { parsePatch } from "../diff/model";
|
||||
import { clearPullRequests, setPullRequestData } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { activeReviewEditor, reviewModeEditorExtension } from "./editor";
|
||||
import {
|
||||
documentChanges,
|
||||
editorThreadRange,
|
||||
hunksToRanges,
|
||||
isUpdateRequired,
|
||||
lineFromAfter,
|
||||
lineToAfter,
|
||||
openPullRequestQuery,
|
||||
patchChanges,
|
||||
patchRanges,
|
||||
shiftRanges,
|
||||
trackedBranch,
|
||||
} from "./model";
|
||||
import { activateReviewMode } from ".";
|
||||
import { setReviewModeEnabled, useReviewModeSettings } from "./settings";
|
||||
import { refreshCurrentBranchPullRequest, updateReviewBranch, useReviewBranch } from "./state";
|
||||
|
||||
const N = 12;
|
||||
const PATH = "src/a.ts";
|
||||
const BASE = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
||||
const HEAD = BASE.replace("line 3\n", "line three\n");
|
||||
const PATCH = "@@ -1,6 +1,6 @@\n line 1\n line 2\n-line 3\n+line three\n line 4\n line 5\n line 6";
|
||||
|
||||
const me: GHUser = { __typename: "User", id: "U1", login: "me", url: "", avatarUrl: "", name: null };
|
||||
|
||||
const comment = (body: string): GHPullRequestReviewComment =>
|
||||
({
|
||||
id: `C-${body}`,
|
||||
author: { __typename: "User", login: "alice", url: "", avatarUrl: "" },
|
||||
body,
|
||||
createdAt: "2026-10-01T10:00:00Z",
|
||||
url: "",
|
||||
state: "SUBMITTED",
|
||||
commit: { oid: "head", abbreviatedOid: "head" },
|
||||
originalCommit: { oid: "head", abbreviatedOid: "head" },
|
||||
diffHunk: "",
|
||||
pullRequestReview: { id: "R0" },
|
||||
viewerCanDelete: false,
|
||||
viewerCanUpdate: false,
|
||||
viewerCanReact: false,
|
||||
reactions: { nodes: [] },
|
||||
}) as unknown as GHPullRequestReviewComment;
|
||||
|
||||
const thread = (id: string, line: number, over: Partial<GHPullRequestReviewThread> = {}) =>
|
||||
({
|
||||
id,
|
||||
isResolved: false,
|
||||
isOutdated: false,
|
||||
path: PATH,
|
||||
subjectType: "LINE",
|
||||
diffSide: "RIGHT",
|
||||
line,
|
||||
originalLine: line,
|
||||
startDiffSide: null,
|
||||
startLine: null,
|
||||
originalStartLine: null,
|
||||
comments: { nodes: [comment(`body of ${id}`)] },
|
||||
viewerCanReply: true,
|
||||
viewerCanResolve: true,
|
||||
viewerCanUnresolve: false,
|
||||
...over,
|
||||
}) as GHPullRequestReviewThread;
|
||||
|
||||
const file: GHCommitFile = {
|
||||
sha: null,
|
||||
filename: PATH,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
changes: 2,
|
||||
patch: PATCH,
|
||||
previousFilename: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("model", () => {
|
||||
const remotes = [
|
||||
{ name: "origin", url: "git@github.com:o/r.git" },
|
||||
{ name: "fork", url: "https://github.com/alice/r.git" },
|
||||
];
|
||||
|
||||
it("finds the tracked remote branch from the branch's config", () => {
|
||||
expect(trackedBranch("origin", "refs/heads/feature/x", remotes)).toEqual({
|
||||
repository: { owner: "o", name: "r" },
|
||||
branch: "feature/x",
|
||||
pullRequest: null,
|
||||
});
|
||||
// gh sets a URL as the remote of a fork's branch.
|
||||
expect(trackedBranch("https://github.com/bob/r.git", "refs/heads/fix", remotes)?.repository).toEqual({
|
||||
owner: "bob",
|
||||
name: "r",
|
||||
});
|
||||
expect(trackedBranch("origin", "refs/pull/7/head", remotes)).toMatchObject({ pullRequest: 7, branch: null });
|
||||
expect(trackedBranch(null, null, remotes)).toBeNull();
|
||||
expect(trackedBranch(".", "refs/heads/main", remotes)).toBeNull();
|
||||
const alias = [{ name: "origin", url: "git@work-github:o/r.git" }];
|
||||
expect(trackedBranch("origin", "refs/heads/x", alias)).toBeNull();
|
||||
expect(trackedBranch("origin", "refs/heads/x", alias, { owner: "o", name: "r" })?.repository).toEqual({
|
||||
owner: "o",
|
||||
name: "r",
|
||||
});
|
||||
expect(trackedBranch("gitlab", "refs/heads/x", [{ name: "gitlab", url: "git@gitlab.com:o/r.git" }])).toBeNull();
|
||||
});
|
||||
|
||||
it("queries the open pull request with the head owner:branch", () => {
|
||||
const tracked = trackedBranch("fork", "refs/heads/a b", remotes)!;
|
||||
expect(openPullRequestQuery({ owner: "o", name: "r" }, tracked)).toBe(
|
||||
"repos/o/r/pulls?state=open&head=alice%3Aa%20b&per_page=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an update when the head is not in the local history", () => {
|
||||
expect(isUpdateRequired("abc", "abc")).toBe(false);
|
||||
expect(isUpdateRequired("ABC", "abc")).toBe(false);
|
||||
expect(isUpdateRequired("abc", "def")).toBe(true);
|
||||
expect(isUpdateRequired("abc", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("takes the patch's changes without context and its commentable ranges", () => {
|
||||
const hunks = parsePatch("@@ -1,5 +1,6 @@\n a\n-b\n+B\n+B2\n c\n-d\n e");
|
||||
expect(patchChanges(hunks)).toEqual([
|
||||
{ start1: 1, end1: 2, start2: 1, end2: 3 },
|
||||
{ start1: 3, end1: 4, start2: 4, end2: 4 },
|
||||
]);
|
||||
expect(documentChanges(patchChanges(hunks)).map((c) => c.kind)).toEqual(["modified", "deleted"]);
|
||||
expect(patchRanges(hunks)).toEqual([{ start1: 0, end1: 5, start2: 0, end2: 6 }]);
|
||||
});
|
||||
|
||||
it("carries lines across local changes and back", () => {
|
||||
// Two lines inserted at 2, line 6 changed into two.
|
||||
const ranges = hunksToRanges([
|
||||
{ start: 2, lines: 2, oldStart: 2, oldLines: 0 },
|
||||
{ start: 8, lines: 2, oldStart: 6, oldLines: 1 },
|
||||
]);
|
||||
expect([0, 1, 2, 5, 6, 7].map((l) => lineToAfter(ranges, l))).toEqual([0, 1, 4, 7, 9, 10]);
|
||||
expect(lineFromAfter(ranges, 4)).toBe(2);
|
||||
expect(lineFromAfter(ranges, 2)).toBeNull();
|
||||
expect(lineFromAfter(ranges, 2, true)).toBe(2);
|
||||
expect(lineFromAfter(ranges, 10)).toBe(7);
|
||||
});
|
||||
|
||||
it("shifts review ranges over local changes, leaving out what they changed again", () => {
|
||||
const review = [
|
||||
{ start1: 0, end1: 0, start2: 2, end2: 4 },
|
||||
{ start1: 5, end1: 6, start2: 8, end2: 9 },
|
||||
];
|
||||
// One line inserted at the top; the second review change edited locally.
|
||||
const local = [
|
||||
{ start1: 0, end1: 0, start2: 0, end2: 1 },
|
||||
{ start1: 8, end1: 9, start2: 9, end2: 11 },
|
||||
];
|
||||
expect(shiftRanges(review, local)).toEqual([{ start1: 0, end1: 0, start2: 3, end2: 5 }]);
|
||||
// A local edit inside a review range splits it.
|
||||
expect(
|
||||
shiftRanges([{ start1: 0, end1: 10, start2: 0, end2: 10 }], [{ start1: 4, end1: 5, start2: 4, end2: 4 }]),
|
||||
).toEqual([
|
||||
{ start1: 0, end1: 10, start2: 0, end2: 4 },
|
||||
{ start1: 0, end1: 10, start2: 4, end2: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("places current right-side threads only", () => {
|
||||
expect(editorThreadRange(thread("a", 4))).toEqual({ start: 3, end: 3 });
|
||||
expect(editorThreadRange(thread("a", 6, { startLine: 4, startDiffSide: "RIGHT" }))).toEqual({ start: 3, end: 5 });
|
||||
expect(editorThreadRange(thread("a", 4, { diffSide: "LEFT" }))).toBeNull();
|
||||
expect(editorThreadRange(thread("a", 4, { isOutdated: true }))).toBeNull();
|
||||
expect(editorThreadRange(thread("a", 4, { subjectType: "FILE" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function calls(op: RegExp) {
|
||||
return ghApi.mock.calls
|
||||
.map(([req]) => req as { endpoint: string; body?: string })
|
||||
.filter((r) => op.test(r.endpoint === "graphql" ? (JSON.parse(r.body!) as { query: string }).query : r.endpoint))
|
||||
.map((r) =>
|
||||
r.endpoint === "graphql" ? (JSON.parse(r.body!) as { variables: Record<string, unknown> }).variables : r,
|
||||
);
|
||||
}
|
||||
|
||||
let restPulls: unknown = [{ number: N }];
|
||||
|
||||
function respond(req: {
|
||||
endpoint: string;
|
||||
body?: string;
|
||||
}): { status: "ok"; data: string } | { status: "error"; error: unknown } {
|
||||
if (req.endpoint.startsWith("repos/o/r/pulls?")) return { status: "ok", data: JSON.stringify(restPulls) };
|
||||
const q = req.endpoint === "graphql" ? (JSON.parse(req.body!) as { query: string }).query : "";
|
||||
if (/addPullRequestReview\s*\(/.test(q))
|
||||
return {
|
||||
status: "ok",
|
||||
data: JSON.stringify({
|
||||
data: {
|
||||
addPullRequestReview: { pullRequestReview: { id: "PRR1", state: "PENDING", comments: { totalCount: 1 } } },
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (/addPullRequestReviewThread\s*\(/.test(q))
|
||||
return {
|
||||
status: "ok",
|
||||
data: JSON.stringify({ data: { addPullRequestReviewThread: { thread: thread("T-new", 4) } } }),
|
||||
};
|
||||
return { status: "error", error: { kind: "other", message: "not stubbed" } };
|
||||
}
|
||||
|
||||
const notify = vi.fn();
|
||||
let views: EditorView[] = [];
|
||||
let deactivate: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
ghApi.mockReset();
|
||||
ghApi.mockImplementation((req: { endpoint: string; body?: string }) => Promise.resolve(respond(req)));
|
||||
checkout.mockReset();
|
||||
notify.mockReset();
|
||||
provideNotificationService({ notify } as unknown as NotificationService);
|
||||
restPulls = [{ number: N }];
|
||||
mergeBase = "head";
|
||||
diffHunks = () => [];
|
||||
for (const k of Object.keys(config)) delete config[k];
|
||||
setCurrentRepository({ owner: "o", name: "r", id: "R1" });
|
||||
useVcs.setState({
|
||||
available: true,
|
||||
root: "/p",
|
||||
roots: ["/p"],
|
||||
repo: "/p",
|
||||
branch: { head: "feature", headCommit: "head", upstream: "origin/feature", ahead: 0, behind: 0, operation: null },
|
||||
});
|
||||
useGhRepository.setState({
|
||||
connection: {
|
||||
coordinates: { owner: "o", name: "r" },
|
||||
repository: { id: "R1", viewerPermission: "READ" } as GHRepository,
|
||||
currentUser: me,
|
||||
},
|
||||
});
|
||||
useReviewModeSettings.setState({ enabled: true, threads: "unresolved" });
|
||||
setPullRequestData(
|
||||
N,
|
||||
"details",
|
||||
() => ({ id: "PR1", number: N, headRefOid: "head", baseRefOid: "base" }) as GHPullRequest,
|
||||
);
|
||||
setPullRequestData(N, "changes", () => [file]);
|
||||
setPullRequestData(N, "pendingReview", () => null);
|
||||
setPullRequestData(N, "threads", () => [thread("T1", 3), thread("T2", 5, { isResolved: true })]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
deactivate?.();
|
||||
deactivate = null;
|
||||
for (const v of views) v.destroy();
|
||||
views = [];
|
||||
cleanup();
|
||||
// React roots of the inlays and the toolbar unmount in a microtask.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
clearNewComments();
|
||||
clearPullRequests();
|
||||
useEditorRegistry.setState({ active: null });
|
||||
useReviewBranch.setState({ number: null, branch: null, updateRequired: false });
|
||||
useGhRepository.setState({ connection: null });
|
||||
useVcs.setState({ available: false, branch: null, repo: null, roots: [], root: null });
|
||||
});
|
||||
|
||||
describe("the pull request of the current branch", () => {
|
||||
it("finds the open pull request whose head the branch tracks and checks the sync state", async () => {
|
||||
config["branch.feature.remote"] = "fork";
|
||||
config["branch.feature.merge"] = "refs/heads/feature";
|
||||
await refreshCurrentBranchPullRequest();
|
||||
expect(useReviewBranch.getState()).toEqual({ number: N, branch: "feature", updateRequired: false });
|
||||
expect(calls(/pulls\?/)).toEqual([
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
endpoint: "repos/o/r/pulls?state=open&head=alice%3Afeature&per_page=1",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires an update when the head commit is not in the local history", async () => {
|
||||
config["branch.feature.remote"] = "origin";
|
||||
config["branch.feature.merge"] = "refs/heads/feature";
|
||||
mergeBase = null;
|
||||
await refreshCurrentBranchPullRequest();
|
||||
expect(useReviewBranch.getState()).toMatchObject({ number: N, updateRequired: true });
|
||||
});
|
||||
|
||||
it("finds nothing without an upstream or an open pull request", async () => {
|
||||
await refreshCurrentBranchPullRequest();
|
||||
expect(useReviewBranch.getState().number).toBeNull();
|
||||
config["branch.feature.remote"] = "origin";
|
||||
config["branch.feature.merge"] = "refs/heads/feature";
|
||||
restPulls = [];
|
||||
await refreshCurrentBranchPullRequest();
|
||||
expect(useReviewBranch.getState().number).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the pull request in the branch widget and adds its popup actions", async () => {
|
||||
config["branch.feature.remote"] = "origin";
|
||||
config["branch.feature.merge"] = "refs/heads/feature";
|
||||
deactivate = activateReviewMode();
|
||||
await waitFor(() => expect(useReviewBranch.getState().number).toBe(N));
|
||||
const ext = () => useBranchWidgetExtensions.getState().extensions.get("github.pullRequest");
|
||||
await waitFor(() => expect(ext()?.presentation?.text).toBe("#12 on feature"));
|
||||
expect(ext()!.presentation!.tooltip).toBe("Pull request #12 exists for current branch feature");
|
||||
expect(ext()!.actions!.map((a) => [a.label, a.checked])).toEqual([
|
||||
["Show Pull Request in the Tool Window", undefined],
|
||||
["Submit Review…", undefined],
|
||||
["Review Mode", true],
|
||||
]);
|
||||
act(() => setReviewModeEnabled(false));
|
||||
expect(ext()!.actions![2]!.checked).toBe(false);
|
||||
|
||||
act(() => useReviewBranch.setState({ updateRequired: true }));
|
||||
expect(ext()!.presentation).toMatchObject({
|
||||
icon: "github-warning",
|
||||
tooltip: "Pull request #12 exists for current branch feature, but local branch is out of sync with remote",
|
||||
});
|
||||
expect(ext()!.actions!.map((a) => a.label)).toContain("Update to Enable Review Mode…");
|
||||
deactivate();
|
||||
deactivate = null;
|
||||
expect(ext()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Update to Enable Review Mode reloads the details, then checks out; a failed reload is reported", async () => {
|
||||
useReviewBranch.setState({ number: N, branch: "feature", updateRequired: true });
|
||||
// The reload fails (not stubbed).
|
||||
await updateReviewBranch();
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "Pull Request branch update failed" }),
|
||||
);
|
||||
expect(checkout).not.toHaveBeenCalled();
|
||||
|
||||
ghApi.mockImplementation((req: { endpoint: string; body?: string }) =>
|
||||
Promise.resolve(
|
||||
req.endpoint === "graphql" && /\.\.\.pullRequestInfo/.test((JSON.parse(req.body!) as { query: string }).query)
|
||||
? {
|
||||
status: "ok",
|
||||
data: JSON.stringify({
|
||||
data: { repository: { pullRequest: { id: "PR1", number: N, headRefOid: "head" } } },
|
||||
}),
|
||||
}
|
||||
: respond(req),
|
||||
),
|
||||
);
|
||||
await updateReviewBranch();
|
||||
expect(checkout).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(useReviewBranch.getState().updateRequired).toBe(false));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openEditor(doc: string) {
|
||||
const ctx = { doc: 1, path: `/p/${PATH}`, language: "typescript", uiMode: null, readOnly: false };
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc, extensions: [reviewModeEditorExtension(ctx)] }),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
useEditorRegistry.setState({ active: { view, ctx } });
|
||||
return view;
|
||||
}
|
||||
|
||||
const inlay = (id: string) => document.querySelector<HTMLElement>(`[data-thread-inlay="${id}"]`);
|
||||
const newCommentBox = () =>
|
||||
waitFor(() => {
|
||||
const el = document.querySelector<HTMLElement>("[data-new-comment]");
|
||||
if (!el) throw new Error("no new comment inlay");
|
||||
return el;
|
||||
});
|
||||
/** The text of the line an inlay is under (other inlays of the line come between). */
|
||||
const lineOf = (el: HTMLElement) => {
|
||||
let line = el.closest(".cm-lineInlay")!.previousElementSibling;
|
||||
while (line?.classList.contains("cm-lineInlay")) line = line.previousElementSibling;
|
||||
return line?.textContent;
|
||||
};
|
||||
|
||||
describe("review in the editor", () => {
|
||||
beforeEach(() => {
|
||||
useReviewBranch.setState({ number: N, branch: "feature", updateRequired: false });
|
||||
});
|
||||
|
||||
it("shows the unresolved threads under their lines, following local edits", async () => {
|
||||
// Two lines added locally at the top.
|
||||
diffHunks = (_a, b) =>
|
||||
b.startsWith("new 1\nnew 2\n")
|
||||
? [{ kind: "added", start: 0, lines: 2, oldStart: 0, oldLines: 0, oldText: "" }]
|
||||
: [];
|
||||
openEditor(`new 1\nnew 2\n${HEAD}`);
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
expect(lineOf(inlay("T1")!)).toBe("line three");
|
||||
expect(inlay("T2")).toBeNull();
|
||||
// The review's changed line is marked; the gutter offers comments on the hunk's lines.
|
||||
expect(document.querySelectorAll(".cm-reviewGutter")).toHaveLength(1);
|
||||
act(() => useReviewModeSettings.setState({ threads: "all" }));
|
||||
await waitFor(() => expect(inlay("T2")).not.toBeNull());
|
||||
expect(lineOf(inlay("T2")!)).toBe("line 5");
|
||||
});
|
||||
|
||||
it("puts the Review Mode group in the editor's corner and hides the review when disabled", async () => {
|
||||
openEditor(HEAD);
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
const button = document.querySelector<HTMLElement>(".cm-openidea-corner [data-review-mode]")!;
|
||||
expect(button.dataset.reviewMode).toBe("on");
|
||||
expect(button.dataset.tooltip).toBe(
|
||||
"In Review Mode, you can see review comments and proposed changes right in the editor",
|
||||
);
|
||||
fireEvent.click(button);
|
||||
const items = await screen.findAllByRole("menuitemcheckbox").catch(() => screen.findAllByRole("menuitem"));
|
||||
expect(items.map((i) => i.textContent)).toEqual([
|
||||
"Disable Review Mode",
|
||||
"Show Unresolved Comments Only",
|
||||
"Show All Comments",
|
||||
]);
|
||||
fireEvent.click(screen.getByText("Disable Review Mode"));
|
||||
await waitFor(() => expect(inlay("T1")).toBeNull());
|
||||
expect(useReviewModeSettings.getState().enabled).toBe(false);
|
||||
expect(document.querySelector(".cm-reviewGutter")).toBeNull();
|
||||
expect(document.querySelector<HTMLElement>("[data-review-mode]")!.dataset.reviewMode).toBe("off");
|
||||
});
|
||||
|
||||
it("offers Update Branch to Enable Review Mode while out of sync", async () => {
|
||||
useReviewBranch.setState({ updateRequired: true });
|
||||
openEditor(HEAD);
|
||||
const button = await waitFor(() => {
|
||||
const b = document.querySelector<HTMLElement>("[data-review-mode]");
|
||||
if (!b) throw new Error("no toolbar");
|
||||
return b;
|
||||
});
|
||||
expect(button.dataset.reviewMode).toBe("outOfSync");
|
||||
expect(inlay("T1")).toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByText("Update Branch to Enable Review Mode…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does nothing for files outside the pull request", async () => {
|
||||
const ctx = { doc: 2, path: "/p/other.ts", language: "", uiMode: null, readOnly: false };
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc: HEAD, extensions: [reviewModeEditorExtension(ctx)] }),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(document.querySelector("[data-review-mode]")).toBeNull();
|
||||
});
|
||||
|
||||
it("Add Single Comment posts a comment on the review head's line", async () => {
|
||||
diffHunks = (_a, b) =>
|
||||
b.startsWith("new 1\n") ? [{ kind: "added", start: 0, lines: 1, oldStart: 0, oldLines: 0, oldText: "" }] : [];
|
||||
const view = openEditor(`new 1\n${HEAD}`);
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
// The caret on document line 4 ("line three", head line 2), a commentable line.
|
||||
view.dispatch({ selection: { anchor: view.state.doc.line(4).from } });
|
||||
act(() => activeReviewEditor()!.addComment(false));
|
||||
const box = await newCommentBox();
|
||||
expect(lineOf(box)).toBe("line three");
|
||||
expect(
|
||||
within(box)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(["Add Single Comment", "Start Review"]);
|
||||
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "why?" } });
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Add Single Comment" }));
|
||||
await waitFor(() => expect(useNewComments.getState().comments).toHaveLength(0));
|
||||
expect(calls(/addPullRequestReview\s*\(/)).toEqual([
|
||||
{
|
||||
pullRequestId: "PR1",
|
||||
commitOid: "head",
|
||||
event: "COMMENT",
|
||||
threads: [{ body: "why?", path: PATH, line: 3, side: "RIGHT", startLine: null, startSide: null }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Start Review on several lines; lines added locally can't be commented", async () => {
|
||||
diffHunks = (_a, b) =>
|
||||
b.startsWith("new 1\n") ? [{ kind: "added", start: 0, lines: 1, oldStart: 0, oldLines: 0, oldText: "" }] : [];
|
||||
openEditor(`new 1\n${HEAD}`);
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
const editor = activeReviewEditor()!;
|
||||
act(() => editor.request({ start: 0, end: 0 }));
|
||||
expect(useNewComments.getState().comments).toHaveLength(0);
|
||||
act(() => editor.request({ start: 2, end: 4 }));
|
||||
const box = await newCommentBox();
|
||||
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "block" } });
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Start Review" }));
|
||||
await waitFor(() => expect(calls(/addPullRequestReview\s*\(/)).toHaveLength(1));
|
||||
expect(calls(/addPullRequestReview\s*\(/)[0]).toEqual({
|
||||
pullRequestId: "PR1",
|
||||
commitOid: "head",
|
||||
threads: [{ body: "block", path: PATH, line: 4, side: "RIGHT", startLine: 2, startSide: "RIGHT" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("Add Review Comment adds a thread to the pending review", async () => {
|
||||
setPullRequestData(N, "pendingReview", () => ({ id: "PRR0", state: "PENDING", commentsCount: 1 }));
|
||||
openEditor(HEAD);
|
||||
await waitFor(() => expect(inlay("T1")).not.toBeNull());
|
||||
act(() => activeReviewEditor()!.request({ start: 0, end: 0 }));
|
||||
const box = await newCommentBox();
|
||||
expect(
|
||||
within(box)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(["Add Review Comment"]);
|
||||
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "nit" } });
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Add Review Comment" }));
|
||||
await waitFor(() =>
|
||||
expect(calls(/addPullRequestReviewThread/)).toEqual([
|
||||
{
|
||||
pullRequestReviewId: "PRR0",
|
||||
body: "nit",
|
||||
path: PATH,
|
||||
line: 1,
|
||||
side: "RIGHT",
|
||||
startLine: null,
|
||||
startSide: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Next Comment moves the caret to the next visible thread", async () => {
|
||||
useReviewModeSettings.setState({ threads: "all" });
|
||||
const view = openEditor(HEAD);
|
||||
await waitFor(() => expect(inlay("T2")).not.toBeNull());
|
||||
const editor = activeReviewEditor()!;
|
||||
await act(() => editor.gotoComment(1));
|
||||
expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(3);
|
||||
await act(() => editor.gotoComment(1));
|
||||
expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(5);
|
||||
expect(await editor.gotoComment(1)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Review mode settings: the "Review Mode" toggle, persisted per project in local storage
|
||||
* (`GithubPullRequestsProjectUISettings.editorReviewEnabled`, on by default), and the
|
||||
* threads shown while it is on ("Show Unresolved Comments Only", the default, or "Show All
|
||||
* Comments"; kept for the session like IntelliJ's view model).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { EditorThreadsOption } from "./model";
|
||||
|
||||
interface ReviewModeSettings {
|
||||
enabled: boolean;
|
||||
threads: EditorThreadsOption;
|
||||
}
|
||||
|
||||
const key = (root: string) => `openidea.github.reviewMode.v1:${root}`;
|
||||
const projectRoot = () => useProject.getState().project?.root ?? null;
|
||||
|
||||
function loadEnabled(root: string | null): boolean {
|
||||
if (!root) return true;
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(key(root)) !== "off";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const useReviewModeSettings = create<ReviewModeSettings>(() => ({
|
||||
enabled: loadEnabled(projectRoot()),
|
||||
threads: "unresolved",
|
||||
}));
|
||||
|
||||
/** Turns review mode on or off for the project. */
|
||||
export function setReviewModeEnabled(enabled: boolean) {
|
||||
useReviewModeSettings.setState({ enabled });
|
||||
const root = projectRoot();
|
||||
if (!root) return;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(key(root), enabled ? "on" : "off");
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export function setEditorThreadsOption(threads: EditorThreadsOption) {
|
||||
useReviewModeSettings.setState({ threads });
|
||||
}
|
||||
|
||||
/** Another project: its own toggle, the default threads option. */
|
||||
export function reloadReviewModeSettings() {
|
||||
useReviewModeSettings.setState({ enabled: loadEnabled(projectRoot()), threads: "unresolved" });
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The pull request of the checked-out branch (IntelliJ's `prOnCurrentBranch` with
|
||||
* `GHPRReviewBranchStateSharedViewModel`): the open pull request whose head is the remote
|
||||
* branch the current branch tracks, looked up again when the branch, its upstream or the
|
||||
* repository changes; and whether the local branch is out of sync with it (`updateRequired`:
|
||||
* its head commit is not in the local history), checked again when HEAD or the pull
|
||||
* request's head moves.
|
||||
*
|
||||
* "Update to Enable Review Mode…" reloads the details, then fetches and checks out the
|
||||
* branch (`gh pr checkout`).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { currentRepoRoot, useVcs } from "@/features/vcs/store";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { errorMessage, rest } from "../api/client";
|
||||
import { checkoutPullRequest } from "../data/local";
|
||||
import {
|
||||
loadPullRequestData,
|
||||
pullRequestData,
|
||||
reloadPullRequestData,
|
||||
subscribePullRequestData,
|
||||
} from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { isUpdateRequired, openPullRequestQuery, trackedBranch } from "./model";
|
||||
|
||||
export interface ReviewBranchState {
|
||||
/** The open pull request whose head the current branch tracks. */
|
||||
number: number | null;
|
||||
/** The local branch. */
|
||||
branch: string | null;
|
||||
/** The pull request's head is not in the local history. */
|
||||
updateRequired: boolean;
|
||||
}
|
||||
|
||||
export const useReviewBranch = create<ReviewBranchState>(() => ({ number: null, branch: null, updateRequired: false }));
|
||||
|
||||
let lookupSeq = 0;
|
||||
|
||||
/** The open pull request for the current branch of the selected repository, or null. */
|
||||
async function findPullRequestForCurrentBranch(): Promise<{ number: number; branch: string } | null> {
|
||||
const connection = useGhRepository.getState().connection;
|
||||
const head = useVcs.getState().branch?.head;
|
||||
if (!connection || !head) return null;
|
||||
const repo = currentRepoRoot();
|
||||
const [remote, merge, remotes] = await Promise.all([
|
||||
call(commands.vcsConfigGet(repo, `branch.${head}.remote`)),
|
||||
call(commands.vcsConfigGet(repo, `branch.${head}.merge`)),
|
||||
call(commands.vcsRemotes(repo)),
|
||||
]);
|
||||
const tracked = trackedBranch(remote, merge, remotes, connection.coordinates);
|
||||
if (!tracked) return null;
|
||||
if (tracked.pullRequest !== null) return { number: tracked.pullRequest, branch: head };
|
||||
const query = openPullRequestQuery(connection.coordinates, tracked);
|
||||
if (!query) return null;
|
||||
const found = await rest<Array<{ number: number }> | null>("GET", query);
|
||||
const number = found?.[0]?.number;
|
||||
return typeof number === "number" ? { number, branch: head } : null;
|
||||
}
|
||||
|
||||
/** Looks up the pull request of the current branch again (`prOnCurrentBranch`). */
|
||||
export async function refreshCurrentBranchPullRequest(): Promise<void> {
|
||||
const seq = ++lookupSeq;
|
||||
let found: { number: number; branch: string } | null = null;
|
||||
try {
|
||||
found = await findPullRequestForCurrentBranch();
|
||||
} catch (e) {
|
||||
console.warn("Could not lookup a pull request for current branch", e);
|
||||
}
|
||||
if (seq !== lookupSeq) return;
|
||||
const s = useReviewBranch.getState();
|
||||
if (found?.number === s.number && found?.branch === s.branch) return;
|
||||
useReviewBranch.setState({ number: found?.number ?? null, branch: found?.branch ?? null, updateRequired: false });
|
||||
if (found) {
|
||||
void loadPullRequestData(found.number, "details").catch(() => undefined);
|
||||
void loadPullRequestData(found.number, "changes").catch(() => undefined);
|
||||
}
|
||||
await refreshSyncState();
|
||||
}
|
||||
|
||||
let syncSeq = 0;
|
||||
|
||||
/** Checks again whether the pull request's head is in the local history. */
|
||||
export async function refreshSyncState(): Promise<void> {
|
||||
const seq = ++syncSeq;
|
||||
const n = useReviewBranch.getState().number;
|
||||
if (n === null) return;
|
||||
let updateRequired = false;
|
||||
try {
|
||||
const pr = await loadPullRequestData(n, "details");
|
||||
const base = await call(commands.vcsMergeBase(currentRepoRoot(), pr.headRefOid, "HEAD")).catch(() => null);
|
||||
updateRequired = isUpdateRequired(pr.headRefOid, base);
|
||||
} catch {
|
||||
// Details not loaded: nothing to compare with (IntelliJ keeps `false`).
|
||||
}
|
||||
if (seq === syncSeq && useReviewBranch.getState().number === n) useReviewBranch.setState({ updateRequired });
|
||||
}
|
||||
|
||||
/** "Update to Enable Review Mode…": reloads the details, then fetches and checks out the branch. */
|
||||
export async function updateReviewBranch(): Promise<void> {
|
||||
const n = useReviewBranch.getState().number;
|
||||
if (n === null) return;
|
||||
try {
|
||||
await reloadPullRequestData(n, "details");
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
title: tx("Pull Request branch update failed"),
|
||||
body: errorMessage(e),
|
||||
key: "github-review-branch-update",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await checkoutPullRequest(n)) await refreshSyncState();
|
||||
}
|
||||
|
||||
/** Whether the changes of the pull request are loaded, loading or failed (the widget's tooltip). */
|
||||
export function changesState(n: number): "loading" | "error" | "loaded" {
|
||||
const s = pullRequestData(n, "changes");
|
||||
return s.value !== undefined ? "loaded" : s.error ? "error" : "loading";
|
||||
}
|
||||
|
||||
/** Follows the branch, the repository and the pull request's head; returns the disposer. */
|
||||
export function trackCurrentBranchPullRequest(): () => void {
|
||||
let stopData: (() => void) | null = null;
|
||||
let lastHead: string | undefined;
|
||||
const followData = (n: number | null) => {
|
||||
stopData?.();
|
||||
stopData = null;
|
||||
lastHead = undefined;
|
||||
if (n === null) return;
|
||||
stopData = subscribePullRequestData(n, () => {
|
||||
const head = pullRequestData(n, "details").value?.headRefOid;
|
||||
if (head && head !== lastHead) {
|
||||
lastHead = head;
|
||||
void refreshSyncState();
|
||||
}
|
||||
});
|
||||
};
|
||||
const disposers = [
|
||||
useVcs.subscribe((s, prev) => {
|
||||
const a = s.branch;
|
||||
const b = prev.branch;
|
||||
if (s.repo !== prev.repo || a?.head !== b?.head || a?.upstream !== b?.upstream)
|
||||
void refreshCurrentBranchPullRequest();
|
||||
else if (a?.headCommit !== b?.headCommit) void refreshSyncState();
|
||||
}),
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
if (s.connection !== prev.connection) void refreshCurrentBranchPullRequest();
|
||||
}),
|
||||
useReviewBranch.subscribe((s, prev) => {
|
||||
if (s.number !== prev.number) followData(s.number);
|
||||
}),
|
||||
() => followData(null),
|
||||
];
|
||||
void refreshCurrentBranchPullRequest();
|
||||
return () => {
|
||||
lookupSeq++;
|
||||
syncSeq++;
|
||||
disposers.reverse().forEach((d) => d());
|
||||
useReviewBranch.setState({ number: null, branch: null, updateRequired: false });
|
||||
};
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/** Pull request state of the Pull Requests tool window: gh availability, list query, selection, details. */
|
||||
import { create } from "zustand";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
GhAvailability,
|
||||
PrDiffRevisions,
|
||||
PrListQuery,
|
||||
PrReviewComment,
|
||||
PullRequestDetails,
|
||||
PullRequestSummary,
|
||||
RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
export type DetailTab = "overview" | "files" | "commits" | "conversation";
|
||||
|
||||
interface GithubState {
|
||||
availability: GhAvailability | null;
|
||||
checking: boolean;
|
||||
query: PrListQuery;
|
||||
list: PullRequestSummary[];
|
||||
listLoading: boolean;
|
||||
listError: string | null;
|
||||
selected: number | null;
|
||||
details: PullRequestDetails | null;
|
||||
detailsLoading: boolean;
|
||||
detailsError: string | null;
|
||||
reviewComments: PrReviewComment[];
|
||||
tab: DetailTab;
|
||||
/** Running gh operation (checkout, create, fetch for diff), with its last output line. */
|
||||
busy: { title: string; line: string; request: RequestId } | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_QUERY: PrListQuery = {
|
||||
state: "open",
|
||||
authorMe: false,
|
||||
reviewRequested: false,
|
||||
assigneeMe: false,
|
||||
search: "",
|
||||
limit: 100,
|
||||
};
|
||||
|
||||
export const useGithub = create<GithubState>(() => ({
|
||||
availability: null,
|
||||
checking: false,
|
||||
query: DEFAULT_QUERY,
|
||||
list: [],
|
||||
listLoading: false,
|
||||
listError: null,
|
||||
selected: null,
|
||||
details: null,
|
||||
detailsLoading: false,
|
||||
detailsError: null,
|
||||
reviewComments: [],
|
||||
tab: "overview",
|
||||
busy: null,
|
||||
}));
|
||||
|
||||
export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
export function readyRepo() {
|
||||
const a = useGithub.getState().availability;
|
||||
return a?.kind === "ready" ? a.repo : null;
|
||||
}
|
||||
|
||||
export async function checkAvailability(): Promise<GhAvailability | null> {
|
||||
if (!isTauri()) return null;
|
||||
useGithub.setState({ checking: true });
|
||||
try {
|
||||
const availability = await call(commands.ghAvailability());
|
||||
useGithub.setState({ availability });
|
||||
return availability;
|
||||
} catch (e) {
|
||||
const availability: GhAvailability = { kind: "error", message: errorMessage(e) };
|
||||
useGithub.setState({ availability });
|
||||
return availability;
|
||||
} finally {
|
||||
useGithub.setState({ checking: false });
|
||||
}
|
||||
}
|
||||
|
||||
let listSeq = 0;
|
||||
export async function loadList(): Promise<void> {
|
||||
if (useGithub.getState().availability?.kind !== "ready") return;
|
||||
const seq = ++listSeq;
|
||||
useGithub.setState({ listLoading: true, listError: null });
|
||||
try {
|
||||
const list = await call(commands.ghPrList(useGithub.getState().query));
|
||||
if (seq === listSeq) useGithub.setState({ list });
|
||||
} catch (e) {
|
||||
if (seq === listSeq) useGithub.setState({ listError: errorMessage(e), list: [] });
|
||||
} finally {
|
||||
if (seq === listSeq) useGithub.setState({ listLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-checks gh and reloads the list (and the open details). */
|
||||
export async function refreshAll(): Promise<void> {
|
||||
const a = await checkAvailability();
|
||||
if (a?.kind !== "ready") return;
|
||||
await loadList();
|
||||
const n = useGithub.getState().selected;
|
||||
if (n !== null) await loadDetails(n);
|
||||
}
|
||||
|
||||
export function setQuery(patch: Partial<PrListQuery>) {
|
||||
useGithub.setState((s) => ({ query: { ...s.query, ...patch } }));
|
||||
void loadList();
|
||||
}
|
||||
|
||||
let detailSeq = 0;
|
||||
export async function loadDetails(number: number): Promise<void> {
|
||||
const seq = ++detailSeq;
|
||||
const same = useGithub.getState().details?.summary.number === number;
|
||||
useGithub.setState({
|
||||
selected: number,
|
||||
detailsLoading: true,
|
||||
detailsError: null,
|
||||
...(same ? {} : { details: null, reviewComments: [] }),
|
||||
});
|
||||
try {
|
||||
const [details, comments] = await Promise.all([
|
||||
call(commands.ghPrView(number)),
|
||||
call(commands.ghPrReviewComments(number)).catch(() => [] as PrReviewComment[]),
|
||||
]);
|
||||
if (seq === detailSeq) useGithub.setState({ details, reviewComments: comments });
|
||||
} catch (e) {
|
||||
if (seq === detailSeq) useGithub.setState({ detailsError: errorMessage(e) });
|
||||
} finally {
|
||||
if (seq === detailSeq) useGithub.setState({ detailsLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function select(number: number | null) {
|
||||
if (number === null) {
|
||||
useGithub.setState({ selected: null, details: null, reviewComments: [] });
|
||||
return;
|
||||
}
|
||||
if (useGithub.getState().selected !== number) void loadDetails(number);
|
||||
}
|
||||
|
||||
/** Runs a cancellable, streaming gh operation shown in the view's banner. */
|
||||
export async function withBusy<T>(
|
||||
title: string,
|
||||
run: (request: RequestId, out: Channel<string>) => Promise<T>,
|
||||
): Promise<T | null> {
|
||||
if (useGithub.getState().busy) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
title: "Another GitHub operation is still running",
|
||||
key: "github-busy",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const request = nextRequestId();
|
||||
const out = new Channel<string>();
|
||||
out.onmessage = (line) => useGithub.setState((s) => (s.busy ? { busy: { ...s.busy, line } } : s));
|
||||
useGithub.setState({ busy: { title, line: "", request } });
|
||||
try {
|
||||
return await run(request, out);
|
||||
} catch (e) {
|
||||
if (!isCancelled(e))
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
title: `${title.replace(/…$/, "")} failed`,
|
||||
body: errorMessage(e),
|
||||
key: "github-op",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
useGithub.setState({ busy: null });
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelBusy() {
|
||||
const b = useGithub.getState().busy;
|
||||
if (b) void commands.cancelRequest(b.request);
|
||||
}
|
||||
|
||||
const diffCache = new Map<string, PrDiffRevisions>();
|
||||
|
||||
/** Another project: start over. */
|
||||
export function resetGithub() {
|
||||
diffCache.clear();
|
||||
useGithub.setState({
|
||||
availability: null,
|
||||
list: [],
|
||||
selected: null,
|
||||
details: null,
|
||||
reviewComments: [],
|
||||
query: DEFAULT_QUERY,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetches what the local repository lacks to diff `pr`, once per head commit. */
|
||||
export async function diffRevisions(pr: PullRequestDetails): Promise<PrDiffRevisions | null> {
|
||||
const key = `${pr.summary.number}:${pr.headOid}:${pr.baseOid}`;
|
||||
const hit = diffCache.get(key);
|
||||
if (hit) return hit;
|
||||
const repo = readyRepo();
|
||||
const revs = await withBusy(`Fetching #${pr.summary.number}…`, (r, out) =>
|
||||
call(
|
||||
commands.ghPrPrepareDiff(
|
||||
r,
|
||||
pr.summary.number,
|
||||
pr.headOid,
|
||||
pr.baseOid,
|
||||
pr.summary.baseRef,
|
||||
repo?.nameWithOwner ?? "",
|
||||
out,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (revs) diffCache.set(key, revs);
|
||||
return revs;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* The timeline editor (GHPRFileEditorComponentFactory): one scrolling column. */
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
outline: none;
|
||||
background: var(--bg-editor);
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.title {
|
||||
max-width: 42em;
|
||||
margin: 0;
|
||||
padding: 20px 16px;
|
||||
color: var(--fg-strong);
|
||||
font-size: 1.4em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.paragraph {
|
||||
margin: 0;
|
||||
}
|
||||
.paragraph + .paragraph {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.commits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.commit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.branch {
|
||||
display: inline-flex;
|
||||
padding: 0 4px 0 2px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-hover);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.loadingThreads {
|
||||
padding: 16px 10px 16px 60px;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: calc(42em + 32px);
|
||||
padding: 16px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.errorTitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
.errorText {
|
||||
color: var(--fg-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.mergedTitle {
|
||||
font-weight: 700;
|
||||
}
|
||||
.mergedBody {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.composer {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* The pull request timeline editor (`GHPRFileEditorComponentFactory`): one scrolling column
|
||||
* with the title (H2, `#N` link), the description item, the merged timeline items, the
|
||||
* loading / error status, the merged banner and the new-comment composer. The timeline loads
|
||||
* page by page as it is scrolled; Refresh Timeline (⌘R / Ctrl+F5) and the context menu's
|
||||
* actions work on it. The tab title follows the details (`{title} #N`).
|
||||
*
|
||||
* `TimelineEditor({ input })` is the component of the editor kind `github.timeline`; an
|
||||
* input of another repository than the connected one shows a message instead.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, type MouseEvent } from "react";
|
||||
import { tx, txTemplate, useTx } from "@/platform/i18n";
|
||||
import { Icon, Spinner, showContextMenu } from "@/ui";
|
||||
import { notifyEditorDocumentChanged, type EditorInput } from "@/workbench/contributions";
|
||||
import type { GHPullRequest } from "../api/types";
|
||||
import { openInBrowser } from "../data/local";
|
||||
import { addIssueComment, deleteMergedBranch, toggleReaction, updateDetails } from "../data/operations";
|
||||
import { loadTimelinePage, usePullRequestData, usePullRequestTimeline } from "../data/pullRequest";
|
||||
import { currentUserHasPermission, sameRepository, useGhRepository } from "../data/repository";
|
||||
import { timelineData, type TimelineInputData } from "../toolwindow/timelineInput";
|
||||
import { BranchChip } from "../ui/chips";
|
||||
import { Placeholder } from "../ui/Placeholder";
|
||||
import { ChatItem, CommentEditor, CommentItem, submitShortcut } from "./comments";
|
||||
import { rich } from "./comments/ChatItem";
|
||||
import { TimelineItem, WebLink } from "./items";
|
||||
import { mergeTimeline } from "./model";
|
||||
import styles from "./Timeline.module.css";
|
||||
|
||||
export function TimelineEditor({ input }: { input: EditorInput }) {
|
||||
const data = timelineData(input);
|
||||
const repo = useGhRepository((s) => s.connection?.coordinates ?? null);
|
||||
if (!data || !sameRepository(repo, data))
|
||||
return <Placeholder title="Connect the Pull Requests tool window to this repository to see the timeline" />;
|
||||
return <Timeline data={data} />;
|
||||
}
|
||||
|
||||
/** Requests the next page when the end is near (IntelliJ: on scrolling) or the column does not fill the view. */
|
||||
const NEAR_END_PX = 400;
|
||||
|
||||
function Timeline({ data }: { data: TimelineInputData }) {
|
||||
const t = useTx();
|
||||
const n = data.number;
|
||||
const details = usePullRequestData(n, "details");
|
||||
const threads = usePullRequestData(n, "threads");
|
||||
const timeline = usePullRequestTimeline(n);
|
||||
const pr = details.value;
|
||||
const scroll = useRef<HTMLDivElement>(null);
|
||||
const entries = useMemo(() => mergeTimeline(timeline.items, import.meta.env.DEV), [timeline.items]);
|
||||
|
||||
// The tab title and icon are read from the details: let the tabs re-read them.
|
||||
useEffect(() => notifyEditorDocumentChanged(), [pr?.title, pr?.state, pr?.isDraft]);
|
||||
|
||||
const more = () => {
|
||||
const el = scroll.current;
|
||||
if (!el || !timeline.hasMore || timeline.loading || timeline.error || timeline.initial) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - NEAR_END_PX) void loadTimelinePage(n);
|
||||
};
|
||||
useEffect(more);
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("textarea, input, a")) return;
|
||||
if (window.getSelection()?.toString()) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [
|
||||
{ kind: "action", action: "github.pullRequest.timeline.refresh" },
|
||||
{ kind: "action", action: "github.pullRequest.openInBrowser", arg: n },
|
||||
{ kind: "action", action: "github.pullRequest.copyUrl", arg: n },
|
||||
]);
|
||||
};
|
||||
|
||||
if (!pr)
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
{details.error ? (
|
||||
<Placeholder title="Can't load pull request details">{details.error}</Placeholder>
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scroll}
|
||||
className={styles.scroll}
|
||||
tabIndex={-1}
|
||||
data-github-timeline={n}
|
||||
onScroll={more}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div className={styles.column}>
|
||||
<h2 className={styles.title}>
|
||||
{pr.title}{" "}
|
||||
<a
|
||||
className={styles.link}
|
||||
href={pr.url}
|
||||
title={t("View in browser")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openInBrowser(pr.url);
|
||||
}}
|
||||
>
|
||||
#{pr.number}
|
||||
</a>
|
||||
</h2>
|
||||
<Description pr={pr} />
|
||||
{entries.map((e) => (
|
||||
<TimelineItem key={e.key} entry={e} pr={pr} threads={threads.value} />
|
||||
))}
|
||||
{(timeline.loading || timeline.error) && (
|
||||
<div className={styles.status}>
|
||||
{timeline.error ? (
|
||||
<>
|
||||
<span className={styles.errorTitle}>{t("Can't load timeline items")}</span>
|
||||
<span className={styles.errorText}>{timeline.error}</span>
|
||||
<button type="button" className={styles.link} onClick={() => void loadTimelinePage(n)}>
|
||||
{t("Retry")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pr.state === "MERGED" && !timeline.error && <MergedBanner pr={pr} />}
|
||||
{currentUserHasPermission("READ") && (
|
||||
<div className={styles.composer}>
|
||||
<CommentEditor
|
||||
showAvatar
|
||||
submitLabel="Comment"
|
||||
submitHint={tx`${submitShortcut()} to comment`}
|
||||
clearOnSubmit
|
||||
onSubmit={(text) => addIssueComment(n, text)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The description item: author, date, body (or "No description provided"), reactions; Edit and Add reaction. */
|
||||
function Description({ pr }: { pr: GHPullRequest }) {
|
||||
const n = pr.number;
|
||||
return (
|
||||
<CommentItem
|
||||
author={pr.author}
|
||||
createdAt={pr.createdAt}
|
||||
body={pr.body}
|
||||
emptyText="No description provided"
|
||||
reactions={pr.reactions.nodes}
|
||||
canEdit={pr.viewerCanUpdate}
|
||||
canReact={pr.viewerCanReact}
|
||||
onSave={(body) => updateDetails(n, { body })}
|
||||
onToggleReaction={(content, add) => toggleReaction(n, pr.id, content, add, "details")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Pull Request Successfully Merged": delete the head branch, or restore it on GitHub. */
|
||||
function MergedBanner({ pr }: { pr: GHPullRequest }) {
|
||||
const t = useTx();
|
||||
const ref = pr.headRef;
|
||||
const canDelete = pr.viewerCanDeleteHeadRef && !!ref;
|
||||
const branch = pr.headRefName ? <BranchChip name={pr.headRefName} className={styles.branch} /> : "branch";
|
||||
return (
|
||||
<ChatItem
|
||||
author={null}
|
||||
icon={<Icon name="github-pr-merged" size={30} />}
|
||||
title={<span className={styles.mergedTitle}>{t("Pull Request Successfully Merged")}</span>}
|
||||
>
|
||||
<span className={styles.mergedBody}>
|
||||
{canDelete
|
||||
? rich(txTemplate("You can now {delete} {branch}."), {
|
||||
delete: (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => void deleteMergedBranch(pr.number, ref!.id)}
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
),
|
||||
branch,
|
||||
})
|
||||
: rich(txTemplate("{branch} has been deleted. {restore}"), {
|
||||
branch,
|
||||
restore: <WebLink url={pr.url}>{t("Restore…")}</WebLink>,
|
||||
})}
|
||||
{!canDelete && <Icon name="external-link" size={12} />}
|
||||
</span>
|
||||
</ChatItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* The row every timeline item, comment and reply uses (collaboration-tools'
|
||||
* `CodeReviewChatItemUIUtil.build` with `CodeReviewTimelineUIUtil.createTitleTextPane`):
|
||||
* the avatar (tooltip: the name), then the header (bold author link · pretty date, tags,
|
||||
* the actions on the right) above the content.
|
||||
*/
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import type { GHActor } from "../../api/types";
|
||||
import { GHOST_USER } from "../../data/model";
|
||||
import { openInBrowser } from "../../data/local";
|
||||
import { Avatar, AVATAR_SIZE } from "../../ui/Avatar";
|
||||
import { prettyDateTime } from "../../ui/prettyDate";
|
||||
import { presentableName } from "../model";
|
||||
import styles from "./comments.module.css";
|
||||
|
||||
/** FULL (timeline items), FULL_SECONDARY (thread replies in the timeline), COMPACT (diff inlays). */
|
||||
export type ChatItemVariant = "full" | "secondary" | "compact";
|
||||
|
||||
export const avatarSize = (v: ChatItemVariant) => (v === "full" ? AVATAR_SIZE.TIMELINE : AVATAR_SIZE.BASE);
|
||||
|
||||
/** A user link: the presentable name in bold, opening the profile. */
|
||||
export function AuthorLink({ actor }: { actor: GHActor | null }) {
|
||||
const a = actor ?? GHOST_USER;
|
||||
return (
|
||||
<a
|
||||
className={styles.author}
|
||||
href={a.url}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (a.url) void openInBrowser(a.url);
|
||||
}}
|
||||
>
|
||||
{presentableName(a)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tag pill (OUTDATED / RESOLVED / PENDING). */
|
||||
export const Tag = ({ children }: { children: ReactNode }) => <span className={styles.tag}>{children}</span>;
|
||||
|
||||
export interface ChatItemProps {
|
||||
variant?: ChatItemVariant;
|
||||
author: GHActor | null;
|
||||
/** The date after the name; none for undated items. */
|
||||
date?: string | null;
|
||||
/** Replaces the avatar (the merged banner's icon). */
|
||||
icon?: ReactNode;
|
||||
/** Replaces the name · date header. */
|
||||
title?: ReactNode;
|
||||
tags?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/** No content width limit (threads with their diffs). */
|
||||
wide?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ChatItem({
|
||||
variant = "full",
|
||||
author,
|
||||
date,
|
||||
icon,
|
||||
title,
|
||||
tags,
|
||||
actions,
|
||||
wide,
|
||||
className,
|
||||
children,
|
||||
}: ChatItemProps) {
|
||||
const a = author ?? GHOST_USER;
|
||||
const name = presentableName(a);
|
||||
return (
|
||||
<div className={[styles.item, className].filter(Boolean).join(" ")} data-variant={variant}>
|
||||
<div className={styles.icon}>{icon ?? <Avatar url={a.avatarUrl} name={name} size={avatarSize(variant)} />}</div>
|
||||
<div className={styles.main} data-wide={wide || undefined}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
{title ?? (
|
||||
<span>
|
||||
<AuthorLink actor={a} />
|
||||
{date && <span className={styles.date}> {prettyDateTime(date)}</span>}
|
||||
</span>
|
||||
)}
|
||||
{tags}
|
||||
</div>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A translated `{name}` template (the first argument a `txTemplate` call gave) with
|
||||
* React nodes for its parameters: `rich(txTemplate("assigned {users}"), { users })`.
|
||||
*/
|
||||
export function rich(template: string, values: Record<string, ReactNode>): ReactNode {
|
||||
const parts = template.split(/\{(\w+)\}/);
|
||||
return parts.map((p, i) => <Fragment key={i}>{i % 2 ? (values[p] ?? `{${p}}`) : p}</Fragment>);
|
||||
}
|
||||
|
||||
/** Nodes joined with ", " (IntelliJ's `joinToString`). */
|
||||
export function joined(nodes: ReactNode[]): ReactNode {
|
||||
return nodes.map((n, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && ", "}
|
||||
{n}
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* The comment text field (collaboration-tools' `CodeReviewCommentTextFieldFactory` with
|
||||
* `CommentInputActionsComponentFactory`): an optional avatar, a growing Markdown field with
|
||||
* @mention completion (the repository's mentionable users, `GHViewModelMentionCompletion`),
|
||||
* then the hints ("{Enter} to add new line", the submit hint) and the buttons (Cancel, extra
|
||||
* actions, the primary one). ⌘/Ctrl+Enter submits, Escape cancels.
|
||||
*
|
||||
* <CommentEditor submitLabel="Reply" submitHint={tx`${submitShortcut()} to reply`}
|
||||
* onSubmit={(text) => replyToComment(n, id, text)} />
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { tx, useTx } from "@/platform/i18n";
|
||||
import { formatBinding, isMacHost } from "@/platform/keymap";
|
||||
import { Button, Popup } from "@/ui";
|
||||
import { errorMessage } from "../../api/client";
|
||||
import type { GHUser } from "../../api/types";
|
||||
import { useGhRepository, useRepositoryList } from "../../data/repository";
|
||||
import { Avatar } from "../../ui/Avatar";
|
||||
import { filterMentions, insertMention, mentionQuery } from "../model";
|
||||
import { avatarSize, type ChatItemVariant } from "./ChatItem";
|
||||
import styles from "./comments.module.css";
|
||||
|
||||
/** The submit shortcut as hints show it (`CommentInputActionsComponentFactory.submitShortcutText`). */
|
||||
export const submitShortcut = () => formatBinding(isMacHost() ? "Meta+Enter" : "Ctrl+Enter");
|
||||
/** The new-line shortcut (Enter). */
|
||||
const newLineShortcut = () => formatBinding("Enter");
|
||||
|
||||
export interface CommentEditorAction {
|
||||
label: string;
|
||||
onClick: () => void | Promise<unknown>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CommentEditorProps {
|
||||
initialText?: string;
|
||||
/** The primary button: "Comment", "Reply", "Save Changes"… */
|
||||
submitLabel: string;
|
||||
/** "⌘⏎ to comment" and the like (translated by the caller). */
|
||||
submitHint: string;
|
||||
/** Sends the text; the field keeps it when this rejects (the error shows under it). */
|
||||
onSubmit: (text: string) => Promise<unknown>;
|
||||
/** Shows Cancel (and Escape cancels). */
|
||||
onCancel?: () => void;
|
||||
/** Escape only, no Cancel button (a diff's new comment: its gutter icon discards it). */
|
||||
noCancelButton?: boolean;
|
||||
/** Buttons before the primary one ("Resolve" of the reply box). */
|
||||
extraActions?: CommentEditorAction[];
|
||||
/** The author avatar on the left: the current user's (FULL / COMPACT inputs). */
|
||||
showAvatar?: boolean;
|
||||
variant?: ChatItemVariant;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
/** Empty the field after a successful submit (new comments and replies). */
|
||||
clearOnSubmit?: boolean;
|
||||
/** Allows submitting an empty text (editing a review body to nothing is not allowed). */
|
||||
allowEmpty?: boolean;
|
||||
/** Called as the text changes (a diff's new comment keeps its draft). */
|
||||
onTextChange?: (text: string) => void;
|
||||
/** Changing it moves the focus into the field again (a new comment requested twice). */
|
||||
focusRequest?: number;
|
||||
}
|
||||
|
||||
export function CommentEditor({
|
||||
initialText = "",
|
||||
submitLabel,
|
||||
submitHint,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
noCancelButton,
|
||||
extraActions = [],
|
||||
showAvatar,
|
||||
variant = "full",
|
||||
placeholder,
|
||||
autoFocus,
|
||||
clearOnSubmit,
|
||||
allowEmpty,
|
||||
onTextChange,
|
||||
focusRequest,
|
||||
}: CommentEditorProps) {
|
||||
const t = useTx();
|
||||
const me = useGhRepository((s) => s.connection?.currentUser ?? null);
|
||||
const [text, setText] = useState(initialText);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mention, setMention] = useState<{ start: number; query: string } | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
// The mentionable users load once per repository, on the first editor.
|
||||
const mentionable = useRepositoryList("mentionableUsers").items;
|
||||
const matches = mention ? filterMentions(mentionable, mention.query) : [];
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const [field, setField] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// Grow with the text, like IntelliJ's comment field.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight + 2}px`;
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus) return;
|
||||
const el = ref.current;
|
||||
el?.focus();
|
||||
el?.setSelectionRange(el.value.length, el.value.length);
|
||||
}, [autoFocus, focusRequest]);
|
||||
|
||||
const canSubmit = !busy && (allowEmpty || !!text.trim());
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(text);
|
||||
if (clearOnSubmit) {
|
||||
setText("");
|
||||
onTextChange?.("");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runExtra = async (a: CommentEditorAction) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await a.onClick();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMention = (value: string, caret: number) => {
|
||||
const m = mentionQuery(value, caret);
|
||||
setMention(m);
|
||||
setActive(0);
|
||||
};
|
||||
|
||||
const pick = (u: GHUser) => {
|
||||
const el = ref.current;
|
||||
if (!el || !mention) return;
|
||||
const r = insertMention(text, mention.start, el.selectionStart, u.login);
|
||||
setText(r.text);
|
||||
onTextChange?.(r.text);
|
||||
setMention(null);
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(r.caret, r.caret);
|
||||
});
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (mention && matches.length) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const d = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive((a) => (a + d + matches.length) % matches.length);
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Enter" && !e.metaKey && !e.ctrlKey) || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
pick(matches[Math.min(active, matches.length - 1)]!);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (mention) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMention(null);
|
||||
} else if (onCancel) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && (isMacHost() ? e.metaKey : e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.editorRow} style={{ ["--gap" as string]: variant === "full" ? "14px" : "10px" }}>
|
||||
{showAvatar && me && <Avatar url={me.avatarUrl} name={me.name || me.login} size={avatarSize(variant)} />}
|
||||
<div className={styles.editor}>
|
||||
<div ref={setField} className={styles.field}>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={styles.textarea}
|
||||
rows={1}
|
||||
value={text}
|
||||
disabled={busy}
|
||||
placeholder={t(placeholder)}
|
||||
aria-label={placeholder ? t(placeholder) : t(submitLabel)}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
onTextChange?.(e.target.value);
|
||||
updateMention(e.target.value, e.target.selectionStart);
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
const el = e.currentTarget;
|
||||
if (mention) updateMention(el.value, el.selectionStart);
|
||||
}}
|
||||
onBlur={() => setTimeout(() => setMention(null), 150)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{mention && (
|
||||
<MentionPopup
|
||||
anchor={field}
|
||||
matches={matches}
|
||||
active={active}
|
||||
onPick={pick}
|
||||
onClose={() => setMention(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.editorFooter}>
|
||||
<div className={styles.hints}>
|
||||
<span>{submitHint}</span>
|
||||
<span>{tx`${newLineShortcut()} to add new line`}</span>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
{onCancel && !noCancelButton && (
|
||||
<Button disabled={busy} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{extraActions.map((a) => (
|
||||
<Button key={a.label} disabled={busy || a.disabled} onClick={() => void runExtra(a)}>
|
||||
{t(a.label)}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="primary" disabled={!canSubmit} onClick={() => void submit()}>
|
||||
{t(submitLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The completion list of the `@query` being typed. */
|
||||
function MentionPopup(props: {
|
||||
anchor: HTMLElement | null;
|
||||
matches: GHUser[];
|
||||
active: number;
|
||||
onPick: (u: GHUser) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { matches } = props;
|
||||
if (!matches.length) return null;
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
onClose={props.onClose}
|
||||
placement="bottom-start"
|
||||
autoFocus={false}
|
||||
restoreFocus={false}
|
||||
aria-label="Mentions"
|
||||
>
|
||||
<div className={styles.mentions} role="listbox">
|
||||
{matches.map((u, i) => (
|
||||
<div
|
||||
key={u.id || u.login}
|
||||
role="option"
|
||||
aria-selected={i === props.active}
|
||||
className={styles.mention}
|
||||
data-active={i === props.active || undefined}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
props.onPick(u);
|
||||
}}
|
||||
>
|
||||
<Avatar url={u.avatarUrl} name={u.login} size={16} title={null} />
|
||||
<span>{u.login}</span>
|
||||
{u.name && <span className={styles.secondary}>{u.name}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* A comment as a chat item (`GHPRTimelineItemComponentFactory` comments,
|
||||
* `GHPRReviewThreadCommentComponentFactory`): the header actions Edit (an inline editor
|
||||
* with "Save Changes"), Delete (after "Delete Comment" / "Are you sure you want to delete
|
||||
* this comment?") and Add reaction, each shown when allowed, then the body and the
|
||||
* reactions row.
|
||||
*/
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { IconButton } from "@/ui";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { errorMessage } from "../../api/client";
|
||||
import type { GHActor, GHReaction } from "../../api/types";
|
||||
import { Markdown } from "../../ui/Markdown";
|
||||
import { ChatItem, type ChatItemVariant } from "./ChatItem";
|
||||
import { CommentEditor, submitShortcut } from "./CommentEditor";
|
||||
import { AddReactionButton, Reactions, useReactionsState, type ToggleReaction } from "./Reactions";
|
||||
import styles from "./comments.module.css";
|
||||
|
||||
/** `CodeReviewCommentUIUtil.createDeleteCommentIconButton`'s question. */
|
||||
export function confirmDeleteComment(): Promise<boolean> {
|
||||
return confirm({
|
||||
title: "Delete Comment",
|
||||
message: tx("Are you sure you want to delete this comment?"),
|
||||
ok: "Delete",
|
||||
icon: "question",
|
||||
});
|
||||
}
|
||||
|
||||
export interface CommentItemProps {
|
||||
variant?: ChatItemVariant;
|
||||
author: GHActor | null;
|
||||
createdAt: string | null;
|
||||
/** Markdown. */
|
||||
body: string;
|
||||
/** Shown in grey italics for an empty body ("No description provided"). */
|
||||
emptyText?: string;
|
||||
/** The reactions row and the Add reaction button (with `canReact`); none when absent. */
|
||||
reactions?: readonly GHReaction[];
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
canReact?: boolean;
|
||||
onSave?: (body: string) => Promise<unknown>;
|
||||
onDelete?: () => Promise<unknown>;
|
||||
onToggleReaction?: ToggleReaction;
|
||||
tags?: ReactNode;
|
||||
/** Renders the body (suggested changes); Markdown by default. */
|
||||
renderBody?: (body: string) => ReactNode;
|
||||
/** Under the body (a review's status line). */
|
||||
footer?: ReactNode;
|
||||
/** Disables the actions (another request of the item runs). */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function CommentItem(props: CommentItemProps) {
|
||||
const {
|
||||
variant = "full",
|
||||
body,
|
||||
emptyText,
|
||||
reactions,
|
||||
canEdit,
|
||||
canDelete,
|
||||
canReact,
|
||||
onSave,
|
||||
onDelete,
|
||||
onToggleReaction,
|
||||
renderBody,
|
||||
} = props;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reactionsState = useReactionsState(reactions ?? [], onToggleReaction ?? (() => Promise.resolve()));
|
||||
const busy = props.busy || deleting;
|
||||
|
||||
const remove = async () => {
|
||||
if (!onDelete || !(await confirmDeleteComment())) return;
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onDelete();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actions =
|
||||
(canEdit && onSave) || (canDelete && onDelete) || (canReact && onToggleReaction && reactions) ? (
|
||||
<>
|
||||
{canEdit && onSave && (
|
||||
<IconButton icon="edit" title="Edit" size="small" disabled={busy} onClick={() => setEditing(true)} />
|
||||
)}
|
||||
{canDelete && onDelete && (
|
||||
<IconButton icon="trash" title="Delete" size="small" disabled={busy} onClick={() => void remove()} />
|
||||
)}
|
||||
{canReact && onToggleReaction && reactions && <AddReactionButton state={reactionsState} />}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ChatItem variant={variant} author={props.author} date={props.createdAt} tags={props.tags} actions={actions}>
|
||||
{editing && onSave ? (
|
||||
<CommentEditor
|
||||
initialText={body}
|
||||
submitLabel="Save Changes"
|
||||
submitHint={tx`${submitShortcut()} to save changes`}
|
||||
autoFocus
|
||||
variant={variant}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSubmit={async (text) => {
|
||||
await onSave(text);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
) : renderBody ? (
|
||||
renderBody(body)
|
||||
) : (
|
||||
<Markdown text={body} emptyText={emptyText} />
|
||||
)}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{props.footer}
|
||||
{reactions && <Reactions state={reactionsState} canReact={!!canReact && !!onToggleReaction} />}
|
||||
</ChatItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Small read-only diffs (`TimelineDiffComponentFactory`): a unified hunk with both line
|
||||
* number columns, inserted / deleted lines coloured and the comment's anchor lines
|
||||
* highlighted; and the rounded box with the file name header a thread's diff sits in
|
||||
* (`createDiffWithHeader`), whose name opens the file's diff and whose chevron collapses it.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { IconButton } from "@/ui";
|
||||
import { hunkLineNumbers, type PatchHunk } from "../model";
|
||||
import styles from "./comments.module.css";
|
||||
|
||||
export function HunkView({ hunk, anchor }: { hunk: PatchHunk; anchor?: [number, number] | null }) {
|
||||
const numbers = hunkLineNumbers(hunk);
|
||||
return (
|
||||
<div className={styles.hunk}>
|
||||
<table>
|
||||
<tbody>
|
||||
{hunk.lines.map((l, i) => (
|
||||
<tr key={i} data-type={l.type} data-anchor={anchor && i >= anchor[0] && i < anchor[1] ? true : undefined}>
|
||||
<td className={styles.lineNo}>{numbers[i]![0] ?? ""}</td>
|
||||
<td className={styles.lineNo}>{numbers[i]![1] ?? ""}</td>
|
||||
<td className={styles.code}>{l.text || " "}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffBox({
|
||||
path,
|
||||
onOpen,
|
||||
collapsible,
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
children,
|
||||
}: {
|
||||
path: string;
|
||||
onOpen?: () => void;
|
||||
collapsible?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.diffBox}>
|
||||
<div className={styles.fileHeader}>
|
||||
<span
|
||||
className={styles.fileName}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
title={path}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => e.key === "Enter" && onOpen?.()}
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
{collapsible && (
|
||||
<IconButton
|
||||
icon={collapsed ? "chevron-down" : "chevron-up"}
|
||||
title={collapsed ? "Expand" : "Collapse"}
|
||||
size="small"
|
||||
onClick={onToggleCollapsed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Reactions of a description, comment or review comment (`GHReactionsComponentFactory`,
|
||||
* `GHReactionsPickerComponentFactory`): a pill per reaction (emoji, count, outlined when
|
||||
* the viewer reacted, tooltip "{users} reacted with '{name}' emoji") that toggles the
|
||||
* viewer's reaction, then a pill opening the picker; and the "Add reaction" header button.
|
||||
* The viewer's toggle shows at once and is undone when the request fails.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { IconButton, Popup, tooltipAttrs } from "@/ui";
|
||||
import type { GHReaction, GHReactionContent } from "../../api/types";
|
||||
import { useGhRepository } from "../../data/repository";
|
||||
import { groupReactions, presentableName, REACTIONS, toggleReactionLocally } from "../model";
|
||||
import styles from "./comments.module.css";
|
||||
|
||||
export type ToggleReaction = (content: GHReactionContent, add: boolean) => Promise<unknown>;
|
||||
|
||||
const reactionOf = (c: GHReactionContent) => REACTIONS.find((r) => r.content === c)!;
|
||||
|
||||
/** What `Reactions` and `AddReactionButton` of one subject share. */
|
||||
export interface ReactionsState {
|
||||
shown: readonly GHReaction[];
|
||||
toggle: (content: GHReactionContent) => void;
|
||||
meId: string | null;
|
||||
}
|
||||
|
||||
/** The reactions shown: the given ones with the viewer's pending toggle applied. */
|
||||
export function useReactionsState(reactions: readonly GHReaction[], onToggle: ToggleReaction): ReactionsState {
|
||||
const me = useGhRepository((s) => s.connection?.currentUser ?? null);
|
||||
// The local toggle applies until the reactions are reloaded.
|
||||
const [local, setLocal] = useState<{ base: readonly GHReaction[]; value: GHReaction[] } | null>(null);
|
||||
const shown = local && local.base === reactions ? local.value : reactions;
|
||||
const toggle = (content: GHReactionContent) => {
|
||||
if (!me) return;
|
||||
const add = !groupReactions(shown, me.id).find((r) => r.content === content)?.isReactedByCurrentUser;
|
||||
setLocal({ base: reactions, value: toggleReactionLocally(shown, content, me, add) });
|
||||
onToggle(content, add).catch(() => setLocal(null));
|
||||
};
|
||||
return { shown, toggle, meId: me?.id ?? null };
|
||||
}
|
||||
|
||||
/** The emoji picker under `anchor`. */
|
||||
function ReactionPicker({
|
||||
anchor,
|
||||
reacted,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
anchor: Element | null;
|
||||
reacted: ReadonlySet<GHReactionContent>;
|
||||
onPick: (c: GHReactionContent) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Popup open anchor={anchor} onClose={onClose} placement="bottom-start" aria-label={tx("Add reaction")}>
|
||||
<div className={styles.picker}>
|
||||
{REACTIONS.map((r) => (
|
||||
<button
|
||||
key={r.content}
|
||||
type="button"
|
||||
className={styles.pickerEmoji}
|
||||
data-own={reacted.has(r.content) || undefined}
|
||||
aria-label={r.name}
|
||||
{...tooltipAttrs(r.name)}
|
||||
onClick={() => {
|
||||
onPick(r.content);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{r.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ReactionsProps {
|
||||
/** `useReactionsState` of the subject (shared with its `AddReactionButton`). */
|
||||
state: ReactionsState;
|
||||
/** The viewer may react (`viewerCanReact`); otherwise the pills only show. */
|
||||
canReact: boolean;
|
||||
}
|
||||
|
||||
/** The reactions row; nothing when there are none. */
|
||||
export function Reactions({ state, canReact }: ReactionsProps) {
|
||||
const { shown, toggle, meId } = state;
|
||||
const [picker, setPicker] = useState<Element | null>(null);
|
||||
const groups = groupReactions(shown, meId);
|
||||
if (!groups.length) return null;
|
||||
return (
|
||||
<div className={styles.reactions}>
|
||||
{groups.map((g) => {
|
||||
const r = reactionOf(g.content);
|
||||
const users = g.users.map((u) => presentableName(u)).join(", ");
|
||||
const name = r.name;
|
||||
return (
|
||||
<button
|
||||
key={g.content}
|
||||
type="button"
|
||||
className={styles.pill}
|
||||
data-own={g.isReactedByCurrentUser || undefined}
|
||||
disabled={!canReact}
|
||||
{...tooltipAttrs(tx`${users} reacted with '${name}' emoji`)}
|
||||
onClick={() => toggle(g.content)}
|
||||
>
|
||||
<span className={styles.emoji}>{r.emoji}</span>
|
||||
{g.users.length}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canReact && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pill}
|
||||
aria-label={tx("Add reaction")}
|
||||
{...tooltipAttrs(tx("Add reaction"))}
|
||||
onClick={(e) => setPicker(e.currentTarget)}
|
||||
>
|
||||
<span className={styles.emoji}>☺</span>+
|
||||
</button>
|
||||
)}
|
||||
{picker && (
|
||||
<ReactionPicker
|
||||
anchor={picker}
|
||||
reacted={new Set(groups.filter((g) => g.isReactedByCurrentUser).map((g) => g.content))}
|
||||
onPick={toggle}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The header's "Add reaction" button: the picker for the same reactions. */
|
||||
export function AddReactionButton({ state }: { state: ReactionsState }) {
|
||||
const { shown, toggle, meId } = state;
|
||||
const [anchor, setAnchor] = useState<Element | null>(null);
|
||||
const reacted = new Set(
|
||||
groupReactions(shown, meId)
|
||||
.filter((g) => g.isReactedByCurrentUser)
|
||||
.map((g) => g.content),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<IconButton icon="add-emoji" title="Add reaction" size="small" onClick={(e) => setAnchor(e.currentTarget)} />
|
||||
{anchor && <ReactionPicker anchor={anchor} reacted={reacted} onPick={toggle} onClose={() => setAnchor(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* A review comment's body with its suggested changes (`GHPRReviewCommentBodyComponentFactory`,
|
||||
* `GHPRReviewSuggestedChangeComponentFactory`): Markdown parts as they are, each
|
||||
* ```` ```suggestion ```` block as a rounded "Suggested change" box with the diff from the
|
||||
* commented code to the suggestion and the "Apply Locally" option button with "Commit…".
|
||||
* The button is disabled, with IntelliJ's tooltip, for resolved and outdated threads and
|
||||
* when the pull request's branch is not checked out.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Button, Icon, Menu, Popup, tooltipAttrs } from "@/ui";
|
||||
import type { GHPullRequestReviewThread } from "../../api/types";
|
||||
import { Markdown } from "../../ui/Markdown";
|
||||
import {
|
||||
splitSuggestions,
|
||||
suggestionDisabledReason,
|
||||
suggestionTarget,
|
||||
threadDiffHunk,
|
||||
type PatchHunk,
|
||||
type SuggestionTarget,
|
||||
} from "../model";
|
||||
import { HunkView } from "./DiffHunk";
|
||||
import { applySuggestion, useOnReviewBranch } from "./suggestion";
|
||||
import styles from "./comments.module.css";
|
||||
|
||||
/** The diff of a suggestion: the commented lines removed, the suggested ones added. */
|
||||
function suggestionHunk(target: SuggestionTarget, lines: readonly string[]): PatchHunk {
|
||||
const start = target.startLineIndex ?? 0;
|
||||
return {
|
||||
startLineBefore: start,
|
||||
startLineAfter: start,
|
||||
lines: [
|
||||
...target.originalLines.map((text) => ({ type: "REMOVE" as const, text })),
|
||||
...lines.map((text) => ({ type: "ADD" as const, text })),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function CommitPopup({
|
||||
anchor,
|
||||
onCommit,
|
||||
onClose,
|
||||
}: {
|
||||
anchor: Element | null;
|
||||
onCommit: (m: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTx();
|
||||
const [message, setMessage] = useState("");
|
||||
const [empty, setEmpty] = useState(false);
|
||||
const commit = () => {
|
||||
if (!message.trim()) return setEmpty(true);
|
||||
onCommit(message);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<Popup open anchor={anchor} onClose={onClose} placement="bottom-end" trapFocus aria-label={t("Commit")}>
|
||||
<div className={styles.commitPopup}>
|
||||
<textarea
|
||||
data-autofocus
|
||||
aria-label={t("Commit message")}
|
||||
placeholder={t("Commit message")}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
setEmpty(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{empty && (
|
||||
<span className={styles.error}>
|
||||
<Icon name="error" size={14} /> {t("Commit message is empty")}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.buttons}>
|
||||
<Button variant="primary" onClick={commit}>
|
||||
Commit
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionBox({
|
||||
number,
|
||||
thread,
|
||||
target,
|
||||
lines,
|
||||
}: {
|
||||
number: number;
|
||||
thread: GHPullRequestReviewThread;
|
||||
target: SuggestionTarget;
|
||||
lines: string[];
|
||||
}) {
|
||||
const t = useTx();
|
||||
const onBranch = useOnReviewBranch(number);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [commit, setCommit] = useState(false);
|
||||
const [arrow, setArrow] = useState<HTMLButtonElement | null>(null);
|
||||
const reason = suggestionDisabledReason(target.applicability, onBranch ?? false);
|
||||
const disabled = busy || reason !== null;
|
||||
const run = async (message?: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await applySuggestion(number, thread, target, lines, message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.suggestion}>
|
||||
<div className={styles.suggestionTop}>
|
||||
<span className={styles.suggestionTitle}>{t("Suggested change")}</span>
|
||||
<span className={styles.split} {...(reason ? tooltipAttrs(t(reason)) : {})}>
|
||||
<Button size="small" disabled={disabled} onClick={() => void run()}>
|
||||
Apply Locally
|
||||
</Button>
|
||||
<Button ref={setArrow} size="small" disabled={disabled} aria-label={t("More")} onClick={() => setMenu(true)}>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<HunkView hunk={suggestionHunk(target, lines)} />
|
||||
<Menu
|
||||
open={menu}
|
||||
anchor={arrow}
|
||||
onClose={() => setMenu(false)}
|
||||
placement="bottom-end"
|
||||
items={[{ label: "Commit…", run: () => setCommit(true) }]}
|
||||
/>
|
||||
{commit && <CommitPopup anchor={arrow} onCommit={(m) => void run(m)} onClose={() => setCommit(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The body of a review comment in `thread`, its suggested changes as boxes. */
|
||||
export function ReviewCommentBody({
|
||||
number,
|
||||
thread,
|
||||
body,
|
||||
}: {
|
||||
number: number;
|
||||
thread: GHPullRequestReviewThread;
|
||||
body: string;
|
||||
}) {
|
||||
const blocks = splitSuggestions(body);
|
||||
if (!blocks.some((b) => b.type === "suggestion")) return <Markdown text={body} />;
|
||||
const target = suggestionTarget({ ...thread, diffHunk: threadDiffHunk(thread) });
|
||||
return (
|
||||
<div>
|
||||
{blocks.map((b, i) =>
|
||||
b.type === "markdown" ? (
|
||||
<Markdown key={i} text={b.text} />
|
||||
) : (
|
||||
<SuggestionBox key={i} number={number} thread={thread} target={target} lines={b.lines} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* A review thread, in two variants:
|
||||
*
|
||||
* - `full` (`GHPRTimelineThreadComponentFactory`, the timeline): the first comment's author
|
||||
* and date with the OUTDATED / RESOLVED / PENDING tags and its actions; the file header
|
||||
* (opening the diff at the line) over the hunk with the anchor highlighted, then the
|
||||
* comment. Resolved threads start collapsed: a 2-line grey preview above the file header.
|
||||
* Replies start folded: their authors' avatars, "Reply" / "{n} replies", the last reply's
|
||||
* date and Resolve / Unresolve; unfolded, the replies and the reply box.
|
||||
* - `compact` (`GHPRReviewThreadComponentFactory`, diff inlays): the comments (with more
|
||||
* than 3, the first, "… {n} more replies" and the last), then "Reply" and Resolve /
|
||||
* Unresolve; "Reply" opens the reply box.
|
||||
*
|
||||
* Mutations go through `data/operations.ts`; the thread comes from the pull request's
|
||||
* `threads` data, which they refresh.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { tx, useTx } from "@/platform/i18n";
|
||||
import { IconButton } from "@/ui";
|
||||
import { errorMessage } from "../../api/client";
|
||||
import type { GHPullRequestReviewComment, GHPullRequestReviewThread } from "../../api/types";
|
||||
import { openPullRequestFileDiff } from "../../data/local";
|
||||
import {
|
||||
deleteReviewComment,
|
||||
replyToComment,
|
||||
resolveThread,
|
||||
toggleReaction,
|
||||
unresolveThread,
|
||||
updateReviewComment,
|
||||
} from "../../data/operations";
|
||||
import { AvatarStack } from "../../ui/Avatar";
|
||||
import { Markdown } from "../../ui/Markdown";
|
||||
import { prettyDateTime } from "../../ui/prettyDate";
|
||||
import { presentableName, threadDiffHunk, threadDiffWithAnchor } from "../model";
|
||||
import { ChatItem, Tag, type ChatItemVariant } from "./ChatItem";
|
||||
import { CommentEditor, submitShortcut } from "./CommentEditor";
|
||||
import { confirmDeleteComment, CommentItem } from "./CommentItem";
|
||||
import { DiffBox, HunkView } from "./DiffHunk";
|
||||
import { AddReactionButton, Reactions, useReactionsState } from "./Reactions";
|
||||
import { ReviewCommentBody } from "./SuggestedChange";
|
||||
import styles from "./comments.module.css";
|
||||
|
||||
export interface ThreadViewProps {
|
||||
/** The pull request number. */
|
||||
number: number;
|
||||
thread: GHPullRequestReviewThread;
|
||||
variant?: "full" | "compact";
|
||||
}
|
||||
|
||||
export function ThreadView({ variant = "full", ...props }: ThreadViewProps) {
|
||||
return variant === "full" ? <FullThread {...props} /> : <CompactThread {...props} />;
|
||||
}
|
||||
|
||||
/** "Resolve" / "Unresolve" (`getResolveToggleActionText`). */
|
||||
const resolveText = (resolved: boolean) => (resolved ? "Unresolve" : "Resolve");
|
||||
|
||||
/** The thread's resolve toggle and busy state; `onChanged` gets the new resolved state. */
|
||||
function useResolve(number: number, thread: GHPullRequestReviewThread, onChanged?: (resolved: boolean) => void) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canChange = thread.isResolved ? thread.viewerCanUnresolve : thread.viewerCanResolve;
|
||||
const toggle = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const t = thread.isResolved ? await unresolveThread(number, thread.id) : await resolveThread(number, thread.id);
|
||||
onChanged?.(t.isResolved);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { busy, error, canChange, toggle };
|
||||
}
|
||||
|
||||
/** The reply box: "Reply" plus Resolve / Unresolve. */
|
||||
function ReplyBox({
|
||||
number,
|
||||
thread,
|
||||
variant,
|
||||
resolve,
|
||||
autoFocus,
|
||||
onCancel,
|
||||
}: {
|
||||
number: number;
|
||||
thread: GHPullRequestReviewThread;
|
||||
variant: ChatItemVariant;
|
||||
resolve: ReturnType<typeof useResolve>;
|
||||
autoFocus?: boolean;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const first = thread.comments.nodes[0];
|
||||
if (!thread.viewerCanReply || !first) return null;
|
||||
return (
|
||||
<div className={styles.replyBox} data-variant={variant === "compact" ? "compact" : undefined}>
|
||||
<CommentEditor
|
||||
submitLabel="Reply"
|
||||
submitHint={tx`${submitShortcut()} to reply`}
|
||||
showAvatar
|
||||
variant={variant === "compact" ? "compact" : "secondary"}
|
||||
autoFocus={autoFocus}
|
||||
clearOnSubmit
|
||||
onCancel={onCancel}
|
||||
extraActions={
|
||||
resolve.canChange ? [{ label: resolveText(thread.isResolved), onClick: resolve.toggle }] : undefined
|
||||
}
|
||||
onSubmit={(text) => replyToComment(number, first.id, text)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tagsOf = (thread: GHPullRequestReviewThread, comment: GHPullRequestReviewComment | undefined, first: boolean) => (
|
||||
<>
|
||||
{first && thread.isOutdated && <Tag>OUTDATED</Tag>}
|
||||
{first && thread.isResolved && <Tag>RESOLVED</Tag>}
|
||||
{comment?.state === "PENDING" && <Tag>PENDING</Tag>}
|
||||
</>
|
||||
);
|
||||
|
||||
/** A review comment of `thread` as a chat item, editable, deletable and reactable as allowed. */
|
||||
function ReviewComment({
|
||||
number,
|
||||
thread,
|
||||
comment,
|
||||
variant,
|
||||
first,
|
||||
}: {
|
||||
number: number;
|
||||
thread: GHPullRequestReviewThread;
|
||||
comment: GHPullRequestReviewComment;
|
||||
variant: ChatItemVariant;
|
||||
first?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<CommentItem
|
||||
variant={variant}
|
||||
author={comment.author}
|
||||
createdAt={comment.createdAt}
|
||||
body={comment.body}
|
||||
reactions={comment.reactions.nodes}
|
||||
canEdit={comment.viewerCanUpdate}
|
||||
canDelete={comment.viewerCanDelete}
|
||||
canReact={comment.viewerCanReact}
|
||||
tags={
|
||||
variant === "compact" ? tagsOf(thread, comment, !!first) : comment.state === "PENDING" && <Tag>PENDING</Tag>
|
||||
}
|
||||
renderBody={(body) => <ReviewCommentBody number={number} thread={thread} body={body} />}
|
||||
onSave={(body) => updateReviewComment(number, comment.id, body)}
|
||||
onDelete={() => deleteReviewComment(number, comment.id)}
|
||||
onToggleReaction={(content, add) => toggleReaction(number, comment.id, content, add, "threads")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function openThreadDiff(number: number, thread: GHPullRequestReviewThread) {
|
||||
const line = thread.line != null && thread.diffSide === "RIGHT" ? thread.line - 1 : undefined;
|
||||
void openPullRequestFileDiff(number, thread.path, { line });
|
||||
}
|
||||
|
||||
function FullThread({ number, thread }: Omit<ThreadViewProps, "variant">) {
|
||||
const t = useTx();
|
||||
// Only resolved threads collapse (`collapsible = isResolved`): unresolved elsewhere, it opens.
|
||||
const [collapsedState, setCollapsedState] = useState(thread.isResolved);
|
||||
const collapsed = thread.isResolved && collapsedState;
|
||||
const [repliesFolded, setRepliesFolded] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setCollapsed = (c: boolean) => {
|
||||
setCollapsedState(c);
|
||||
if (c) setRepliesFolded(true);
|
||||
};
|
||||
const resolve = useResolve(number, thread, setCollapsed);
|
||||
const [main, ...replies] = thread.comments.nodes;
|
||||
const reactions = useReactionsState(main?.reactions.nodes ?? [], (content, add) =>
|
||||
main ? toggleReaction(number, main.id, content, add, "threads") : Promise.resolve(),
|
||||
);
|
||||
const busy = resolve.busy || deleting;
|
||||
const diff = threadDiffWithAnchor({ ...thread, diffHunk: threadDiffHunk(thread) });
|
||||
const box = (
|
||||
<DiffBox
|
||||
path={thread.path}
|
||||
onOpen={() => openThreadDiff(number, thread)}
|
||||
collapsible={thread.isResolved}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{diff ? (
|
||||
<HunkView hunk={diff.hunk} anchor={diff.anchor} />
|
||||
) : thread.subjectType === "FILE" ? null : (
|
||||
<div className={styles.noDiff}>{t("Could not load diff hunk for thread")}</div>
|
||||
)}
|
||||
</DiffBox>
|
||||
);
|
||||
|
||||
const remove = async () => {
|
||||
if (!main || !(await confirmDeleteComment())) return;
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteReviewComment(number, main.id);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actions = main && (main.viewerCanUpdate || main.viewerCanDelete || main.viewerCanReact) && (
|
||||
<>
|
||||
{main.viewerCanUpdate && (
|
||||
<IconButton icon="edit" title="Edit" size="small" disabled={busy} onClick={() => setEditing(true)} />
|
||||
)}
|
||||
{main.viewerCanDelete && (
|
||||
<IconButton icon="trash" title="Delete" size="small" disabled={busy} onClick={() => void remove()} />
|
||||
)}
|
||||
{main.viewerCanReact && <AddReactionButton state={reactions} />}
|
||||
</>
|
||||
);
|
||||
|
||||
const replyAuthors = [...new Map(replies.filter((r) => r.author).map((r) => [r.author!.login, r.author!])).values()];
|
||||
const last = replies[replies.length - 1];
|
||||
const n = replies.length;
|
||||
|
||||
return (
|
||||
<div className={styles.thread} data-thread={thread.id}>
|
||||
<ChatItem
|
||||
author={main?.author ?? null}
|
||||
date={main?.createdAt}
|
||||
tags={tagsOf(thread, main, true)}
|
||||
actions={actions || undefined}
|
||||
wide
|
||||
>
|
||||
<div className={styles.threadContent}>
|
||||
{!main ? (
|
||||
box
|
||||
) : collapsed ? (
|
||||
<>
|
||||
<div className={styles.preview}>
|
||||
<Markdown text={main.body} />
|
||||
</div>
|
||||
{box}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{box}
|
||||
{editing ? (
|
||||
<CommentEditor
|
||||
initialText={main.body}
|
||||
submitLabel="Save Changes"
|
||||
submitHint={tx`${submitShortcut()} to save changes`}
|
||||
autoFocus
|
||||
onCancel={() => setEditing(false)}
|
||||
onSubmit={async (text) => {
|
||||
await updateReviewComment(number, main.id, text);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ReviewCommentBody number={number} thread={thread} body={main.body} />
|
||||
)}
|
||||
<Reactions state={reactions} canReact={main.viewerCanReact} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(error || resolve.error) && <div className={styles.error}>{error ?? resolve.error}</div>}
|
||||
{repliesFolded && (n > 0 || thread.viewerCanReply || resolve.canChange) && (
|
||||
<div className={styles.folded}>
|
||||
{(n > 0 || thread.viewerCanReply) && (
|
||||
<span className={styles.foldedReplies}>
|
||||
{n > 0 && (
|
||||
<AvatarStack
|
||||
items={replyAuthors.map((a) => ({ key: a.login, url: a.avatarUrl, name: presentableName(a) }))}
|
||||
max={replyAuthors.length}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setRepliesFolded(false);
|
||||
setCollapsedState(false);
|
||||
}}
|
||||
>
|
||||
{n === 0 ? t("Reply") : n === 1 ? tx`${n} reply` : tx`${n} replies`}
|
||||
</button>
|
||||
{last && <span className={styles.secondary}>{prettyDateTime(last.createdAt)}</span>}
|
||||
</span>
|
||||
)}
|
||||
{resolve.canChange && (
|
||||
<button type="button" className={styles.link} disabled={busy} onClick={() => void resolve.toggle()}>
|
||||
{t(resolveText(thread.isResolved))}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ChatItem>
|
||||
{!repliesFolded && (
|
||||
<>
|
||||
{replies.map((c) => (
|
||||
<ReviewComment key={c.id} number={number} thread={thread} comment={c} variant="secondary" />
|
||||
))}
|
||||
<ReplyBox number={number} thread={thread} variant="secondary" resolve={resolve} autoFocus />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactThread({ number, thread }: Omit<ThreadViewProps, "variant">) {
|
||||
const t = useTx();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [replying, setReplying] = useState(false);
|
||||
const resolve = useResolve(number, thread);
|
||||
const comments = thread.comments.nodes;
|
||||
const folded = !expanded && comments.length > 3;
|
||||
const shown = folded ? [comments[0]!, comments[comments.length - 1]!] : comments;
|
||||
const hidden = comments.length - 2;
|
||||
return (
|
||||
<div className={styles.thread} data-thread={thread.id}>
|
||||
{shown.map((c, i) => (
|
||||
<div key={c.id}>
|
||||
<ReviewComment
|
||||
number={number}
|
||||
thread={thread}
|
||||
comment={c}
|
||||
variant="compact"
|
||||
first={c.id === comments[0]?.id}
|
||||
/>
|
||||
{folded && i === 0 && (
|
||||
<div className={styles.moreReplies}>
|
||||
<button type="button" className={styles.link} onClick={() => setExpanded(true)}>
|
||||
… {tx`${hidden} more replies`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{resolve.error && <div className={styles.error}>{resolve.error}</div>}
|
||||
{replying ? (
|
||||
<ReplyBox
|
||||
number={number}
|
||||
thread={thread}
|
||||
variant="compact"
|
||||
resolve={resolve}
|
||||
autoFocus
|
||||
onCancel={() => setReplying(false)}
|
||||
/>
|
||||
) : (
|
||||
(thread.viewerCanReply || resolve.canChange) && (
|
||||
<div className={styles.compactFooter}>
|
||||
{thread.viewerCanReply && (
|
||||
<button type="button" className={styles.link} onClick={() => setReplying(true)}>
|
||||
{t("Reply")}
|
||||
</button>
|
||||
)}
|
||||
{resolve.canChange && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
disabled={resolve.busy}
|
||||
onClick={() => void resolve.toggle()}
|
||||
>
|
||||
{t(resolveText(thread.isResolved))}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* Code review chat items (collaboration-tools' CodeReviewChatItemUIUtil): FULL timeline
|
||||
* items (30px avatar, 14px gap, 10px 16px padding), FULL_SECONDARY replies (20px avatar
|
||||
* under the FULL content) and COMPACT diff inlays (20px avatar, 10px padding).
|
||||
*/
|
||||
/* --- Chat item --------------------------------------------------------- */
|
||||
|
||||
.item {
|
||||
--icon: 30px;
|
||||
--gap: 14px;
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
padding: 10px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.item[data-variant="secondary"] {
|
||||
--icon: 20px;
|
||||
--gap: 10px;
|
||||
padding: 4px 16px 4px 60px;
|
||||
}
|
||||
.item[data-variant="compact"] {
|
||||
--icon: 20px;
|
||||
--gap: 10px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.icon {
|
||||
flex: none;
|
||||
width: var(--icon);
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: var(--gh-content-width, 42em);
|
||||
}
|
||||
.main[data-wide] {
|
||||
max-width: none;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.author {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.author:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.date {
|
||||
color: var(--fg);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--gh-tag-bg);
|
||||
color: var(--gh-tag-fg);
|
||||
font-size: var(--fs-tiny);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.link:disabled {
|
||||
color: var(--fg-faint);
|
||||
cursor: default;
|
||||
}
|
||||
.secondary {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* --- Status line (StatusMessageComponentFactory) ------------------------ */
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.status::before {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--line);
|
||||
content: "";
|
||||
}
|
||||
.status > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.status[data-type="INFO"] {
|
||||
--line: var(--gh-status-info);
|
||||
}
|
||||
.status[data-type="SECONDARY_INFO"] {
|
||||
--line: var(--gh-status-secondary);
|
||||
}
|
||||
.status[data-type="SUCCESS"] {
|
||||
--line: var(--gh-status-success);
|
||||
}
|
||||
.status[data-type="ERROR"],
|
||||
.status[data-type="WARNING"] {
|
||||
--line: var(--gh-status-error);
|
||||
}
|
||||
|
||||
/* --- Comment editor (CodeReviewCommentTextFieldFactory) ----------------- */
|
||||
|
||||
.editorRow {
|
||||
display: flex;
|
||||
gap: var(--gap, 14px);
|
||||
min-width: 0;
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: var(--gh-content-width, 42em);
|
||||
}
|
||||
.field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.field:focus-within {
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
.textarea {
|
||||
flex: 1;
|
||||
min-height: 22px;
|
||||
max-height: 320px;
|
||||
padding: 4px 8px;
|
||||
overflow-y: auto;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
}
|
||||
.editorFooter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mentions {
|
||||
max-height: 240px;
|
||||
min-width: 220px;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mention {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mention[data-active] {
|
||||
background: var(--bg-list-selected);
|
||||
}
|
||||
|
||||
/* --- Reactions ---------------------------------------------------------- */
|
||||
|
||||
.reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 0 8px 0 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: var(--gh-reaction-bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pill:hover:not(:disabled) {
|
||||
background: var(--gh-reaction-bg-hover);
|
||||
}
|
||||
.pill[data-own] {
|
||||
border-color: var(--gh-reaction-border-reacted);
|
||||
}
|
||||
.pill:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.emoji {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
.picker {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
padding: 7px;
|
||||
}
|
||||
.pickerEmoji {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-toolbar-button);
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pickerEmoji:hover,
|
||||
.pickerEmoji[data-own] {
|
||||
background: var(--bg-toolbar-hover);
|
||||
}
|
||||
|
||||
/* --- Threads ------------------------------------------------------------ */
|
||||
|
||||
.thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.threadContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.preview {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--fg-secondary);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.preview * {
|
||||
color: inherit !important;
|
||||
}
|
||||
.diffBox {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gh-bubble-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-editor);
|
||||
}
|
||||
.fileHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 2px 6px 2px 10px;
|
||||
}
|
||||
.fileName {
|
||||
overflow: hidden;
|
||||
color: var(--link);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fileName:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hunk {
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--gh-bubble-border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hunk table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.hunk td {
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
.lineNo {
|
||||
width: 1px;
|
||||
padding: 0 6px !important;
|
||||
color: var(--fg-line-number);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
.code {
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
.hunk tr[data-type="ADD"] {
|
||||
background: var(--diff-inserted);
|
||||
}
|
||||
.hunk tr[data-type="REMOVE"] {
|
||||
background: var(--diff-deleted);
|
||||
}
|
||||
.hunk tr[data-anchor] .code,
|
||||
.hunk tr[data-anchor] .lineNo {
|
||||
background: var(--gh-anchor-line);
|
||||
}
|
||||
.noDiff {
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--gh-bubble-border);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.folded {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.foldedReplies {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.compactFooter {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 4px 10px 6px 40px;
|
||||
}
|
||||
.moreReplies {
|
||||
padding: 2px 10px 2px 40px;
|
||||
}
|
||||
.replyBox {
|
||||
padding: 6px 16px 6px 60px;
|
||||
}
|
||||
.replyBox[data-variant="compact"] {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
/* --- Suggested change (GHPRReviewSuggestedChangeComponentFactory) -------- */
|
||||
|
||||
.suggestion {
|
||||
margin: 4px 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gh-bubble-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.suggestionTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--gh-bubble-border);
|
||||
background: var(--bg-editor);
|
||||
}
|
||||
.suggestionTitle {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.split {
|
||||
display: inline-flex;
|
||||
}
|
||||
.split > :first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.split > :last-child {
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
border-left: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.commitPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 450px;
|
||||
padding: 10px;
|
||||
}
|
||||
.commitPopup textarea {
|
||||
min-height: 90px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
.commitPopup textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Shared code review comment components (collaboration-tools' chat items and the GitHub
|
||||
* plugin's comment / thread factories), used by the timeline and the review diff's inlays:
|
||||
*
|
||||
* - `ThreadView({ number, thread, variant })`: a review thread, `full` in the timeline,
|
||||
* `compact` in diff inlays (first comment, "… n more replies", last; Reply / Resolve).
|
||||
* - `CommentItem(props)`: one comment as a chat item with Edit / Delete / Add reaction.
|
||||
* - `CommentEditor(props)`: the Markdown comment field with @mention completion, hints and
|
||||
* submit / cancel / extra buttons (⌘/Ctrl+Enter submits, Escape cancels).
|
||||
* - `Reactions` / `AddReactionButton` with `useReactionsState(reactions, onToggle)`.
|
||||
* - `ChatItem`, `Tag`, `AuthorLink`: the bare row layout, for new-comment inlays.
|
||||
* - `ReviewCommentBody`, `HunkView`, `DiffBox`: a review comment's body with suggested
|
||||
* changes, a read-only hunk, the rounded file box.
|
||||
*
|
||||
* Every mutation goes through `data/operations.ts`.
|
||||
*/
|
||||
export * from "./ChatItem";
|
||||
export * from "./CommentEditor";
|
||||
export * from "./CommentItem";
|
||||
export * from "./DiffHunk";
|
||||
export * from "./Reactions";
|
||||
export * from "./SuggestedChange";
|
||||
export * from "./ThreadView";
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Applying a suggested change locally (`GHPRReviewCommentBodyViewModel.applySuggestionLocally`
|
||||
* / `commitSuggestion`, `GHSuggestedChangeApplier`): the suggestion becomes a patch of the
|
||||
* local file (`buildSuggestionPatch`) applied with `vcsApplyPatch`; "Commit…" then commits
|
||||
* just the suggestion's lines of that file (a partial commit of its hunks). The thread is
|
||||
* resolved afterwards when the viewer may. Progress shows in the tool window's banner.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useVcs } from "@/features/vcs/store";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { GHPullRequest, GHPullRequestReviewThread } from "../../api/types";
|
||||
import { withBusy } from "../../data/local";
|
||||
import { resolveThread } from "../../data/operations";
|
||||
import { usePullRequestData } from "../../data/pullRequest";
|
||||
import { buildSuggestionPatch, type SuggestionTarget } from "../model";
|
||||
|
||||
async function repositoryRoot(): Promise<string> {
|
||||
const roots = await call(commands.vcsRoots());
|
||||
const root = roots[0]?.root;
|
||||
if (!root) throw new Error("The project is not a Git repository");
|
||||
return String(root).replace(/[\\/]+$/, "");
|
||||
}
|
||||
|
||||
/** Applies `suggestion` to the local file; with `commitMessage`, commits it. Whether it succeeded. */
|
||||
export async function applySuggestion(
|
||||
number: number,
|
||||
thread: Pick<GHPullRequestReviewThread, "id" | "viewerCanResolve">,
|
||||
target: SuggestionTarget,
|
||||
suggestion: readonly string[],
|
||||
commitMessage?: string,
|
||||
): Promise<boolean> {
|
||||
const title = commitMessage ? tx("Committing suggested change") : tx("Applying suggested change");
|
||||
const done = await withBusy(title, async () => {
|
||||
const root = await repositoryRoot();
|
||||
const abs = `${root}/${target.path}`;
|
||||
const text = await call(commands.fsReadText(abs));
|
||||
const { patch, start, lines } = buildSuggestionPatch(text, target, suggestion);
|
||||
const outcome = await call(commands.vcsApplyPatch(null, patch));
|
||||
const files = outcome.conflicts.join(", ");
|
||||
if (files) throw new Error(tx`Conflicts in ${files}`);
|
||||
if (commitMessage) {
|
||||
const head = (await call(commands.vcsFileAt(abs, "HEAD"))) ?? "";
|
||||
const disk = await call(commands.fsReadText(abs));
|
||||
const end = start + Math.max(lines, 1);
|
||||
const hunks = (await commands.vcsDiffHunks(head, disk))
|
||||
.filter((h) => h.start < end && h.start + Math.max(h.lines, 1) > start)
|
||||
.map(({ start, lines, oldStart, oldLines }) => ({ start, lines, oldStart, oldLines }));
|
||||
if (!hunks.length) throw new Error(tx`${target.path}: nothing to commit`);
|
||||
await call(
|
||||
commands.vcsCommit(null, commitMessage, {
|
||||
amend: false,
|
||||
signOff: false,
|
||||
author: null,
|
||||
runHooks: true,
|
||||
partial: [{ path: abs, hunks }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!done) return false;
|
||||
if (thread.viewerCanResolve) await withBusy(tx("Resolving thread"), () => resolveThread(number, thread.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
let branchCache: {
|
||||
at: number;
|
||||
version: number;
|
||||
value: Promise<{ head: string | null; upstream: string | null; commit: string | null }>;
|
||||
} | null = null;
|
||||
|
||||
function branchInfo(version: number) {
|
||||
if (!branchCache || branchCache.version !== version || Date.now() - branchCache.at > 2000) {
|
||||
branchCache = {
|
||||
at: Date.now(),
|
||||
version,
|
||||
value: call(commands.vcsBranchInfo(null)).then(
|
||||
(b) => ({ head: b.head, upstream: b.upstream, commit: b.headCommit }),
|
||||
() => ({ head: null, upstream: null, commit: null }),
|
||||
),
|
||||
};
|
||||
}
|
||||
return branchCache.value;
|
||||
}
|
||||
|
||||
/** Whether the checked-out branch is the pull request's (it tracks its head branch, or is at its head). */
|
||||
export function isReviewBranch(
|
||||
pr: Pick<GHPullRequest, "headRefName" | "headRefOid">,
|
||||
b: { head: string | null; upstream: string | null; commit: string | null },
|
||||
): boolean {
|
||||
if (b.commit && b.commit === pr.headRefOid) return true;
|
||||
if (b.upstream) return b.upstream.slice(b.upstream.indexOf("/") + 1) === pr.headRefName;
|
||||
return b.head === pr.headRefName;
|
||||
}
|
||||
|
||||
/** `isOnReviewBranch`, checked again when the repository changes or the window regains focus; null while unknown. */
|
||||
export function useOnReviewBranch(number: number): boolean | null {
|
||||
const pr = usePullRequestData(number, "details").value;
|
||||
const version = useVcs((s) => s.version);
|
||||
const [value, setValue] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (!pr) return;
|
||||
let alive = true;
|
||||
const check = () => void branchInfo(version).then((b) => alive && setValue(isReviewBranch(pr, b)));
|
||||
check();
|
||||
window.addEventListener("focus", check);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.removeEventListener("focus", check);
|
||||
};
|
||||
}, [pr, version]);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* The timeline's items (`GHPRTimelineItemComponentFactory`, `GHPRTimelineEventComponentFactoryImpl`):
|
||||
* conversation comments, reviews with their status and threads, grouped commits, and the
|
||||
* events with IntelliJ's texts — merged simple events as stacked paragraphs, merged state
|
||||
* events as their last state.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { tx, txTemplate, useTx } from "@/platform/i18n";
|
||||
import type {
|
||||
GHActor,
|
||||
GHLabel,
|
||||
GHPullRequest,
|
||||
GHPullRequestRequestedReviewer,
|
||||
GHPullRequestReviewThread,
|
||||
GHReferencedSubject,
|
||||
} from "../api/types";
|
||||
import { GHOST_USER } from "../data/model";
|
||||
import { openInBrowser } from "../data/local";
|
||||
import { deleteIssueComment, toggleReaction, updateIssueComment, updateReviewBody } from "../data/operations";
|
||||
import { sameRepository, useGhRepository } from "../data/repository";
|
||||
import { openPullRequest } from "../toolwindow/open";
|
||||
import { BranchChip, LabelChip } from "../ui/chips";
|
||||
import { Markdown } from "../ui/Markdown";
|
||||
import { prettyDateTime } from "../ui/prettyDate";
|
||||
import { ChatItem, CommentItem, ThreadView } from "./comments";
|
||||
import { joined, rich } from "./comments/ChatItem";
|
||||
import {
|
||||
canEditReview,
|
||||
commitsActor,
|
||||
effectiveRename,
|
||||
presentableName,
|
||||
reviewStatus,
|
||||
threadReviewId,
|
||||
type ComplexEvent,
|
||||
type SimpleGroup,
|
||||
type StateGroup,
|
||||
type StatusType,
|
||||
type TimelineCommit,
|
||||
type TimelineEntry,
|
||||
} from "./model";
|
||||
import cstyles from "./comments/comments.module.css";
|
||||
import styles from "./Timeline.module.css";
|
||||
|
||||
/** A web link (opens the browser). */
|
||||
export function WebLink({ url, children }: { url: string; children: ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
className={styles.link}
|
||||
href={url}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openInBrowser(url);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** `StatusMessageComponentFactory`: the content with a coloured bar on its left. */
|
||||
export function StatusLine({ type = "INFO", children }: { type?: StatusType; children: ReactNode }) {
|
||||
return (
|
||||
<div className={cstyles.status} data-type={type}>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actorLink = (a: GHActor | GHPullRequestRequestedReviewer | null) => {
|
||||
const r = a ?? GHOST_USER;
|
||||
return <WebLink url={r.url}>{presentableName(r)}</WebLink>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
|
||||
function labelsText(added: GHLabel[], removed: GHLabel[]): ReactNode {
|
||||
const a = added.length ? joined(added.map((l) => <LabelChip key={l.id || l.name} label={l} />)) : null;
|
||||
const r = removed.length ? joined(removed.map((l) => <LabelChip key={l.id || l.name} label={l} />)) : null;
|
||||
if (a && r) return rich(txTemplate("added labels {added} and removed labels {removed}"), { added: a, removed: r });
|
||||
if (a) return rich(txTemplate("added labels {labels}"), { labels: a });
|
||||
if (r) return rich(txTemplate("removed labels {labels}"), { labels: r });
|
||||
return null;
|
||||
}
|
||||
|
||||
function assigneesText(assigned: GHActor[], unassigned: GHActor[]): ReactNode {
|
||||
const a = assigned.length ? joined(assigned.map(actorLink)) : null;
|
||||
const u = unassigned.length ? joined(unassigned.map(actorLink)) : null;
|
||||
if (a && u)
|
||||
return rich(txTemplate("assigned {assigned} and unassigned {unassigned}"), { assigned: a, unassigned: u });
|
||||
if (a) return rich(txTemplate("assigned {users}"), { users: a });
|
||||
if (u) return rich(txTemplate("unassigned {users}"), { users: u });
|
||||
return null;
|
||||
}
|
||||
|
||||
function reviewersText(
|
||||
added: Array<GHPullRequestRequestedReviewer | null>,
|
||||
removed: Array<GHPullRequestRequestedReviewer | null>,
|
||||
): ReactNode {
|
||||
const a = added.length ? joined(added.map(actorLink)) : null;
|
||||
const r = removed.length ? joined(removed.map(actorLink)) : null;
|
||||
if (a && r)
|
||||
return rich(txTemplate("requested a review from {added} and removed review request from {removed}"), {
|
||||
added: a,
|
||||
removed: r,
|
||||
});
|
||||
if (a) return rich(txTemplate("requested a review from {reviewers}"), { reviewers: a });
|
||||
if (r) return rich(txTemplate("removed review request from {reviewers}"), { reviewers: r });
|
||||
return null;
|
||||
}
|
||||
|
||||
const renameText = ([from, to]: [string, string]) =>
|
||||
rich(txTemplate("renamed this pull request from '{from}' to '{to}'"), { from, to });
|
||||
|
||||
/** An event item: the actor's row with the text in a status line. */
|
||||
function EventItem({
|
||||
actor,
|
||||
date,
|
||||
type,
|
||||
children,
|
||||
}: {
|
||||
actor: GHActor | null;
|
||||
date: string | null;
|
||||
type?: StatusType;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ChatItem author={actor ?? GHOST_USER} date={date}>
|
||||
<StatusLine type={type}>{children}</StatusLine>
|
||||
</ChatItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleEventItem({ group }: { group: SimpleGroup }) {
|
||||
const rename = effectiveRename(group);
|
||||
const paragraphs = [
|
||||
labelsText(group.addedLabels, group.removedLabels),
|
||||
assigneesText(group.assigned, group.unassigned),
|
||||
reviewersText(group.addedReviewers, group.removedReviewers),
|
||||
rename && renameText(rename),
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<EventItem actor={group.actor} date={group.createdAt}>
|
||||
{paragraphs.length === 1
|
||||
? paragraphs[0]
|
||||
: paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.paragraph}>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</EventItem>
|
||||
);
|
||||
}
|
||||
|
||||
function StateEventItem({ group }: { group: StateGroup }) {
|
||||
const t = useTx();
|
||||
if (group.newState === "CLOSED")
|
||||
return (
|
||||
<EventItem actor={group.actor} date={group.createdAt} type="SECONDARY_INFO">
|
||||
{t("closed this")}
|
||||
</EventItem>
|
||||
);
|
||||
if (group.newState === "OPEN")
|
||||
return (
|
||||
<EventItem actor={group.actor} date={group.createdAt}>
|
||||
{t("reopened this")}
|
||||
</EventItem>
|
||||
);
|
||||
const merged = group.last.__typename === "MergedEvent" ? group.last : null;
|
||||
const commit = merged?.mergedEventCommit;
|
||||
return (
|
||||
<EventItem actor={group.actor} date={group.createdAt} type="SUCCESS">
|
||||
{commit && merged
|
||||
? rich(txTemplate("merged commit {commit} into {branch}"), {
|
||||
commit: <WebLink url={commit.url}>{commit.abbreviatedOid}</WebLink>,
|
||||
branch: <BranchChip name={merged.mergeRefName} className={styles.branch} />,
|
||||
})
|
||||
: t("merged this")}
|
||||
</EventItem>
|
||||
);
|
||||
}
|
||||
|
||||
/** A referenced issue or pull request: "Title #N", the number opening it (in the IDE when it is of this repository). */
|
||||
function ReferenceLink({ subject }: { subject: GHReferencedSubject }) {
|
||||
const repo = useGhRepository((s) => s.connection?.coordinates ?? null);
|
||||
const local = subject.__typename === "PullRequest" && sameRepository(repo, repoOfUrl(subject.url));
|
||||
return (
|
||||
<div>
|
||||
{subject.title}
|
||||
<a
|
||||
className={styles.link}
|
||||
href={subject.url}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (local) void openPullRequest(subject.number);
|
||||
else void openInBrowser(subject.url);
|
||||
}}
|
||||
>
|
||||
#{subject.number}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function repoOfUrl(url: string) {
|
||||
const m = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\//.exec(url);
|
||||
return m ? { owner: m[1]!, name: m[2]! } : null;
|
||||
}
|
||||
|
||||
function ComplexEventItem({ event }: { event: ComplexEvent }) {
|
||||
const t = useTx();
|
||||
const at = { actor: event.actor, date: event.createdAt };
|
||||
const branch = (name: string) => <BranchChip name={name} className={styles.branch} />;
|
||||
switch (event.__typename) {
|
||||
case "BaseRefChangedEvent":
|
||||
return <EventItem {...at}>{t("changed the base branch")}</EventItem>;
|
||||
case "BaseRefForcePushedEvent":
|
||||
case "HeadRefForcePushedEvent":
|
||||
return (
|
||||
<EventItem {...at}>
|
||||
{rich(txTemplate("force-pushed the {branch} branch"), {
|
||||
branch: event.ref
|
||||
? branch(event.ref.name)
|
||||
: event.__typename === "BaseRefForcePushedEvent"
|
||||
? "base"
|
||||
: "head",
|
||||
})}
|
||||
</EventItem>
|
||||
);
|
||||
case "HeadRefDeletedEvent":
|
||||
return (
|
||||
<EventItem {...at}>
|
||||
{rich(txTemplate("deleted the {branch} branch"), { branch: branch(event.headRefName) })}
|
||||
</EventItem>
|
||||
);
|
||||
case "HeadRefRestoredEvent":
|
||||
return <EventItem {...at}>{t("restored head branch")}</EventItem>;
|
||||
case "ReviewDismissedEvent": {
|
||||
const login = (event.review?.author ?? GHOST_USER).login;
|
||||
return (
|
||||
<EventItem {...at}>
|
||||
{rich(txTemplate("dismissed {login}'s stale review"), { login: <b>{login}</b> })}
|
||||
{event.dismissalMessageHTML && <Markdown text={event.dismissalMessageHTML} />}
|
||||
</EventItem>
|
||||
);
|
||||
}
|
||||
case "ReadyForReviewEvent":
|
||||
return <EventItem {...at}>{t("marked this pull request as ready for review")}</EventItem>;
|
||||
case "ConvertToDraftEvent":
|
||||
return <EventItem {...at}>{t("marked this pull request as draft")}</EventItem>;
|
||||
case "CrossReferencedEvent":
|
||||
case "ConnectedEvent":
|
||||
case "DisconnectedEvent": {
|
||||
const title =
|
||||
event.__typename === "CrossReferencedEvent"
|
||||
? t("mentioned this pull request")
|
||||
: event.__typename === "ConnectedEvent"
|
||||
? t("linked an issue that may be closed by this")
|
||||
: t("removed a link to an issue");
|
||||
const subject = event.__typename === "CrossReferencedEvent" ? event.source : event.subject;
|
||||
return (
|
||||
<ChatItem author={event.actor ?? GHOST_USER} date={event.createdAt}>
|
||||
<div>{title}</div>
|
||||
<StatusLine>
|
||||
<ReferenceLink subject={subject} />
|
||||
</StatusLine>
|
||||
</ChatItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commits, comments, reviews
|
||||
|
||||
function CommitsItem({ commits, prAuthor }: { commits: TimelineCommit[]; prAuthor: GHActor | null }) {
|
||||
const t = useTx();
|
||||
const n = commits.length;
|
||||
const single = n === 1 ? commits[0]!.commit.author : null;
|
||||
return (
|
||||
<ChatItem author={commitsActor(commits, prAuthor) ?? GHOST_USER} date={single?.date ?? null}>
|
||||
<div>{n === 1 ? t("added a commit to this pull request") : tx`added ${n} commits to this pull request`}</div>
|
||||
<StatusLine>
|
||||
<div className={styles.commits}>
|
||||
{commits.map(({ commit }) => {
|
||||
const author = commit.author;
|
||||
const actor = author ? (author.user ?? GHOST_USER) : null;
|
||||
return (
|
||||
<div key={commit.oid} className={styles.commit}>
|
||||
<p className={styles.paragraph}>
|
||||
<WebLink url={commit.url}>{commit.abbreviatedOid}</WebLink> {commit.messageHeadline}
|
||||
</p>
|
||||
{actor && (
|
||||
<p className={styles.paragraph}>
|
||||
{actorLink(actor)}
|
||||
{author?.date && <> {prettyDateTime(author.date)}</>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</StatusLine>
|
||||
</ChatItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewItem({
|
||||
number,
|
||||
review,
|
||||
threads,
|
||||
}: {
|
||||
number: number;
|
||||
review: Extract<TimelineEntry, { kind: "review" }>["review"];
|
||||
threads: GHPullRequestReviewThread[] | undefined;
|
||||
}) {
|
||||
const t = useTx();
|
||||
const status = reviewStatus(review.state);
|
||||
const own = threads?.filter((th) => threadReviewId(th) === review.id);
|
||||
return (
|
||||
<div>
|
||||
<CommentItem
|
||||
author={review.author}
|
||||
createdAt={review.createdAt}
|
||||
body={review.body}
|
||||
canEdit={canEditReview(review)}
|
||||
onSave={(body) => updateReviewBody(number, review.id, body)}
|
||||
footer={<StatusLine type={status.type}>{t(status.text)}</StatusLine>}
|
||||
/>
|
||||
{own === undefined ? (
|
||||
<div className={styles.loadingThreads}>{t("Loading…")}</div>
|
||||
) : (
|
||||
own.map((th) => <ThreadView key={th.id} number={number} thread={th} variant="full" />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One timeline entry. */
|
||||
export function TimelineItem({
|
||||
entry,
|
||||
pr,
|
||||
threads,
|
||||
}: {
|
||||
entry: TimelineEntry;
|
||||
pr: GHPullRequest;
|
||||
threads: GHPullRequestReviewThread[] | undefined;
|
||||
}) {
|
||||
const number = pr.number;
|
||||
switch (entry.kind) {
|
||||
case "comment": {
|
||||
const c = entry.comment;
|
||||
return (
|
||||
<CommentItem
|
||||
author={c.author}
|
||||
createdAt={c.createdAt}
|
||||
body={c.body}
|
||||
reactions={c.reactions.nodes}
|
||||
canEdit={c.viewerCanUpdate}
|
||||
canDelete={c.viewerCanDelete}
|
||||
canReact={c.viewerCanReact}
|
||||
onSave={(body) => updateIssueComment(number, c.id, body)}
|
||||
onDelete={() => deleteIssueComment(number, c.id)}
|
||||
onToggleReaction={(content, add) => toggleReaction(number, c.id, content, add, "timeline")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "review":
|
||||
return <ReviewItem number={number} review={entry.review} threads={threads} />;
|
||||
case "commits":
|
||||
return <CommitsItem commits={entry.commits} prAuthor={pr.author} />;
|
||||
case "simple":
|
||||
return <SimpleEventItem group={entry} />;
|
||||
case "state":
|
||||
return <StateEventItem group={entry} />;
|
||||
case "event":
|
||||
return <ComplexEventItem event={entry.event} />;
|
||||
case "unknown": {
|
||||
const type = entry.type;
|
||||
return (
|
||||
<ChatItem author={pr.author ?? GHOST_USER} date={null}>
|
||||
{tx`Cannot display item - ${type}`}
|
||||
</ChatItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GHActor, GHLabel, GHPRTimelineItem, GHPullRequestReviewThread, GHUser } from "../api/types";
|
||||
import {
|
||||
buildSuggestionPatch,
|
||||
filterMentions,
|
||||
groupReactions,
|
||||
hunkLineNumbers,
|
||||
insertMention,
|
||||
mentionQuery,
|
||||
mergeTimeline,
|
||||
parsePatchHunk,
|
||||
reviewStatus,
|
||||
splitSuggestions,
|
||||
suggestionDisabledReason,
|
||||
suggestionTarget,
|
||||
threadDiffWithAnchor,
|
||||
toggleReactionLocally,
|
||||
truncateHunkBefore,
|
||||
type SimpleGroup,
|
||||
type StateGroup,
|
||||
} from "./model";
|
||||
|
||||
const user = (login: string, name: string | null = null): GHUser => ({
|
||||
__typename: "User",
|
||||
id: `id-${login}`,
|
||||
login,
|
||||
url: `https://github.com/${login}`,
|
||||
avatarUrl: "",
|
||||
name,
|
||||
});
|
||||
const alice = user("alice");
|
||||
const bob = user("bob");
|
||||
const label = (name: string): GHLabel => ({ id: `l-${name}`, url: "", name, color: "ff0000" });
|
||||
const at = (min: number) => new Date(Date.UTC(2024, 0, 1, 12, min)).toISOString();
|
||||
|
||||
const ev = (type: string, actor: GHActor | null, min: number, extra: object = {}) =>
|
||||
({ __typename: type, actor, createdAt: at(min), ...extra }) as GHPRTimelineItem;
|
||||
const commit = (id: string): GHPRTimelineItem => ({
|
||||
__typename: "PullRequestCommit",
|
||||
id,
|
||||
url: "",
|
||||
commit: { id, oid: id, abbreviatedOid: id.slice(0, 7), url: "", messageHeadline: id, author: null },
|
||||
});
|
||||
|
||||
describe("mergeTimeline", () => {
|
||||
it("groups consecutive commits", () => {
|
||||
const out = mergeTimeline([commit("a"), commit("b"), ev("ClosedEvent", alice, 0), commit("c")]);
|
||||
expect(out.map((e) => e.kind)).toEqual(["commits", "state", "commits"]);
|
||||
expect(out[0]).toMatchObject({ commits: [{ id: "a" }, { id: "b" }] });
|
||||
});
|
||||
|
||||
it("merges simple events of one actor within 2 minutes", () => {
|
||||
const out = mergeTimeline([
|
||||
ev("LabeledEvent", alice, 0, { label: label("bug") }),
|
||||
ev("AssignedEvent", alice, 1, { user: bob }),
|
||||
ev("LabeledEvent", alice, 4, { label: label("ui") }),
|
||||
ev("LabeledEvent", bob, 4, { label: label("x") }),
|
||||
]);
|
||||
expect(out).toHaveLength(3);
|
||||
const first = out[0] as SimpleGroup;
|
||||
expect(first.count).toBe(2);
|
||||
expect(first.addedLabels.map((l) => l.name)).toEqual(["bug"]);
|
||||
expect(first.assigned.map((u) => u.login)).toEqual(["bob"]);
|
||||
expect((out[1] as SimpleGroup).addedLabels.map((l) => l.name)).toEqual(["ui"]);
|
||||
});
|
||||
|
||||
it("drops merged events that cancel out", () => {
|
||||
const out = mergeTimeline([
|
||||
ev("LabeledEvent", alice, 0, { label: label("bug") }),
|
||||
ev("UnlabeledEvent", alice, 1, { label: label("bug") }),
|
||||
ev("ClosedEvent", bob, 5),
|
||||
ev("ReopenedEvent", bob, 5),
|
||||
]);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a single event even without changes and tracks renames", () => {
|
||||
const out = mergeTimeline([
|
||||
ev("RenamedTitleEvent", alice, 0, { previousTitle: "a", currentTitle: "b" }),
|
||||
ev("RenamedTitleEvent", alice, 1, { previousTitle: "b", currentTitle: "c" }),
|
||||
]);
|
||||
expect((out[0] as SimpleGroup).rename).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("keeps merged as the state of merged state events", () => {
|
||||
const merged = ev("MergedEvent", alice, 0, { mergedEventCommit: null, mergeRefName: "main" });
|
||||
const out = mergeTimeline([merged, ev("ClosedEvent", alice, 1)]);
|
||||
expect((out[0] as StateGroup).newState).toBe("MERGED");
|
||||
expect((out[0] as StateGroup).last).toBe(merged);
|
||||
});
|
||||
|
||||
it("hides unknown items unless asked", () => {
|
||||
const items: GHPRTimelineItem[] = [{ __typename: "Unknown", type: "PinnedEvent" }];
|
||||
expect(mergeTimeline(items)).toEqual([]);
|
||||
expect(mergeTimeline(items, true)).toMatchObject([{ kind: "unknown", type: "PinnedEvent" }]);
|
||||
});
|
||||
|
||||
it("does not merge across comments", () => {
|
||||
const comment = { __typename: "IssueComment", id: "c1" } as unknown as GHPRTimelineItem;
|
||||
const out = mergeTimeline([
|
||||
ev("AssignedEvent", alice, 0, { user: bob }),
|
||||
comment,
|
||||
ev("AssignedEvent", alice, 0, { user: alice }),
|
||||
]);
|
||||
expect(out.map((e) => e.kind)).toEqual(["simple", "comment", "simple"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewStatus", () => {
|
||||
it("maps states to IntelliJ's texts", () => {
|
||||
expect(reviewStatus("APPROVED")).toEqual({ text: "approved the changes", type: "SUCCESS" });
|
||||
expect(reviewStatus("CHANGES_REQUESTED").type).toBe("ERROR");
|
||||
expect(reviewStatus("PENDING").text).toBe("started a review");
|
||||
expect(reviewStatus("DISMISSED").text).toBe("reviewed the changes");
|
||||
});
|
||||
});
|
||||
|
||||
const HUNK = ["@@ -10,4 +10,5 @@ class A", " a", "-b", "+B", "+B2", " c", " d"].join("\n");
|
||||
|
||||
describe("hunks", () => {
|
||||
it("parses and numbers a hunk", () => {
|
||||
const h = parsePatchHunk(HUNK)!;
|
||||
expect(h.startLineBefore).toBe(9);
|
||||
expect(h.lines.map((l) => l.type)).toEqual(["CONTEXT", "REMOVE", "ADD", "ADD", "CONTEXT", "CONTEXT"]);
|
||||
expect(hunkLineNumbers(h)).toEqual([
|
||||
[10, 10],
|
||||
[11, null],
|
||||
[null, 11],
|
||||
[null, 12],
|
||||
[12, 13],
|
||||
[13, 14],
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates before an index", () => {
|
||||
const h = truncateHunkBefore(parsePatchHunk(HUNK)!, 2);
|
||||
expect(h.startLineBefore).toBe(11);
|
||||
expect(h.startLineAfter).toBe(10);
|
||||
expect(h.lines).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps the anchor and 3 lines of context before it", () => {
|
||||
const d = threadDiffWithAnchor({
|
||||
diffHunk: HUNK,
|
||||
diffSide: "RIGHT",
|
||||
originalLine: 14,
|
||||
startDiffSide: null,
|
||||
originalStartLine: null,
|
||||
})!;
|
||||
expect(d.hunk.lines).toHaveLength(4);
|
||||
expect(d.anchor).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it("anchors a multi-line range", () => {
|
||||
const d = threadDiffWithAnchor({
|
||||
diffHunk: HUNK,
|
||||
diffSide: "RIGHT",
|
||||
originalLine: 14,
|
||||
startDiffSide: "RIGHT",
|
||||
originalStartLine: 13,
|
||||
})!;
|
||||
expect(d.anchor).toEqual([3, 5]);
|
||||
expect(d.hunk.lines).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("has no diff for an empty hunk", () => {
|
||||
expect(
|
||||
threadDiffWithAnchor({
|
||||
diffHunk: "",
|
||||
diffSide: "RIGHT",
|
||||
originalLine: 1,
|
||||
startDiffSide: null,
|
||||
originalStartLine: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestions", () => {
|
||||
const thread = {
|
||||
isResolved: false,
|
||||
path: "src/a.ts",
|
||||
line: 12,
|
||||
startLine: 11,
|
||||
originalLine: 12,
|
||||
originalStartLine: 11,
|
||||
diffHunk: HUNK,
|
||||
} satisfies Partial<GHPullRequestReviewThread> & { diffHunk: string };
|
||||
|
||||
it("splits a body into Markdown and suggestion blocks", () => {
|
||||
expect(splitSuggestions("Use this:\n```suggestion\nB\nC\n```\nthanks")).toEqual([
|
||||
{ type: "markdown", text: "Use this:" },
|
||||
{ type: "suggestion", lines: ["B", "C"] },
|
||||
{ type: "markdown", text: "thanks" },
|
||||
]);
|
||||
expect(splitSuggestions("```suggestion\nx")).toEqual([{ type: "markdown", text: "```suggestion\nx" }]);
|
||||
});
|
||||
|
||||
it("finds the commented code and the applicability", () => {
|
||||
const t = suggestionTarget(thread);
|
||||
expect(t).toMatchObject({ applicability: "APPLICABLE", startLineIndex: 10, endLineIndex: 11 });
|
||||
expect(t.originalLines).toEqual(["B", "B2"]);
|
||||
expect(suggestionTarget({ ...thread, line: null }).applicability).toBe("OUTDATED");
|
||||
expect(suggestionTarget({ ...thread, isResolved: true }).applicability).toBe("RESOLVED");
|
||||
expect(suggestionDisabledReason("APPLICABLE", false)).toMatch(/different branch/);
|
||||
expect(suggestionDisabledReason("APPLICABLE", true)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a patch with context against the local file", () => {
|
||||
const text = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "B", "B2", "c", "d"].join("\n") + "\n";
|
||||
const r = buildSuggestionPatch(text, suggestionTarget(thread), ["X"]);
|
||||
expect(r.start).toBe(10);
|
||||
expect(r.patch).toBe(
|
||||
["--- a/src/a.ts", "+++ b/src/a.ts", "@@ -8,7 +8,6 @@", " 8", " 9", " a", "-B", "-B2", "+X", " c", " d", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds moved code and fails when it is gone", () => {
|
||||
const moved = ["0", "a", "B", "B2", "c"].join("\n");
|
||||
const r = buildSuggestionPatch(moved, suggestionTarget(thread), ["X"]);
|
||||
expect(r.start).toBe(2);
|
||||
expect(r.patch).toContain("\\ No newline at end of file");
|
||||
expect(() => buildSuggestionPatch("nothing\n", suggestionTarget(thread), ["X"])).toThrow(/does not apply/);
|
||||
});
|
||||
|
||||
it("keeps CRLF line ends", () => {
|
||||
const r = buildSuggestionPatch("a\r\nB\r\nB2\r\n", suggestionTarget(thread), ["X"]);
|
||||
expect(r.patch).toContain("-B\r\n");
|
||||
expect(r.patch).toContain("+X\r\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactions", () => {
|
||||
it("groups by content and marks the viewer's", () => {
|
||||
const rs = [
|
||||
{ content: "HEART" as const, user: alice },
|
||||
{ content: "EYES" as const, user: bob },
|
||||
{ content: "HEART" as const, user: bob },
|
||||
];
|
||||
const g = groupReactions(rs, bob.id);
|
||||
expect(g.map((r) => [r.content, r.users.length, r.isReactedByCurrentUser])).toEqual([
|
||||
["HEART", 2, true],
|
||||
["EYES", 1, true],
|
||||
]);
|
||||
expect(groupReactions(toggleReactionLocally(rs, "HEART", bob, false), bob.id)[0]!.users).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mentions", () => {
|
||||
it("finds the query before the caret", () => {
|
||||
expect(mentionQuery("hi @al", 6)).toEqual({ start: 3, query: "al" });
|
||||
expect(mentionQuery("@", 1)).toEqual({ start: 0, query: "" });
|
||||
expect(mentionQuery("mail@x", 6)).toBeNull();
|
||||
expect(mentionQuery("hi @al ", 7)).toBeNull();
|
||||
});
|
||||
|
||||
it("filters and inserts", () => {
|
||||
const users = [user("bob", "Alice Bobson"), user("alice"), user("malik")];
|
||||
expect(filterMentions(users, "al").map((u) => u.login)).toEqual(["alice", "bob", "malik"]);
|
||||
expect(insertMention("hi @al!", 3, 6, "alice")).toEqual({ text: "hi @alice !", caret: 10 });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user