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:
imacross
2026-10-31 07:50:43 +08:00
committed by GitHub
parent 696377acc2
commit 059fd8a69b
192 changed files with 23668 additions and 2076 deletions
+48 -1
View File
@@ -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")]
+24 -2
View File
@@ -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(),
})
}
+22 -1
View File
@@ -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
View File
@@ -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"}
+32
View File
@@ -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());
}
+16
View File
@@ -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
});
+9 -1
View File
@@ -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]
+2
View File
@@ -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,
+13
View File
@@ -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(
+1 -1
View File
@@ -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": {
+27
View File
@@ -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,
+27
View File
@@ -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 ----------------------------------
/**
+2 -5
View File
@@ -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",
-371
View File
@@ -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);
}
-609
View File
@@ -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>
);
}
+112
View File
@@ -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" });
});
});
+104
View File
@@ -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 };
}
+78
View File
@@ -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;
}
+505
View File
@@ -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
}
`,
};
+852
View File
@@ -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
}
}
}
`;
+619
View File
@@ -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 });
}
+499
View File
@@ -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;
}
+258
View File
@@ -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);
}
+263
View File
@@ -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>
);
}
+154
View File
@@ -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>
);
}
+198
View File
@@ -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]);
});
});
+216
View File
@@ -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() ?? "";
}
+446
View File
@@ -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();
});
+78
View File
@@ -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,
};
}
+470
View File
@@ -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();
});
});
+86
View File
@@ -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) });
});
+159
View File
@@ -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();
});
+192
View File
@@ -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);
}
+444
View File
@@ -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
);
}
+400
View File
@@ -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;
}
+352
View File
@@ -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();
});
+342
View File
@@ -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;
}
+69
View File
@@ -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>
);
}
+280
View File
@@ -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();
});
});
+162
View File
@@ -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);
}
+347
View File
@@ -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");
});
});
+409
View File
@@ -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";
}
+79
View File
@@ -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 } }));
}
-244
View File
@@ -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>
);
}
+81
View File
@@ -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)}
/>
</>
);
}
+99
View File
@@ -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());
}
+226
View File
@@ -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();
});
});
+317
View File
@@ -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;
}
+133
View File
@@ -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([]);
});
});
+486
View File
@@ -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;
}
+56
View File
@@ -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 */
}
}
-112
View File
@@ -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
View File
@@ -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());
}
+77
View File
@@ -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>
</>
);
}
+265
View File
@@ -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;
}
+159
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
});
+389
View File
@@ -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>
);
}
+330
View File
@@ -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");
});
});
+209
View File
@@ -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;
}
-179
View File
@@ -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),
};
}
-180
View File
@@ -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>
);
}
+558
View File
@@ -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))];
}
+157
View File
@@ -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);
};
}
+248
View File
@@ -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" });
}
+165
View File
@@ -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 });
};
}
-219
View File
@@ -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}>&nbsp;{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;
}
+387
View File
@@ -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}&nbsp;
<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>&nbsp;{commit.messageHeadline}
</p>
{actor && (
<p className={styles.paragraph}>
{actorLink(actor)}
{author?.date && <>&nbsp;{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>
);
}
}
}
+264
View File
@@ -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