From cae8b2d55b78e53d764f2762848e055ca3ba5459 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 28 Jul 2026 17:37:07 -0500 Subject: [PATCH 01/17] Store resolved commit SHAs separately from branch names and PR numbers Separate accepted review inputs from loaded sources and make commit, index, and working-copy revisions explicit, including nullable absent sides and conflict stages. Share source parsing, provider URL canonicalization, and logical/exact identity formatting across Core and Electron, while selecting generated-file attributes from the effective revision. --- core/App.tsx | 20 +- core/README.md | 11 + core/__tests__/App-render.test.tsx | 2 + core/__tests__/ReviewCodeView-scroll.test.tsx | 3 + core/__tests__/codiff-share-cli.test.ts | 2 +- core/__tests__/git-state.test.ts | 70 +++--- .../narrative-walkthrough-view.test.ts | 5 +- core/__tests__/reload-selection.test.ts | 24 +- core/__tests__/review-command-target.test.ts | 7 +- core/__tests__/review-history.test.ts | 23 ++ core/__tests__/review-source-codec.test.ts | 92 ++++++++ core/__tests__/useAppWalkthrough.test.tsx | 15 +- core/app/components/ReviewCodeView.tsx | 9 +- core/app/components/Sidebar.tsx | 30 ++- .../app/components/walkthrough/CommitView.tsx | 2 +- core/global.d.ts | 3 +- core/index.ts | 5 + core/lib/reload-selection.ts | 61 ++--- core/lib/review-command-target.ts | 6 +- core/lib/review-history.ts | 15 ++ core/lib/review-source-codec.cjs | 218 ++++++++++++++++++ core/lib/review-source-codec.ts | 21 ++ core/lib/source.ts | 71 +++--- core/tsconfig.build.json | 1 + core/types.ts | 95 ++++++-- .../__tests__/narrative-walkthrough.test.ts | 2 +- electron/__tests__/walkthrough-commit.test.ts | 2 +- electron/__tests__/window-identity.test.ts | 18 +- electron/generated-files.cjs | 37 ++- electron/git-state.cjs | 33 ++- electron/git-state/commit-metadata.cjs | 17 +- electron/git-state/commit.cjs | 121 +++++----- electron/git-state/comparison.cjs | 95 ++++---- electron/git-state/pull-request.cjs | 24 +- electron/walkthrough-commit.cjs | 3 +- electron/window-identity.cjs | 120 ++++------ vite.config.ts | 1 + 37 files changed, 870 insertions(+), 414 deletions(-) create mode 100644 core/__tests__/review-history.test.ts create mode 100644 core/__tests__/review-source-codec.test.ts create mode 100644 core/lib/review-history.ts create mode 100644 core/lib/review-source-codec.cjs create mode 100644 core/lib/review-source-codec.ts diff --git a/core/App.tsx b/core/App.tsx index 6e2fa347..b767ac42 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -170,11 +170,11 @@ const getReloadSourceForLaunch = ( } if (!launchOptions.source) { - return reloadSelection.source; + return getRefreshSource(reloadSelection.source); } return getSourceKey(reloadSelection.source) === getSourceKey(launchOptions.source) - ? reloadSelection.source + ? getRefreshSource(reloadSelection.source) : undefined; }; @@ -513,7 +513,9 @@ export default function App() { const sourceKey = getSourceKey(currentState.source); try { - const nextState = await window.codiff.getRepositoryState(currentState.source); + const nextState = await window.codiff.getRepositoryState( + getRefreshSource(currentState.source), + ); const orderedState = { ...nextState, files: sortFiles(nextState.files), @@ -1047,7 +1049,7 @@ export default function App() { setLoadingSectionIds(new Set()); window.codiff - .getRepositoryState(currentState.source) + .getRepositoryState(getRefreshSource(currentState.source)) .then((nextState) => { if (sourceRequestRef.current !== request) { return; @@ -1693,7 +1695,7 @@ export default function App() { state.source.type === 'commit' && state.commitMetadata ? (() => { const historyAvatarUrl = historyEntries.find( - (entry) => entry.ref === state.commitMetadata?.ref, + (entry) => entry.sha === state.commitMetadata?.sha, )?.gravatarUrl; return historyAvatarUrl ? { @@ -1919,11 +1921,11 @@ export default function App() { historySource?.type === 'branch-diff' ? historySource : historySource?.type === 'branch-working-tree' && - historySource.baseRef && - historySource.headRef + historySource.baseSha && + historySource.headSha ? { - baseRef: historySource.baseRef, - headRef: historySource.headRef, + baseSha: historySource.baseSha, + headSha: historySource.headSha, ref: historySource.ref, type: 'branch-diff', } diff --git a/core/README.md b/core/README.md index 78f0152a..461c15a9 100644 --- a/core/README.md +++ b/core/README.md @@ -13,3 +13,14 @@ export function Review({ snapshot }: { snapshot: SharedWalkthroughSnapshot }) { return ; } ``` + +## Review identity + +`codiff main` is a selector. After Git resolves it, the stored source keeps +the exact 40-character commit SHA that was read, not the string `main`. A +GitHub PR or GitLab MR number is also not a commit: the same PR can point at +a new head tomorrow. + +`GitSha` is only a full object id. Branch names, tags, bookmarks, and PR/MR +numbers stay ordinary strings. A `Revision` carries a SHA only when it is a +commit. The working copy and index have no SHA. diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx index c1894bc8..79468e05 100644 --- a/core/__tests__/App-render.test.tsx +++ b/core/__tests__/App-render.test.tsx @@ -48,6 +48,8 @@ class StubWorker extends EventTarget { } reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; +const gitSha = (value: string) => value as GitSha; + beforeEach(() => { window.localStorage.clear(); window.sessionStorage.clear(); diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index 295216ac..23ecb3b5 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -14,6 +14,7 @@ import { import type { ChangedFile, DefinitionSearchResult, + GitSha, PullRequestCodeQualityFinding, ReviewSource, } from '../types.ts'; @@ -26,6 +27,8 @@ import { type ReviewDiffBlock, } from './helpers/review-code-view.tsx'; +const gitSha = (value: string) => value as GitSha; + const markdownEditorMock = vi.hoisted(() => ({ flush: vi.fn<() => Promise>(async () => true), heightByAriaLabel: new Map(), diff --git a/core/__tests__/codiff-share-cli.test.ts b/core/__tests__/codiff-share-cli.test.ts index 39bda88d..9f2af8f5 100644 --- a/core/__tests__/codiff-share-cli.test.ts +++ b/core/__tests__/codiff-share-cli.test.ts @@ -451,7 +451,7 @@ exit 1 expect(stdout).toBe(`${origin}/w/generated-walkthrough\n`); expect(body.snapshot.repository).toMatchObject({ root: await realpath(repositoryPath), - source: { ref: head, type: 'commit' }, + source: { sha: head, type: 'commit' }, title: 'Update example', }); expect(body.snapshot.walkthrough).toMatchObject({ diff --git a/core/__tests__/git-state.test.ts b/core/__tests__/git-state.test.ts index ab7bcdb2..e94b0f4b 100644 --- a/core/__tests__/git-state.test.ts +++ b/core/__tests__/git-state.test.ts @@ -9,6 +9,7 @@ import { fileHasVisibleDiff, getDiffLineCount } from '../lib/diff.ts'; import type { DiffSection, DiffSectionContentRequest, + GitSha, RepositoryState, ReviewSource, } from '../types.ts'; @@ -140,6 +141,7 @@ type GitStateModule = { }; const execFileAsync = promisify(execFile); +const gitSha = (value: string) => value as GitSha; const require = createRequire(import.meta.url); const { readGeneratedAttributeStates } = require('../../electron/generated-files.cjs') as GeneratedFilesModule; @@ -979,9 +981,9 @@ test('normalizeGitHubPullRequestCommit reads GitHub PR commit metadata', () => { author: 'PR Author', committedAt: Date.parse('2026-05-22T12:34:56Z'), gravatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', - parents: ['parent-sha'], - ref: 'commit-sha', + parentShas: ['parent-sha'], scope: 'pull-request', + sha: 'commit-sha', subject: 'Feature commit', }); }); @@ -1139,27 +1141,31 @@ test('readRepositoryState and history handle fresh repositories', async () => { }); }); -test('readWalkthroughRepositoryState falls back to HEAD only for a clean implicit source', () => - withRepo(async (repo) => { - await writeRepoFile(repo, 'example.txt', 'before\n'); - await commitAll(repo, 'initial commit'); - await writeRepoFile(repo, 'example.txt', 'after\n'); - await commitAll(repo, 'update example'); - - const cleanState = await readWalkthroughRepositoryState(repo); - expect(cleanState.source).toMatchObject({ type: 'commit' }); - expect(cleanState.commitMetadata?.subject).toBe('update example'); - - const explicitWorkingTreeState = await readWalkthroughRepositoryState(repo, { - type: 'working-tree', - }); - expect(explicitWorkingTreeState.source).toEqual({ type: 'working-tree' }); - expect(explicitWorkingTreeState.files).toEqual([]); - - await writeRepoFile(repo, 'example.txt', 'local\n'); - const dirtyState = await readWalkthroughRepositoryState(repo); - expect(dirtyState.source).toEqual({ type: 'working-tree' }); - })); +test( + 'readWalkthroughRepositoryState falls back to HEAD only for a clean implicit source', + () => + withRepo(async (repo) => { + await writeRepoFile(repo, 'example.txt', 'before\n'); + await commitAll(repo, 'initial commit'); + await writeRepoFile(repo, 'example.txt', 'after\n'); + await commitAll(repo, 'update example'); + + const cleanState = await readWalkthroughRepositoryState(repo); + expect(cleanState.source).toMatchObject({ type: 'commit' }); + expect(cleanState.commitMetadata?.subject).toBe('update example'); + + const explicitWorkingTreeState = await readWalkthroughRepositoryState(repo, { + type: 'working-tree', + }); + expect(explicitWorkingTreeState.source).toEqual({ type: 'working-tree' }); + expect(explicitWorkingTreeState.files).toEqual([]); + + await writeRepoFile(repo, 'example.txt', 'local\n'); + const dirtyState = await readWalkthroughRepositoryState(repo); + expect(dirtyState.source).toEqual({ type: 'working-tree' }); + }), + 15_000, +); test('readWalkthroughRepositoryState keeps a fresh repository on the working tree', () => withRepo(async (repo) => { @@ -1213,9 +1219,9 @@ test('readRepositoryState reports commit metadata for root commits', async () => if (!metadata) { throw new Error('Expected commit metadata.'); } - expect(metadata.ref).toBe(commit); + expect(metadata.sha).toBe(commit); expect(metadata.subject).toBe('initial commit'); - expect(metadata.parents).toEqual([]); + expect(metadata.parentShas).toEqual([]); expect(metadata.stats).toEqual({ additions: 2, binaryFiles: 0, @@ -1261,7 +1267,7 @@ test('readRepositoryState reports commit body, trailers, refs, and rename stats' if (!metadata) { throw new Error('Expected commit metadata.'); } - expect(metadata.ref).toBe(commit); + expect(metadata.sha).toBe(commit); expect(metadata.body).toBe('Detailed comment.'); expect(metadata.body).not.toContain('Co-authored-by'); expect(metadata.trailers).toEqual([ @@ -1346,8 +1352,8 @@ test('readRepositoryState opens branch refs as current branch diffs against the expect(source.ref).toBe(baseBranch); expect(source.type).toBe('branch-diff'); - expect(source.baseRef).toBeTruthy(); - expect(source.headRef).toBeTruthy(); + expect(source.baseSha).toBeTruthy(); + expect(source.headSha).toBeTruthy(); expect(state.files.map((file) => file.path)).toEqual(['file.txt']); expect(state.files[0].sections[0].oldFile?.contents).toBe('base\n'); expect(state.files[0].sections[0].newFile?.contents).toBe('feature two\n'); @@ -1376,7 +1382,7 @@ test('readRepositoryState opens branch refs as current branch diffs against the 'feature one', ]); }); -}); +}, 15_000); test('readRepositoryState reports missing branch refs clearly', async () => { await withRepo(async (repo) => { @@ -1789,7 +1795,7 @@ test('readRepositoryState reads commit diffs from short hashes', async () => { }); expect(state.source).toEqual({ - ref: commit, + sha: commit, type: 'commit', }); expect(state.files.map((file) => file.path).sort()).toEqual(['file.txt', 'new.txt']); @@ -1904,7 +1910,7 @@ test('readRepositoryState defers medium committed files and loads them on demand kind: 'commit', path: 'large.txt', source: { - ref: commit, + sha: gitSha(commit), type: 'commit', }, }); @@ -1913,7 +1919,7 @@ test('readRepositoryState defers medium committed files and loads them on demand expect(loadedSection.newFile?.contents).toBe(contents); expect(loadedSection.patch).toContain('+large committed line'); }); -}); +}, 15_000); test('readRepositoryState rejects non-repository launch paths', async () => { await using directory = await createTemporaryDirectory('codiff-not-a-repo-'); diff --git a/core/__tests__/narrative-walkthrough-view.test.ts b/core/__tests__/narrative-walkthrough-view.test.ts index 43ec1715..cd1d6144 100644 --- a/core/__tests__/narrative-walkthrough-view.test.ts +++ b/core/__tests__/narrative-walkthrough-view.test.ts @@ -24,11 +24,14 @@ import { } from '../lib/narrative-walkthrough.ts'; import type { ChangedFile, + GitSha, NarrativeWalkthrough, WalkthroughHunk, WalkthroughHunkGroup, } from '../types.ts'; +const gitSha = (value: string) => value as GitSha; + const hunk = ({ added, additionEnd, @@ -654,7 +657,7 @@ test('working-tree walkthroughs are committable even without commit seed text', const committedReview: NarrativeWalkthrough = { ...walkthrough(), commit: {}, - source: { ref: 'HEAD', type: 'commit' }, + source: { sha: gitSha('HEAD'), type: 'commit' }, }; expect(isWalkthroughCommittable(wt)).toBe(true); diff --git a/core/__tests__/reload-selection.test.ts b/core/__tests__/reload-selection.test.ts index e95d63ac..cd0528ef 100644 --- a/core/__tests__/reload-selection.test.ts +++ b/core/__tests__/reload-selection.test.ts @@ -14,7 +14,15 @@ import { haveReloadedFilesChanged, writeReloadSelection, } from '../lib/reload-selection.ts'; -import type { ChangedFile, GitFileStatus, RepositoryState, ReviewSource } from '../types.ts'; +import type { + ChangedFile, + GitFileStatus, + GitSha, + RepositoryState, + ReviewSource, +} from '../types.ts'; + +const gitSha = (value: string) => value as GitSha; beforeEach(() => { window.sessionStorage.clear(); @@ -56,8 +64,8 @@ test('reload selection preserves the branch diff source without a selected file' const currentState = { ...state([]), source: { - baseRef: 'base123', - headRef: 'head123', + baseSha: gitSha('base123'), + headSha: gitSha('head123'), ref: 'main', type: 'branch-diff', }, @@ -72,8 +80,8 @@ test('reload selection preserves the branch diff source without a selected file' test('reload selection preserves history source for the current source', () => { const branchSource = { - baseRef: 'base123', - headRef: 'head123', + baseSha: gitSha('base123'), + headSha: gitSha('head123'), ref: 'main', type: 'branch-diff', } satisfies ReviewSource; @@ -87,7 +95,7 @@ test('reload selection preserves history source for the current source', () => { expect( getReloadHistorySource(selection, { ...currentState, - source: { ref: 'abc1234', type: 'commit' }, + source: { sha: gitSha('abc1234'), type: 'commit' }, }), ).toBeNull(); }); @@ -103,7 +111,7 @@ test('reload selection preserves the commit view for the current source', () => expect( getReloadMainMode(selection, { ...currentState, - source: { ref: 'abc1234', type: 'commit' }, + source: { sha: gitSha('abc1234'), type: 'commit' }, }), ).toBeNull(); }); @@ -163,7 +171,7 @@ test('reload selection is ignored when it belongs to another repository source', const workingTreeState = state([changedFile]); const commitState = { ...workingTreeState, - source: { ref: 'abc1234', type: 'commit' }, + source: { sha: gitSha('abc1234'), type: 'commit' }, } satisfies RepositoryState; writeReloadSelection(workingTreeState, changedFile.path); diff --git a/core/__tests__/review-command-target.test.ts b/core/__tests__/review-command-target.test.ts index 7664424e..3a38c719 100644 --- a/core/__tests__/review-command-target.test.ts +++ b/core/__tests__/review-command-target.test.ts @@ -3,7 +3,7 @@ import { createReviewCommandTarget, resolveReviewCommandTarget, } from '../lib/review-command-target.ts'; -import type { ChangedFile, ReviewSource } from '../types.ts'; +import type { ChangedFile, GitSha, ResolvedReviewSource, ReviewSource } from '../types.ts'; const file = (path: string): ChangedFile => ({ fingerprint: `${path}:1`, @@ -58,7 +58,10 @@ test('review command target falls back to selected path outside active walkthrou test('review command target ignores stale active target from another source', () => { const selectedFile = file('src/first.ts'); - const currentSource = { ref: 'HEAD', type: 'commit' } satisfies ReviewSource; + const currentSource = { + sha: 'HEAD' as GitSha, + type: 'commit', + } satisfies ResolvedReviewSource; const staleSource = { type: 'working-tree' } satisfies ReviewSource; const target = resolveReviewCommandTarget({ diff --git a/core/__tests__/review-history.test.ts b/core/__tests__/review-history.test.ts new file mode 100644 index 00000000..55ab4647 --- /dev/null +++ b/core/__tests__/review-history.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'vite-plus/test'; +import { diffRange, shaForRevision } from '../lib/review-history.ts'; +import type { GitSha, Revision } from '../types.ts'; + +const gitSha = (value: string) => value as GitSha; + +test('keeps revision SHA identity separate from labels and non-commit markers', () => { + const base: Revision = { + label: { kind: 'commit', text: 'base' }, + sha: gitSha('a'.repeat(40)), + }; + const head: Revision = { + label: { kind: 'commit', text: 'head' }, + sha: gitSha('b'.repeat(40)), + }; + const range = diffRange(base, head); + + expect(shaForRevision(base)).toBe(base.sha); + expect(range.head.label.text).toBe('head'); + expect(() => + shaForRevision({ kind: 'index', label: { kind: 'review-marker', text: 'Index' } }), + ).toThrow('Expected a commit revision'); +}); diff --git a/core/__tests__/review-source-codec.test.ts b/core/__tests__/review-source-codec.test.ts new file mode 100644 index 00000000..e935f8f2 --- /dev/null +++ b/core/__tests__/review-source-codec.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from 'vite-plus/test'; +import { + decodeResolvedReviewSource, + decodeReviewSource, + formatResolvedSourceIdentity, + formatReviewSourceIdentity, +} from '../lib/review-source-codec.ts'; + +test('decodes and formats every local review source kind', () => { + const values = [ + { type: 'working-tree' }, + { ref: 'HEAD~2', type: 'commit' }, + { ref: 'main', type: 'branch' }, + { baseSha: 'A'.repeat(40), headSha: 'B'.repeat(40), ref: 'main', type: 'branch-diff' }, + { + baseSha: 'A'.repeat(40), + headSha: 'B'.repeat(40), + ref: 'main', + type: 'branch-working-tree', + }, + { base: 'main', head: 'feature', symmetric: true, type: 'range' }, + ] as const; + + expect(values.map((value) => formatReviewSourceIdentity(decodeReviewSource(value)!))).toEqual([ + 'working-tree', + 'commit:HEAD~2', + 'branch:main', + `branch-diff:main:${'a'.repeat(40)}:${'b'.repeat(40)}`, + `branch-working-tree:main:${'a'.repeat(40)}:${'b'.repeat(40)}`, + 'range:main...feature', + ]); +}); + +test('distinguishes unresolved and resolved revision identities', () => { + const unresolvedCommit = decodeReviewSource({ ref: 'HEAD', type: 'commit' })!; + const resolvedCommit = decodeResolvedReviewSource({ sha: 'A'.repeat(40), type: 'commit' })!; + const unresolvedBranchWorkingTree = decodeReviewSource({ + ref: 'main', + type: 'branch-working-tree', + })!; + + expect(formatReviewSourceIdentity(unresolvedCommit)).toBe('commit:HEAD'); + expect(formatResolvedSourceIdentity(resolvedCommit)).toBe(`commit:${'a'.repeat(40)}`); + expect(formatReviewSourceIdentity(unresolvedBranchWorkingTree)).toBe( + 'branch-working-tree:main:unresolved', + ); + expect( + decodeReviewSource({ baseSha: 'a'.repeat(40), ref: 'main', type: 'branch-working-tree' }), + ).toBeNull(); +}); + +test('canonicalizes GitHub and GitLab review URLs and changes exact identity at a new head', () => { + const github = decodeReviewSource({ + type: 'pull-request', + url: 'https://GitHub.com/NKZW-Tech/Codiff.git/pull/8/changes#r42', + })!; + const gitlab = decodeReviewSource({ + type: 'pull-request', + url: 'https://gitlab.example.com/Group/Subgroup/Project.git/-/merge_requests/9/diffs', + })!; + const firstHead = decodeResolvedReviewSource({ ...github, headSha: 'A'.repeat(40) })!; + const secondHead = decodeResolvedReviewSource({ ...github, headSha: 'B'.repeat(40) })!; + + expect(github).toMatchObject({ + number: 8, + owner: 'NKZW-Tech', + projectPath: 'NKZW-Tech/Codiff', + provider: 'github', + repo: 'Codiff', + url: 'https://github.com/NKZW-Tech/Codiff/pull/8', + }); + expect(formatReviewSourceIdentity(github)).toBe( + 'pull-request:github:github.com:nkzw-tech/codiff#8', + ); + expect(formatReviewSourceIdentity(gitlab)).toBe( + 'pull-request:gitlab:gitlab.example.com:group/subgroup/project#9', + ); + expect(formatReviewSourceIdentity(firstHead)).toBe(formatReviewSourceIdentity(secondHead)); + expect(formatResolvedSourceIdentity(firstHead)).not.toBe( + formatResolvedSourceIdentity(secondHead), + ); +}); + +test('rejects malformed source coordinates', () => { + expect(decodeReviewSource(null)).toBeNull(); + expect(decodeReviewSource({ type: 'commit' })).toBeNull(); + expect(decodeReviewSource({ base: 'main', head: 'feature', type: 'range' })).toBeNull(); + expect( + decodeReviewSource({ type: 'pull-request', url: 'https://example.com/review/1' }), + ).toBeNull(); + expect(decodeResolvedReviewSource({ ref: 'main', type: 'branch' })).toBeNull(); +}); diff --git a/core/__tests__/useAppWalkthrough.test.tsx b/core/__tests__/useAppWalkthrough.test.tsx index 736e299a..c9c66c66 100644 --- a/core/__tests__/useAppWalkthrough.test.tsx +++ b/core/__tests__/useAppWalkthrough.test.tsx @@ -6,10 +6,17 @@ import { act } from 'react'; import { expect, test, vi } from 'vite-plus/test'; import { useAppWalkthrough } from '../app/hooks/useAppWalkthrough.ts'; import { createDefaultConfig } from '../config/defaults.ts'; -import type { NarrativeWalkthrough, RepositoryState, WalkthroughProgressEvent } from '../types.ts'; +import type { + GitSha, + NarrativeWalkthrough, + RepositoryState, + WalkthroughProgressEvent, +} from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { renderReact, waitFor } from './helpers/react.tsx'; +const gitSha = (value: string) => value as GitSha; + type AppWalkthroughController = ReturnType; const walkthrough: NarrativeWalkthrough = { @@ -153,7 +160,7 @@ test('walkthrough controller lazily generates, refreshes, and transitions modes' test('walkthrough controller routes progress, commit APIs, and sharing through current state', async () => { let onProgress: ((progress: WalkthroughProgressEvent) => void) | null = null; const createWalkthroughCommit = vi.fn(async () => ({ - hash: 'abc123', + sha: gitSha('abc123'), status: 'committed' as const, })); const updateWalkthroughCommitMessage = vi.fn(async () => ({ @@ -191,13 +198,13 @@ test('walkthrough controller routes progress, commit APIs, and sharing through c await getController().commitWalkthrough({ body: 'Body', paths: ['src/app.ts'], - source: { ref: 'old', type: 'commit' }, + source: { sha: gitSha('old'), type: 'commit' }, subject: 'Subject', }); await getController().updateWalkthroughCommitMessage({ body: 'Body', paths: ['src/app.ts'], - source: { ref: 'old', type: 'commit' }, + source: { sha: gitSha('old'), type: 'commit' }, subject: 'Subject', }); }); diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index 7a5ed6a6..e65b5647 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -117,6 +117,7 @@ import type { GitIdentity, PullRequestCodeQualityFinding, PullRequestExistingReviewComment, + ResolvedReviewSource, ReviewAuthor, ReviewSource, } from '../../types.ts'; @@ -1027,7 +1028,7 @@ function ImageDiffPreview({ loadImageContent: (request: DiffImageContentRequest) => Promise; onLayoutReady: (sectionId: string) => void; section: DiffSection; - source: ReviewSource; + source: ResolvedReviewSource; }) { const loadingResult: DiffImageContentResult = { reason: 'Loading image...', @@ -2606,7 +2607,7 @@ export function ReviewCodeView({ selectedPath: string | null; showSourceDescription?: boolean; showWhitespace: boolean; - source: ReviewSource; + source: ResolvedReviewSource; sourceDescriptionActions?: ReactNode; sourceDescriptionFooter?: ReactNode; supportsReviewCommentActions: boolean; @@ -2699,13 +2700,13 @@ export function ReviewCodeView({ ? getPullRequestDescriptionAuthor(source.author) : undefined; const sourceTitle = shouldShowCommitMessage - ? commitMessageMetadata.subject.trim() || commitMessageMetadata.shortRef + ? commitMessageMetadata.subject.trim() || commitMessageMetadata.shortSha : shouldShowSourceDescription ? (source.title?.trim() ?? '') : ''; const sourceDescriptionItemId = shouldShowCommitMessage && source.type === 'commit' - ? `commit-message:${source.ref}` + ? `commit-message:${source.sha}` : shouldShowSourceDescription && (sourceDescription || sourceTitle) ? `source-description:${source.provider ?? ''}:${source.url}` : null; diff --git a/core/app/components/Sidebar.tsx b/core/app/components/Sidebar.tsx index 20f9af8d..92340dc7 100644 --- a/core/app/components/Sidebar.tsx +++ b/core/app/components/Sidebar.tsx @@ -15,7 +15,13 @@ import { } from '../../lib/diff.ts'; import { isNativeInputTarget } from '../../lib/keyboard.ts'; import { getShortRef, getSourceKey } from '../../lib/source.ts'; -import type { ChangedFile, HistoryEntry, NarrativeWalkthrough, ReviewSource } from '../../types.ts'; +import type { + ChangedFile, + HistoryEntry, + NarrativeWalkthrough, + ResolvedReviewSource, + ReviewSource, +} from '../../types.ts'; import { Avatar } from './Avatar.tsx'; import { Button } from './Button.tsx'; import { ReviewFileTree } from './FileTree.tsx'; @@ -53,10 +59,10 @@ export function Sidebar({ walkthroughLoading, walkthroughProgress, }: { - branchSource: Extract | null; + branchSource: Extract | null; commitFiles: ReadonlyArray; commitViewOpen: boolean; - currentSource: ReviewSource; + currentSource: ResolvedReviewSource | ReviewSource; files: ReadonlyArray; historyEntries: ReadonlyArray; historyHasMore: boolean; @@ -243,8 +249,8 @@ function HistorySidebar({ pullRequestSource, searchQuery, }: { - branchSource: Extract | null; - currentSource: ReviewSource; + branchSource: Extract | null; + currentSource: ResolvedReviewSource | ReviewSource; entries: ReadonlyArray; hasMore: boolean; loading: boolean; @@ -261,11 +267,11 @@ function HistorySidebar({ author: entry.author, committedAt: entry.committedAt, gravatarUrl: entry.gravatarUrl, - key: `commit:${entry.ref}`, + key: `commit:${entry.sha}`, kind: 'entry' as const, - ref: entry.ref, + ref: entry.sha, scope: entry.scope, - source: { ref: entry.ref, type: 'commit' } satisfies ReviewSource, + source: { ref: entry.sha, type: 'commit' } satisfies ReviewSource, subject: entry.subject, })); const matchesQuery = (row: (typeof commitRows)[number]) => @@ -334,16 +340,16 @@ function HistorySidebar({ committedAt: null, gravatarUrl: undefined, key: getSourceKey({ - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, + baseSha: branchSource.baseSha, + headSha: branchSource.headSha, ref: branchSource.ref, type: 'branch-working-tree', }), kind: 'entry' as const, ref: 'branch+', source: { - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, + baseSha: branchSource.baseSha, + headSha: branchSource.headSha, ref: branchSource.ref, type: 'branch-working-tree', } satisfies ReviewSource, diff --git a/core/app/components/walkthrough/CommitView.tsx b/core/app/components/walkthrough/CommitView.tsx index dd5e990e..e3441848 100644 --- a/core/app/components/walkthrough/CommitView.tsx +++ b/core/app/components/walkthrough/CommitView.tsx @@ -473,7 +473,7 @@ export function CommitView({ Committed {selectedFiles.length} file{selectedFiles.length === 1 ? '' : 's'} - {result && result.status === 'committed' ? result.hash.slice(0, 10) : ''} + {result && result.status === 'committed' ? result.sha.slice(0, 10) : ''} {branch ? ` ยท onto ${branch}` : ''} diff --git a/core/global.d.ts b/core/global.d.ts index 21a0f5cb..afcb4f3b 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -21,6 +21,7 @@ import type { PlanReview, RepositoryHistory, RepositoryState, + ResolvedReviewSource, ReviewAssistantRequest, ReviewAssistantResult, ReviewSource, @@ -67,7 +68,7 @@ declare global { path: string; }) => Promise; getNarrativeWalkthrough: ( - source?: ReviewSource, + source?: ResolvedReviewSource, options?: NarrativeWalkthroughRequestOptions, ) => Promise; getPlanReview: () => Promise; diff --git a/core/index.ts b/core/index.ts index 0a5d9053..fb0a3c7d 100644 --- a/core/index.ts +++ b/core/index.ts @@ -1,4 +1,5 @@ export { defaultReviewPreferences } from './defaults.ts'; +export { diffRange, isCommitRevision, shaForRevision } from './lib/review-history.ts'; export { parsePlanShareManifest, parsePlanShareUpload, @@ -9,8 +10,10 @@ export type { ChangedFile, CodiffFeatureFlags, CodiffPreferences, + DiffRange, DiffSection, GitIdentity, + GitSha, NarrativeWalkthrough, PlanCommentThread, PullRequestCodeQualityFinding, @@ -25,6 +28,8 @@ export type { PullRequestReviewer, ReviewPreferences, RepositoryState, + Revision, + RevisionLabel, ReviewSource, SharePlanResult, ShareWalkthroughResult, diff --git a/core/lib/reload-selection.ts b/core/lib/reload-selection.ts index d602ffa5..0c62fece 100644 --- a/core/lib/reload-selection.ts +++ b/core/lib/reload-selection.ts @@ -1,4 +1,10 @@ -import type { GitFileStatus, RepositoryState, ReviewSource } from '../types.ts'; +import type { + GitFileStatus, + RepositoryState, + ResolvedReviewSource, + ReviewSource, +} from '../types.ts'; +import { decodeResolvedReviewSource, decodeReviewSource } from './review-source-codec.ts'; import { getSourceKey } from './source.ts'; const reloadSelectionStorageKey = 'codiff.reloadSelection.v3'; @@ -11,13 +17,13 @@ type ReloadSelectionFile = { export type ReloadMainMode = 'commit' | 'review'; -type ReloadSelection = { +export type ReloadSelection = { files: ReadonlyArray; historySource?: ReviewSource | null; mainMode?: ReloadMainMode; root: string; selectedPath: string | null; - source: ReviewSource; + source: ResolvedReviewSource; }; const getStorage = () => { @@ -31,51 +37,10 @@ const getStorage = () => { const isObject = (value: unknown): value is Record => typeof value === 'object' && value != null; -const isOptionalString = (value: unknown) => value == null || typeof value === 'string'; +const isReviewSource = (value: unknown): value is ReviewSource => decodeReviewSource(value) != null; -const isReviewSource = (value: unknown): value is ReviewSource => { - if (!isObject(value) || typeof value.type !== 'string') { - return false; - } - - if (value.type === 'working-tree') { - return true; - } - - if (value.type === 'commit') { - return typeof value.ref === 'string'; - } - - if (value.type === 'range') { - return ( - typeof value.base === 'string' && - typeof value.head === 'string' && - typeof value.symmetric === 'boolean' - ); - } - - if (value.type === 'branch') { - return typeof value.ref === 'string'; - } - - if (value.type === 'branch-diff' || value.type === 'branch-working-tree') { - return ( - typeof value.ref === 'string' && - typeof value.baseRef === 'string' && - typeof value.headRef === 'string' - ); - } - - return ( - value.type === 'pull-request' && - typeof value.url === 'string' && - (value.number == null || typeof value.number === 'number') && - isOptionalString(value.headSha) && - isOptionalString(value.owner) && - isOptionalString(value.repo) && - isOptionalString(value.title) - ); -}; +const isResolvedReviewSource = (value: unknown): value is ResolvedReviewSource => + decodeResolvedReviewSource(value) != null; const isGitFileStatus = (value: unknown): value is GitFileStatus => value === 'added' || @@ -99,7 +64,7 @@ const isReloadSelection = (value: unknown): value is ReloadSelection => (value.mainMode == null || value.mainMode === 'commit' || value.mainMode === 'review') && typeof value.root === 'string' && (value.selectedPath == null || typeof value.selectedPath === 'string') && - isReviewSource(value.source); + isResolvedReviewSource(value.source); const getMatchingSelection = (selection: ReloadSelection | null, state: RepositoryState) => selection?.root === state.root && getSourceKey(selection.source) === getSourceKey(state.source) diff --git a/core/lib/review-command-target.ts b/core/lib/review-command-target.ts index b7405e3f..4993d5aa 100644 --- a/core/lib/review-command-target.ts +++ b/core/lib/review-command-target.ts @@ -1,4 +1,4 @@ -import type { ChangedFile, ReviewSource } from '../types.ts'; +import type { ChangedFile, ResolvedReviewSource } from '../types.ts'; import type { ReviewIdentity } from './app-types.ts'; import { getFileReviewIdentity } from './review-identity.ts'; import { getSourceKey } from './source.ts'; @@ -10,7 +10,7 @@ export type ReviewCommandTarget = { }; export const createReviewCommandTarget = ( - source: ReviewSource, + source: ResolvedReviewSource, file: ChangedFile, reviewIdentity: ReviewIdentity = getFileReviewIdentity(file), ): ReviewCommandTarget => ({ @@ -29,7 +29,7 @@ export const resolveReviewCommandTarget = ({ activeTarget: ReviewCommandTarget | null; files: ReadonlyArray; selectedPath: string | null; - source: ReviewSource; + source: ResolvedReviewSource; useActiveTarget: boolean; }): ReviewCommandTarget | null => { const sourceKey = getSourceKey(source); diff --git a/core/lib/review-history.ts b/core/lib/review-history.ts new file mode 100644 index 00000000..9181671b --- /dev/null +++ b/core/lib/review-history.ts @@ -0,0 +1,15 @@ +import type { DiffRange, GitSha, Revision } from '../types.ts'; + +export const isCommitRevision = ( + revision: Revision, +): revision is Extract => + revision.kind !== 'index' && revision.kind !== 'working-copy'; + +export const shaForRevision = (revision: Revision): GitSha => { + if (!isCommitRevision(revision)) { + throw new Error(`Expected a commit revision, received ${revision.kind}.`); + } + return revision.sha; +}; + +export const diffRange = (base: Revision, head: Revision): DiffRange => ({ base, head }); diff --git a/core/lib/review-source-codec.cjs b/core/lib/review-source-codec.cjs new file mode 100644 index 00000000..f4fc6189 --- /dev/null +++ b/core/lib/review-source-codec.cjs @@ -0,0 +1,218 @@ +// @ts-nocheck + +/** @param {unknown} value */ +const isObject = (value) => typeof value === 'object' && value != null && !Array.isArray(value); +/** @param {unknown} value */ +const isOptionalString = (value) => value == null || typeof value === 'string'; +/** @param {unknown} value */ +const isOptionalNumber = (value) => value == null || typeof value === 'number'; + +/** @param {string} value */ +const parseReviewUrl = (value) => { + try { + const url = new globalThis.URL(value); + const host = url.hostname.toLowerCase(); + const github = url.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/.*)?$/i); + if (github) { + const owner = github[1]; + const repo = github[2].replace(/\.git$/i, ''); + const number = Number(github[3]); + return { + host, + number, + owner, + projectPath: `${owner}/${repo}`, + provider: /** @type {const} */ ('github'), + repo, + url: `${url.protocol}//${url.host}/${owner}/${repo}/pull/${number}`, + }; + } + const gitlab = url.pathname.match(/^\/(.+?)\/-\/merge_requests\/(\d+)(?:\/.*)?$/i); + if (gitlab) { + const projectPath = gitlab[1].replace(/\.git$/i, ''); + const number = Number(gitlab[2]); + return { + host, + number, + projectPath, + provider: /** @type {const} */ ('gitlab'), + url: `${url.protocol}//${url.host}/${projectPath}/-/merge_requests/${number}`, + }; + } + } catch { + // Invalid and non-review URLs are rejected by the codec. + } + return null; +}; + +/** @param {Record} value */ +const decodePullRequestSource = (value) => { + if ( + typeof value.url !== 'string' || + !isOptionalNumber(value.number) || + !isOptionalString(value.headSha) || + !isOptionalString(value.host) || + !isOptionalString(value.owner) || + !isOptionalString(value.projectPath) || + !isOptionalString(value.repo) || + !isOptionalString(value.title) + ) { + return null; + } + const parsed = parseReviewUrl(value.url); + const provider = value.provider || parsed?.provider; + if (provider !== 'github' && provider !== 'gitlab') { + return null; + } + return { + ...value, + ...(parsed || {}), + ...(typeof value.headSha === 'string' ? { headSha: value.headSha.toLowerCase() } : {}), + provider, + type: /** @type {const} */ ('pull-request'), + }; +}; + +/** @param {unknown} input */ +const decodeReviewSource = (input) => { + if (!isObject(input) || typeof input.type !== 'string') { + return null; + } + const value = /** @type {Record} */ (input); + if (value.type === 'working-tree') { + return { type: /** @type {const} */ ('working-tree') }; + } + if (value.type === 'commit') { + return typeof value.ref === 'string' + ? { ref: value.ref, type: /** @type {const} */ ('commit') } + : null; + } + if (value.type === 'branch') { + return typeof value.ref === 'string' + ? { ref: value.ref, type: /** @type {const} */ ('branch') } + : null; + } + if (value.type === 'branch-diff') { + return typeof value.ref === 'string' && + typeof value.baseSha === 'string' && + typeof value.headSha === 'string' + ? { + baseSha: value.baseSha.toLowerCase(), + headSha: value.headSha.toLowerCase(), + ref: value.ref, + type: /** @type {const} */ ('branch-diff'), + } + : null; + } + if (value.type === 'branch-working-tree') { + if ( + typeof value.ref !== 'string' || + !isOptionalString(value.baseSha) || + !isOptionalString(value.headSha) + ) { + return null; + } + const hasBase = typeof value.baseSha === 'string' && value.baseSha.length > 0; + const hasHead = typeof value.headSha === 'string' && value.headSha.length > 0; + if (hasBase !== hasHead) { + return null; + } + return { + ...(hasBase + ? { baseSha: value.baseSha.toLowerCase(), headSha: value.headSha.toLowerCase() } + : {}), + ref: value.ref, + type: /** @type {const} */ ('branch-working-tree'), + }; + } + if (value.type === 'range') { + return typeof value.base === 'string' && + typeof value.head === 'string' && + typeof value.symmetric === 'boolean' + ? { + base: value.base, + head: value.head, + symmetric: value.symmetric, + type: /** @type {const} */ ('range'), + } + : null; + } + return value.type === 'pull-request' ? decodePullRequestSource(value) : null; +}; + +/** @param {unknown} input */ +const decodeResolvedReviewSource = (input) => { + if (!isObject(input) || typeof input.type !== 'string') { + return null; + } + const value = /** @type {Record} */ (input); + if (value.type === 'commit') { + return typeof value.sha === 'string' + ? { sha: value.sha.toLowerCase(), type: /** @type {const} */ ('commit') } + : null; + } + const source = decodeReviewSource(input); + if (!source || source.type === 'branch') { + return null; + } + if ( + source.type === 'branch-working-tree' && + (!('baseSha' in source) || !source.baseSha || !source.headSha) + ) { + return null; + } + return source; +}; + +/** @param {ReturnType | ReturnType} source */ +const formatReviewSourceIdentity = (source) => { + if (!source) { + return null; + } + if (source.type === 'working-tree') { + return 'working-tree'; + } + if (source.type === 'commit') { + return `commit:${'sha' in source ? source.sha : source.ref}`; + } + if (source.type === 'branch') { + return `branch:${source.ref}`; + } + if (source.type === 'branch-diff') { + return `branch-diff:${source.ref}:${source.baseSha}:${source.headSha}`; + } + if (source.type === 'branch-working-tree') { + return 'baseSha' in source && source.baseSha && source.headSha + ? `branch-working-tree:${source.ref}:${source.baseSha}:${source.headSha}` + : `branch-working-tree:${source.ref}:unresolved`; + } + if (source.type === 'range') { + return `range:${source.base}${source.symmetric ? '...' : '..'}${source.head}`; + } + const parsed = parseReviewUrl(source.url); + const provider = source.provider || parsed?.provider || ''; + const host = (source.host || parsed?.host || '').toLowerCase(); + const projectPath = ( + source.projectPath || + (source.owner && source.repo ? `${source.owner}/${source.repo}` : parsed?.projectPath) || + '' + ).toLowerCase(); + return `pull-request:${provider}:${host}:${projectPath}#${source.number || parsed?.number || source.url}`; +}; + +/** @param {ReturnType} source */ +const formatResolvedSourceIdentity = (source) => { + const logical = formatReviewSourceIdentity(source); + return logical && source?.type === 'pull-request' + ? `${logical}:${source.headSha || 'unresolved-head'}` + : logical; +}; + +// eslint-disable-next-line no-undef +module.exports = { + decodeResolvedReviewSource, + decodeReviewSource, + formatResolvedSourceIdentity, + formatReviewSourceIdentity, + parseReviewUrl, +}; diff --git a/core/lib/review-source-codec.ts b/core/lib/review-source-codec.ts new file mode 100644 index 00000000..17da876e --- /dev/null +++ b/core/lib/review-source-codec.ts @@ -0,0 +1,21 @@ +import type { ResolvedReviewSource, ReviewSource } from '../types.ts'; +import codec from './review-source-codec.cjs'; + +const reviewSourceCodec = codec as { + decodeResolvedReviewSource(value: unknown): unknown; + decodeReviewSource(value: unknown): unknown; + formatResolvedSourceIdentity(source: ResolvedReviewSource): string | null; + formatReviewSourceIdentity(source: ResolvedReviewSource | ReviewSource): string | null; +}; + +export const decodeReviewSource = (value: unknown): ReviewSource | null => + reviewSourceCodec.decodeReviewSource(value) as ReviewSource | null; + +export const decodeResolvedReviewSource = (value: unknown): ResolvedReviewSource | null => + reviewSourceCodec.decodeResolvedReviewSource(value) as ResolvedReviewSource | null; + +export const formatReviewSourceIdentity = (source: ResolvedReviewSource | ReviewSource): string => + reviewSourceCodec.formatReviewSourceIdentity(source) as string; + +export const formatResolvedSourceIdentity = (source: ResolvedReviewSource): string => + reviewSourceCodec.formatResolvedSourceIdentity(source) as string; diff --git a/core/lib/source.ts b/core/lib/source.ts index 93872710..de2b014e 100644 --- a/core/lib/source.ts +++ b/core/lib/source.ts @@ -1,6 +1,7 @@ -import type { ReviewSource } from '../types.ts'; +import type { ResolvedReviewSource, ReviewSource } from '../types.ts'; import type { RepositoryLoadError } from './app-types.ts'; import { abbreviateHomePath } from './files.ts'; +import { formatResolvedSourceIdentity, formatReviewSourceIdentity } from './review-source-codec.ts'; const rangeLabel = (source: Extract) => `${source.base}${source.symmetric ? '...' : '..'}${source.head}`; @@ -73,22 +74,22 @@ const sourceCapabilitiesByType = { }, } satisfies Record; -const getSourceCapabilities = (source: ReviewSource) => sourceCapabilitiesByType[source.type]; +type DisplayReviewSource = ResolvedReviewSource | ReviewSource; -export const getSourceKey = (source: ReviewSource) => - source.type === 'commit' - ? `commit:${source.ref}` - : source.type === 'branch-diff' - ? `branch-diff:${source.ref}:${source.baseRef}:${source.headRef}` - : source.type === 'branch-working-tree' - ? `branch-working-tree:${source.ref}:${source.baseRef}:${source.headRef}` - : source.type === 'branch' - ? `branch:${source.ref}` - : source.type === 'range' - ? `range:${rangeLabel(source)}` - : source.type === 'pull-request' - ? `pull-request:${source.provider ?? ''}:${source.host ?? ''}:${source.projectPath ?? `${source.owner ?? ''}/${source.repo ?? ''}`}#${source.number ?? source.url}` - : 'working-tree'; +const getSourceCapabilities = (source: DisplayReviewSource) => + sourceCapabilitiesByType[source.type]; + +export const getSourceKey = (source: DisplayReviewSource) => formatReviewSourceIdentity(source); + +/** + * Identifies the exact revision currently rendered for asynchronous work. + * A pull request's logical source key stays stable as its head moves, while + * deferred results must never cross that immutable head boundary. + */ +export const getSourceRevisionKey = (source: DisplayReviewSource) => + 'sha' in source || source.type !== 'commit' + ? formatResolvedSourceIdentity(source as ResolvedReviewSource) + : formatReviewSourceIdentity(source); const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error); @@ -109,9 +110,9 @@ export const getRepositoryLoadError = (error: unknown): RepositoryLoadError => { export const getShortRef = (ref: string) => ref.slice(0, 7); -export const getSourceLabel = (source: ReviewSource) => +export const getSourceLabel = (source: DisplayReviewSource) => source.type === 'commit' - ? getShortRef(source.ref) + ? getShortRef('sha' in source ? source.sha : source.ref) : source.type === 'branch' || source.type === 'branch-diff' ? `Branch vs ${source.ref}` : source.type === 'branch-working-tree' @@ -126,38 +127,40 @@ export const getSourceLabel = (source: ReviewSource) => : 'Pull request' : 'Uncommitted'; -export const getHistorySource = (source: ReviewSource): ReviewSource | undefined => - getSourceCapabilities(source).historySource ? source : undefined; +export const getHistorySource = (source: DisplayReviewSource): ReviewSource | undefined => + getSourceCapabilities(source).historySource ? (source as ReviewSource) : undefined; -export const getRefreshSource = (source: ReviewSource): ReviewSource => - source.type === 'branch-working-tree' - ? { - ref: source.ref, - type: 'branch-working-tree', - } - : source; +export const getRefreshSource = (source: DisplayReviewSource): ReviewSource => + source.type === 'commit' && 'sha' in source + ? { ref: source.sha, type: 'commit' } + : source.type === 'branch-working-tree' + ? { + ref: source.ref, + type: 'branch-working-tree', + } + : (source as ReviewSource); -export const supportsLazyDiffContent = (source: ReviewSource) => +export const supportsLazyDiffContent = (source: DisplayReviewSource) => getSourceCapabilities(source).lazyDiffContent; -export const supportsDiffSearchContentPreload = (source: ReviewSource) => +export const supportsDiffSearchContentPreload = (source: DisplayReviewSource) => getSourceCapabilities(source).preloadDiffSearchContent; -export const shouldStartInHistoryWhenEmpty = (source: ReviewSource) => +export const shouldStartInHistoryWhenEmpty = (source: DisplayReviewSource) => getSourceCapabilities(source).startInHistoryWhenEmpty; -export const usesViewedFileState = (source: ReviewSource) => +export const usesViewedFileState = (source: DisplayReviewSource) => getSourceCapabilities(source).viewedFileState; -export const getEmptySourceTitle = (source: ReviewSource) => +export const getEmptySourceTitle = (source: DisplayReviewSource) => getSourceCapabilities(source).emptyTitle; export const getEmptySourceDetail = ( - source: ReviewSource, + source: DisplayReviewSource, root: string, ): { kind: 'code' | 'text'; text: string; title?: string } => source.type === 'commit' - ? { kind: 'text', text: getShortRef(source.ref) } + ? { kind: 'text', text: getShortRef('sha' in source ? source.sha : source.ref) } : source.type === 'branch' || source.type === 'branch-diff' || source.type === 'branch-working-tree' diff --git a/core/tsconfig.build.json b/core/tsconfig.build.json index 9ed75c10..78ad9861 100644 --- a/core/tsconfig.build.json +++ b/core/tsconfig.build.json @@ -22,6 +22,7 @@ "app/**/*.ts", "app/**/*.tsx", "config/**/*.ts", + "lib/**/*.cjs", "lib/**/*.js", "lib/**/*.ts" ] diff --git a/core/types.ts b/core/types.ts index e5ca564a..23034567 100644 --- a/core/types.ts +++ b/core/types.ts @@ -17,6 +17,8 @@ export type DiffSection = { name: string; }; patch: string; + /** Provider-neutral revision range represented by this diff section. */ + range?: DiffRange; summary?: { canLoad?: boolean; fileCount?: number; @@ -110,6 +112,9 @@ export type PullRequestMergeState = { statusLabel: string; }; +/** A full, resolved Git commit object ID. Never use this for a ref selector. */ +export type GitSha = string & { readonly __gitSha: unique symbol }; + export type ReviewSource = | { type: 'working-tree'; @@ -124,9 +129,9 @@ export type ReviewSource = } | { /** Resolved base commit for a branch diff snapshot. */ - baseRef: string; + baseSha: GitSha; /** Resolved head commit for a branch diff snapshot. */ - headRef: string; + headSha: GitSha; /** Target branch the current branch was compared against. */ ref: string; type: 'branch-diff'; @@ -138,9 +143,9 @@ export type ReviewSource = * (`codiff main`), before merge-base resolution happens; the resolved * state's `source` always carries a concrete value. */ - baseRef?: string; - /** Resolved head commit for the branch part of the comparison. See {@link baseRef}. */ - headRef?: string; + baseSha?: GitSha; + /** Resolved head commit for the branch part of the comparison. See {@link baseSha}. */ + headSha?: GitSha; /** Target branch the current branch was compared against. */ ref: string; type: 'branch-working-tree'; @@ -179,13 +184,32 @@ export type ReviewSource = /** Sources that can be entered from the palette or native application menu. */ export type OpenReviewSourceKind = 'branch' | 'commit' | 'pull-request'; +/** + * A source after Git resolution. Commit selectors become full SHAs, and a + * branch-working-tree snapshot always carries the exact branch comparison it + * was read against. Persisted and rendered sources use this shape so a `ref` + * never silently changes from a selector into object identity. + */ +export type ResolvedReviewSource = + | Exclude + | { + sha: GitSha; + type: 'commit'; + } + | { + baseSha: GitSha; + headSha: GitSha; + ref: string; + type: 'branch-working-tree'; + }; + export type HistoryEntry = { author: string; committedAt: number; gravatarUrl?: string; - parents: ReadonlyArray; - ref: string; + parentShas: ReadonlyArray; scope?: 'base' | 'pull-request'; + sha: GitSha; subject: string; }; @@ -210,10 +234,10 @@ export type CommitMetadata = { body: string; committer: CommitMetadataPerson; files: ReadonlyArray; - parents: ReadonlyArray; - ref: string; + parentShas: ReadonlyArray; refs: ReadonlyArray; - shortRef: string; + sha: GitSha; + shortSha: string; signature: { key?: string; signer?: string; @@ -248,7 +272,7 @@ export type RepositoryState = { launchPath: string; reviewComments?: ReadonlyArray; root: string; - source: ReviewSource; + source: ResolvedReviewSource; }; export type CodiffFeatureFlags = { @@ -347,7 +371,7 @@ export type SharedWalkthroughSnapshot = { repository: { generalComments?: ReadonlyArray; root: string; - source: ReviewSource; + source: ResolvedReviewSource; title?: string; }; reviewComments?: ReadonlyArray; @@ -391,6 +415,39 @@ export type ShareResult = export type SharePlanResult = ShareResult; export type ShareWalkthroughResult = ShareResult; +/** Mutable display text for a revision; never durable object identity. */ +export type RevisionLabel = { + kind: 'bookmark' | 'branch' | 'commit' | 'review-marker' | 'tag' | 'version'; + text: string; + url?: string; +}; + +/** Provider-neutral revision identity, distinct from mutable labels and selectors. */ +export type Revision = + | { + aliases?: ReadonlyArray; + kind?: 'commit'; + label: RevisionLabel; + sha: GitSha; + } + | { + aliases?: ReadonlyArray; + kind: 'index'; + label: RevisionLabel; + stage?: 1 | 2 | 3; + } + | { + aliases?: ReadonlyArray; + kind: 'working-copy'; + label: RevisionLabel; + }; + +/** Provider-neutral base/head identity for one review range. Null means that file side is absent. */ +export type DiffRange = { + base: Revision | null; + head: Revision | null; +}; + export type WalkthroughContext = { changedFiles?: ReadonlyArray<{ path: string; @@ -597,7 +654,7 @@ export type NarrativeWalkthrough = { branch: string | null; root: string; }; - source: ReviewSource; + source: ResolvedReviewSource; support: ReadonlyArray; title: string; version: 4; @@ -630,7 +687,7 @@ export type WalkthroughCommitRequest = { body: string; /** Repo-relative paths to commit; other staged changes are left untouched. */ paths: ReadonlyArray; - source?: ReviewSource; + source?: ResolvedReviewSource; /** First line of the commit message. */ subject: string; }; @@ -638,7 +695,7 @@ export type WalkthroughCommitRequest = { export type WalkthroughCommitResult = | { /** Full SHA of the new commit. */ - hash: string; + sha: GitSha; status: 'committed'; } | { @@ -656,7 +713,7 @@ export type WalkthroughCommitMessageRequest = { body: string; /** Repo-relative paths still selected for the commit. */ paths: ReadonlyArray; - source?: ReviewSource; + source?: ResolvedReviewSource; /** The current subject line. */ subject: string; }; @@ -683,7 +740,7 @@ export type ReviewAssistantRequest = { startLineNumber?: number; startSide?: 'additions' | 'deletions'; }; - source?: ReviewSource; + source?: ResolvedReviewSource; walkthroughNote?: { action: 'review' | 'scan' | 'skim'; context: string; @@ -717,7 +774,7 @@ export type DiffSectionContentRequest = { kind: DiffSection['kind']; path: string; showWhitespace?: boolean; - source?: ReviewSource; + source?: ResolvedReviewSource; }; export type DefinitionSearchRequest = { @@ -752,7 +809,7 @@ export type DefinitionSearchResult = export type DiffImageContentRequest = { kind: DiffSection['kind']; path: string; - source?: ReviewSource; + source?: ResolvedReviewSource; }; export type DiffImageRevision = { diff --git a/electron/__tests__/narrative-walkthrough.test.ts b/electron/__tests__/narrative-walkthrough.test.ts index 76a6154f..9c9699d4 100644 --- a/electron/__tests__/narrative-walkthrough.test.ts +++ b/electron/__tests__/narrative-walkthrough.test.ts @@ -1446,7 +1446,7 @@ test('strips the commit composer when the source is not a working tree', () => { input.commit = { title: 'Fix hunk nav' }; const result = normalizeNarrativeWalkthrough(input, files, { - source: { ref: 'abc1234', type: 'commit' }, + source: { sha: 'abc1234', type: 'commit' }, }); expect(result.commit).toBeUndefined(); diff --git a/electron/__tests__/walkthrough-commit.test.ts b/electron/__tests__/walkthrough-commit.test.ts index 046c3201..6705512b 100644 --- a/electron/__tests__/walkthrough-commit.test.ts +++ b/electron/__tests__/walkthrough-commit.test.ts @@ -19,7 +19,7 @@ const { createWalkthroughCommit } = require('../walkthrough-commit.cjs') as { repoPath: string, request: { body?: string; paths?: ReadonlyArray; subject?: string }, onOutput?: (chunk: string) => void, - ) => Promise<{ hash: string; status: 'committed' } | { reason: string; status: 'failed' }>; + ) => Promise<{ sha: string; status: 'committed' } | { reason: string; status: 'failed' }>; }; const execFileAsync = promisify(execFile); diff --git a/electron/__tests__/window-identity.test.ts b/electron/__tests__/window-identity.test.ts index b617c927..91e967e4 100644 --- a/electron/__tests__/window-identity.test.ts +++ b/electron/__tests__/window-identity.test.ts @@ -23,7 +23,7 @@ const { findMatchingWindowIdentity, getWindowIdentity, getWindowIdentityForRepos source?: | { type: 'working-tree' } | { ref: string; type: 'branch' } - | { baseRef: string; headRef: string; ref: string; type: 'branch-diff' } + | { baseSha: string; headSha: string; ref: string; type: 'branch-diff' } | { ref: string; type: 'commit' } | { number?: number; @@ -42,8 +42,8 @@ const { findMatchingWindowIdentity, getWindowIdentity, getWindowIdentityForRepos root: string; source: | { type: 'working-tree' } - | { ref: string; type: 'commit' } - | { baseRef: string; headRef: string; ref: string; type: 'branch-diff' }; + | { sha: string; type: 'commit' } + | { baseSha: string; headSha: string; ref: string; type: 'branch-diff' }; }) => { key: string; repositoryRoot: string; sourceKey: string } | null; }; @@ -141,7 +141,7 @@ test.sequential('resolved repository states build identities without invoking Gi expect( getWindowIdentityForRepositoryState({ root: repository.path, - source: { ref: head, type: 'commit' }, + source: { sha: head, type: 'commit' }, }), ).toMatchObject({ repositoryRoot: await realpath(repository.path), @@ -189,7 +189,7 @@ test('window identities distinguish branch history launches', async () => { expect( getWindowIdentity(directory.path, { - source: { baseRef: head, headRef: nextHead, ref: 'feature', type: 'branch-diff' }, + source: { baseSha: head, headSha: nextHead, ref: 'feature', type: 'branch-diff' }, })?.sourceKey, ).toBe(`branch-diff:feature:${head}:${nextHead}`); }); @@ -205,7 +205,7 @@ test('window identities normalize GitHub pull request sources', async () => { url: 'https://github.com/NKZW-Tech/Codiff/pull/8', }, })?.sourceKey, - ).toBe('pull-request:nkzw-tech/codiff#8'); + ).toBe('pull-request:github:github.com:nkzw-tech/codiff#8'); expect( getWindowIdentity(directory.path, { source: { @@ -237,7 +237,7 @@ test('window identities normalize pull request URLs copied from a review tab', a url: 'https://github.com/NKZW-Tech/Codiff/pull/8/changes#r4821', }, })?.sourceKey, - ).toBe('pull-request:nkzw-tech/codiff#8'); + ).toBe('pull-request:github:github.com:nkzw-tech/codiff#8'); expect( getWindowIdentity(directory.path, { source: { @@ -245,7 +245,7 @@ test('window identities normalize pull request URLs copied from a review tab', a url: 'https://gitlab.example.com/group/subgroup/project/-/merge_requests/8/diffs', }, })?.sourceKey, - ).toBe('pull-request:gitlab:gitlab.example.com/group/subgroup/project#8'); + ).toBe('pull-request:gitlab:gitlab.example.com:group/subgroup/project#8'); }); test('window identities normalize GitLab merge request sources', async () => { @@ -259,7 +259,7 @@ test('window identities normalize GitLab merge request sources', async () => { url: 'https://gitlab.example.com/group/subgroup/project/-/merge_requests/8', }, })?.sourceKey, - ).toBe('pull-request:gitlab:gitlab.example.com/group/subgroup/project#8'); + ).toBe('pull-request:gitlab:gitlab.example.com:group/subgroup/project#8'); }); test('window identity matching requires exact identity matches', () => { diff --git a/electron/generated-files.cjs b/electron/generated-files.cjs index 3dca624c..b28dc52c 100644 --- a/electron/generated-files.cjs +++ b/electron/generated-files.cjs @@ -15,21 +15,6 @@ const isGeneratedAttributeValue = (value) => /** @param {string} value */ const isNotGeneratedAttributeValue = (value) => value === 'unset' || value === 'false'; -/** @param {import('../core/types.ts').ReviewSource} source */ -const getGeneratedAttributeSource = (source) => - source.type === 'commit' - ? source.ref - : source.type === 'range' - ? source.head - : source.type === 'branch-diff' - ? source.headRef - : source.type === 'pull-request' - ? source.headSha - : // `branch-working-tree` includes live uncommitted files, so the generated - // attribute state must be computed live (like `working-tree`) rather than - // pinned to a fixed ref. - undefined; - /** @param {Buffer} output */ const parseGeneratedAttributeStates = (output) => { const fields = output.toString('utf8').split('\0'); @@ -89,15 +74,18 @@ const readGeneratedAttributeStatesFromTree = async (repoRoot, paths, source) => /** * @param {string} repoRoot * @param {ReadonlyArray} paths - * @param {string | undefined} source + * @param {import('../core/types.ts').Revision} revision */ -const readGeneratedAttributeStates = async (repoRoot, paths, source) => { +const readRevisionGeneratedAttributeStates = async (repoRoot, paths, revision) => { if (paths.length === 0) { return new Map(); } + const kind = revision.kind || 'commit'; + const source = kind === 'commit' && 'sha' in revision ? revision.sha : undefined; + const options = kind === 'index' ? ['--cached'] : source ? ['--source', source] : []; try { - return await checkGeneratedAttributeStates(repoRoot, paths, source ? ['--source', source] : []); + return await checkGeneratedAttributeStates(repoRoot, paths, options); } catch { if (source) { try { @@ -129,19 +117,22 @@ const applyGeneratedAttributeStates = (state, generatedAttributeStates) => ({ }), }); -/** @param {import('../core/types.ts').RepositoryState} state */ -const annotateGeneratedFiles = async (state) => +/** + * @param {import('../core/types.ts').RepositoryState} state + * @param {import('../core/types.ts').Revision} revision + */ +const annotateGeneratedFiles = async (state, revision) => applyGeneratedAttributeStates( state, - await readGeneratedAttributeStates( + await readRevisionGeneratedAttributeStates( state.root, state.files.map((file) => file.path), - getGeneratedAttributeSource(state.source), + revision, ), ); module.exports = { annotateGeneratedFiles, applyGeneratedAttributeStates, - readGeneratedAttributeStates, + readRevisionGeneratedAttributeStates, }; diff --git a/electron/git-state.cjs b/electron/git-state.cjs index 243b6d67..51e7be67 100644 --- a/electron/git-state.cjs +++ b/electron/git-state.cjs @@ -94,9 +94,19 @@ const readRepositoryState = async (launchPath, source = { type: 'working-tree' } source.type === 'range' || source.type === 'branch' || source.type === 'branch-diff'; + const generatedRevision = + state.source.type === 'pull-request' && state.source.headSha + ? { + label: { kind: 'commit', text: state.source.headSha.slice(0, 7) }, + sha: state.source.headSha, + } + : { + kind: /** @type {const} */ ('working-copy'), + label: { kind: /** @type {const} */ ('review-marker'), text: 'Working Copy' }, + }; const [branch, annotatedState] = await Promise.all([ gitOrEmpty(state.root, ['symbolic-ref', '--short', 'HEAD']), - comparisonState ? state : annotateGeneratedFiles(state), + comparisonState ? state : annotateGeneratedFiles(state, generatedRevision), ]); return { ...annotatedState, branch: branch.trim() || null }; }; @@ -155,8 +165,8 @@ const isGitLabReviewSource = (source) => /** @param {Extract} source */ const getBranchHistoryRef = (source) => - source.type !== 'branch' && source.baseRef && source.headRef - ? `${source.baseRef}..${source.headRef}` + source.type !== 'branch' && source.baseSha && source.headSha + ? `${source.baseSha}..${source.headSha}` : `${source.ref}..HEAD`; /** @param {string} launchPath @param {number} [limit] @param {ReviewSource} [source] @returns {Promise} */ @@ -195,9 +205,14 @@ const readDiffSectionContent = async (launchPath, request) => : request.source?.type === 'branch-working-tree' ? readBranchWorkingTreeSectionContent(launchPath, request) : request.kind === 'commit' || request.source?.type === 'commit' - ? readCommitSectionContent(launchPath, request.source?.ref || 'HEAD', request.path, { - force: request.force, - }) + ? readCommitSectionContent( + launchPath, + request.source?.type === 'commit' ? request.source.sha : 'HEAD', + request.path, + { + force: request.force, + }, + ) : readWorkingTreeDiffSectionContent(launchPath, request); /** @param {string} launchPath @param {DiffImageContentRequest} request @returns {Promise} */ @@ -219,7 +234,11 @@ const readDiffImageContent = (launchPath, request) => : request.source?.type === 'branch-working-tree' ? readBranchWorkingTreeImageContent(launchPath, request) : request.kind === 'commit' || request.source?.type === 'commit' - ? readCommitImageContent(launchPath, request.source?.ref || 'HEAD', request.path) + ? readCommitImageContent( + launchPath, + request.source?.type === 'commit' ? request.source.sha : 'HEAD', + request.path, + ) : readWorkingTreeDiffImageContent(launchPath, request); module.exports = { diff --git a/electron/git-state/commit-metadata.cjs b/electron/git-state/commit-metadata.cjs index 6c26e8c9..4b5e3515 100644 --- a/electron/git-state/commit-metadata.cjs +++ b/electron/git-state/commit-metadata.cjs @@ -5,6 +5,7 @@ const { fileSort, getGravatarHash, git, gitBufferWithInput, gitOrEmpty } = requi /** * @typedef {import('../../core/types.ts').CommitMetadata} CommitMetadata * @typedef {import('../../core/types.ts').CommitMetadataFile} CommitMetadataFile + * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('./common.cjs').StatusItem} StatusItem * @typedef {{additions?: number; binary: boolean; deletions?: number; path: string}} NumstatItem */ @@ -124,9 +125,9 @@ const readCommitMessageParts = async (repoRoot, subject, body) => { const parseCommitMetadataHeader = (raw) => { const parts = raw.split('\0'); const [ - ref, - shortRef, - parents, + sha, + shortSha, + parentShas, authorName, authorEmail, authorDate, @@ -144,9 +145,11 @@ const parseCommitMetadataHeader = (raw) => { author: createCommitMetadataPerson(authorName, authorEmail, authorDate), body: body || '', committer: createCommitMetadataPerson(committerName, committerEmail, committerDate), - parents: parents ? parents.split(' ').filter(Boolean) : [], - ref: ref || '', - shortRef: shortRef || '', + parentShas: parentShas + ? /** @type {Array} */ (parentShas.split(' ').filter(Boolean)) + : [], + sha: /** @type {GitSha} */ (sha || ''), + shortSha: shortSha || '', signature: { ...(signatureKey ? { key: signatureKey.trim() } : {}), ...(signatureSigner ? { signer: signatureSigner } : {}), @@ -236,7 +239,7 @@ const readCommitMetadataForCommit = async (repoRoot, commit, firstParent, status ...header, body: messageParts.body, files, - ref: commit, + sha: /** @type {GitSha} */ (commit), refs: refs .split('\n') .map((value) => value.trim()) diff --git a/electron/git-state/commit.cjs b/electron/git-state/commit.cjs index 86f32419..48da1275 100644 --- a/electron/git-state/commit.cjs +++ b/electron/git-state/commit.cjs @@ -22,7 +22,9 @@ const { * @typedef {import('../../core/types.ts').DiffImageContentRequest} DiffImageContentRequest * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest + * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState + * @typedef {import('../../core/types.ts').ResolvedReviewSource} ResolvedReviewSource * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource * @typedef {import('./common.cjs').StatusItem} StatusItem * @typedef {Extract} BranchSource @@ -31,10 +33,10 @@ const { * @typedef {Extract} CommitSource * @typedef {Extract} RangeSource * @typedef {BranchSource | BranchDiffSource | CommitSource | RangeSource} ComparisonSource - * @typedef {CommitSource | BranchDiffSource | RangeSource} ResolvedComparisonSource + * @typedef {Extract} ResolvedComparisonSource * @typedef {{ - * newRef: string; - * oldRef?: string; + * newSha: GitSha; + * oldSha?: GitSha; * repoRoot: string; * source: ResolvedComparisonSource; * sourceLabel: string; @@ -76,10 +78,10 @@ const parseCommitNameStatus = (raw, options = {}) => { return options.sort === false ? files : files.sort(fileSort); }; -/** @param {string} repoRoot @param {string} commit @returns {Promise>} */ +/** @param {string} repoRoot @param {string} commit @returns {Promise>} */ const readCommitParents = async (repoRoot, commit) => { const raw = (await git(repoRoot, ['rev-list', '--parents', '-n', '1', commit])).trim(); - return raw ? raw.split(' ').slice(1) : []; + return raw ? /** @type {Array} */ (raw.split(' ').slice(1)) : []; }; /** @@ -102,33 +104,38 @@ const readCommitNameStatus = async (repoRoot, commit, firstParent, options = {}) /** * @param {string} repoRoot * @param {string} ref + * @returns {Promise} */ const resolveRangeEndpoint = async (repoRoot, ref) => { if (ref !== 'HEAD') { try { - return (await git(repoRoot, ['rev-parse', '--verify', `refs/heads/${ref}^{commit}`])).trim(); + return /** @type {GitSha} */ ( + (await git(repoRoot, ['rev-parse', '--verify', `refs/heads/${ref}^{commit}`])).trim() + ); } catch { // Fall back to Git's normal ref parser for tags, hashes, and fully-qualified refs. } } - return (await git(repoRoot, ['rev-parse', '--verify', `${ref}^{commit}`])).trim(); + return /** @type {GitSha} */ ( + (await git(repoRoot, ['rev-parse', '--verify', `${ref}^{commit}`])).trim() + ); }; /** * Resolve a `base...head` (symmetric -> merge-base) or `base..head` range to the - * concrete (oldRef, newRef) pair the commit helpers diff against. + * concrete (oldSha, newSha) pair the commit helpers diff against. * @param {string} repoRoot @param {string} base @param {string} head @param {boolean} symmetric - * @returns {Promise<{ newRef: string; oldRef: string }>} + * @returns {Promise<{ newSha: GitSha; oldSha: GitSha }>} */ const resolveRangeRefs = async (repoRoot, base, head, symmetric) => { - const newRef = await resolveRangeEndpoint(repoRoot, head); - const oldRef = symmetric + const newSha = await resolveRangeEndpoint(repoRoot, head); + const oldSha = symmetric ? ( - await git(repoRoot, ['merge-base', await resolveRangeEndpoint(repoRoot, base), newRef]) + await git(repoRoot, ['merge-base', await resolveRangeEndpoint(repoRoot, base), newSha]) ).trim() : await resolveRangeEndpoint(repoRoot, base); - return { newRef, oldRef }; + return { newSha, oldSha: /** @type {GitSha} */ (oldSha) }; }; /** @param {string} left @param {string} right */ @@ -211,19 +218,19 @@ const normalizeBranchSourceInput = (input) => /** * @param {string} repoRoot * @param {BranchSource | BranchDiffSource} source - * @returns {Promise<{newRef: string; oldRef: string; source: BranchDiffSource; sourceLabel: string}>} + * @returns {Promise<{newSha: GitSha; oldSha: GitSha; source: BranchDiffSource; sourceLabel: string}>} */ const resolveBranchComparison = async (repoRoot, source) => { if (source.type === 'branch-diff') { return { - newRef: source.headRef, - oldRef: source.baseRef, + newSha: source.headSha, + oldSha: source.baseSha, source, sourceLabel: 'branch', }; } - const newRef = await resolveRangeEndpoint(repoRoot, 'HEAD'); + const newSha = await resolveRangeEndpoint(repoRoot, 'HEAD'); let branchRef; try { branchRef = await resolveRangeEndpoint(repoRoot, source.ref); @@ -235,13 +242,15 @@ const resolveBranchComparison = async (repoRoot, source) => { }`, ); } - const oldRef = (await git(repoRoot, ['merge-base', branchRef, newRef])).trim(); + const oldSha = /** @type {GitSha} */ ( + (await git(repoRoot, ['merge-base', branchRef, newSha])).trim() + ); return { - newRef, - oldRef, + newSha, + oldSha, source: { - baseRef: oldRef, - headRef: newRef, + baseSha: oldSha, + headSha: newSha, ref: source.ref, type: 'branch-diff', }, @@ -256,15 +265,15 @@ const resolveBranchComparison = async (repoRoot, source) => { */ const resolveComparisonSource = async (repoRoot, source) => { if (source.type === 'commit') { - const commit = ( - await git(repoRoot, ['rev-parse', '--verify', `${source.ref}^{commit}`]) - ).trim(); + const commit = /** @type {GitSha} */ ( + (await git(repoRoot, ['rev-parse', '--verify', `${source.ref}^{commit}`])).trim() + ); const [firstParent] = await readCommitParents(repoRoot, commit); return { - newRef: commit, - oldRef: firstParent, + newSha: commit, + oldSha: firstParent, source: { - ref: commit, + sha: commit, type: 'commit', }, sourceLabel: 'commit', @@ -272,15 +281,15 @@ const resolveComparisonSource = async (repoRoot, source) => { } if (source.type === 'range') { - const { newRef, oldRef } = await resolveRangeRefs( + const { newSha, oldSha } = await resolveRangeRefs( repoRoot, source.base, source.head, source.symmetric, ); return { - newRef, - oldRef, + newSha, + oldSha, source, sourceLabel: 'range', }; @@ -297,7 +306,7 @@ const resolveComparisonSource = async (repoRoot, source) => { const readResolvedComparison = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const comparison = await resolveComparisonSource(repoRoot, source); - const status = await readCommitNameStatus(repoRoot, comparison.newRef, comparison.oldRef, { + const status = await readCommitNameStatus(repoRoot, comparison.newSha, comparison.oldSha, { sort: false, }); @@ -315,11 +324,11 @@ const readResolvedCommitComparison = async (repoRoot, commit) => { sort: false, }); return { - newRef: commit, - oldRef: firstParent, + newSha: /** @type {GitSha} */ (commit), + oldSha: firstParent, repoRoot, source: { - ref: commit, + sha: /** @type {GitSha} */ (commit), type: 'commit', }, sourceLabel: 'commit', @@ -331,8 +340,8 @@ const readResolvedCommitComparison = async (repoRoot, commit) => { const readResolvedComparisonState = (launchPath, comparison) => readComparisonState({ launchPath, - newRef: comparison.newRef, - oldRef: comparison.oldRef, + newSha: comparison.newSha, + oldSha: comparison.oldSha, repoRoot: comparison.repoRoot, source: comparison.source, status: comparison.status, @@ -343,7 +352,7 @@ const readComparisonGeneratedAttributeStates = (comparison) => readGeneratedAttributeStates( comparison.repoRoot, comparison.status.map((file) => file.path), - comparison.newRef, + comparison.newSha, ); /** @param {string} launchPath @param {ComparisonSource} source @returns {Promise} */ @@ -371,8 +380,8 @@ const readComparisonSourceSectionContent = async ( const comparison = await readResolvedComparison(launchPath, source); return readComparisonSectionContent( comparison.repoRoot, - comparison.newRef, - comparison.oldRef, + comparison.newSha, + comparison.oldSha, comparison.status, requestedPath, comparison.sourceLabel, @@ -391,8 +400,8 @@ const readComparisonSourceImageContent = async (launchPath, source, requestedPat const comparison = await readResolvedComparison(launchPath, source); return await readComparisonImageContent( comparison.repoRoot, - comparison.newRef, - comparison.oldRef, + comparison.newSha, + comparison.oldSha, comparison.status, requestedPath, comparison.sourceLabel, @@ -410,8 +419,8 @@ const readCommitStateFromComparison = async (launchPath, comparison) => { const [commitMetadata, state, generatedAttributeStates] = await Promise.all([ readCommitMetadataForCommit( comparison.repoRoot, - comparison.newRef, - comparison.oldRef, + comparison.newSha, + comparison.oldSha, comparison.status, ), readResolvedComparisonState(launchPath, comparison), @@ -531,7 +540,7 @@ const readBranchImageContent = (launchPath, input, requestedPath) => /** * Reduce a `branch-working-tree` input (which may or may not already carry a - * resolved baseRef/headRef) down to the plain branch/branch-diff shape that + * resolved baseSha/headSha) down to the plain branch/branch-diff shape that * {@link readBranchState} already understands. * @param {string | BranchSource | BranchDiffSource | BranchWorkingTreeSource} input * @returns {string | BranchSource | BranchDiffSource} @@ -541,8 +550,8 @@ const toBranchComparisonInput = (input) => { return input; } - return input.baseRef && input.headRef - ? { baseRef: input.baseRef, headRef: input.headRef, ref: input.ref, type: 'branch-diff' } + return input.baseSha && input.headSha + ? { baseSha: input.baseSha, headSha: input.headSha, ref: input.ref, type: 'branch-diff' } : { ref: input.ref, type: 'branch' }; }; @@ -614,8 +623,8 @@ const mergeBranchAndWorkingTreeState = (branchState, workingTreeState) => { files, generatedAt: Date.now(), source: { - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, + baseSha: branchSource.baseSha, + headSha: branchSource.headSha, ref: branchSource.ref, type: 'branch-working-tree', }, @@ -642,18 +651,18 @@ const readBranchWorkingTreeState = async (launchPath, input, options = {}) => { /** * By the time a section/image content request comes in for a * `branch-working-tree` source, that source is always the fully resolved - * copy round-tripped from `RepositoryState.source` (baseRef/headRef are only + * copy round-tripped from `RepositoryState.source` (baseSha/headSha are only * absent momentarily, at CLI-argument construction time, before the initial * state has been read). * @param {BranchWorkingTreeSource} source * @returns {BranchDiffSource} */ const toResolvedBranchDiffSource = (source) => { - if (!source.baseRef || !source.headRef) { + if (!source.baseSha || !source.headSha) { throw new Error('Cannot load branch-working-tree content before the branch diff is resolved.'); } - return { baseRef: source.baseRef, headRef: source.headRef, ref: source.ref, type: 'branch-diff' }; + return { baseSha: source.baseSha, headSha: source.headSha, ref: source.ref, type: 'branch-diff' }; }; /** @@ -706,8 +715,8 @@ const listRepositoryHistory = async (launchPath, limit = 200, ref = 'HEAD') => { const entries = []; for (const record of raw.split('\x1e')) { - const [ref, parents, committedAt, subject, author, email] = record.trim().split('\x1f'); - if (!ref || !committedAt || subject == null) { + const [sha, parentShas, committedAt, subject, author, email] = record.trim().split('\x1f'); + if (!sha || !committedAt || subject == null) { continue; } @@ -719,8 +728,8 @@ const listRepositoryHistory = async (launchPath, limit = 200, ref = 'HEAD') => { author: author || '', committedAt: Number(committedAt) * 1000, gravatarUrl, - parents: parents ? parents.split(' ') : [], - ref, + parentShas: parentShas ? /** @type {Array} */ (parentShas.split(' ')) : [], + sha: /** @type {GitSha} */ (sha), subject, }); } diff --git a/electron/git-state/comparison.cjs b/electron/git-state/comparison.cjs index a34ed280..f72b8ebf 100644 --- a/electron/git-state/comparison.cjs +++ b/electron/git-state/comparison.cjs @@ -13,20 +13,21 @@ const { createEmptyFileContent, readGitFiles } = require('./git-files.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult + * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource * @typedef {import('./common.cjs').StatusItem} StatusItem */ -/** @param {string} newRef @param {string | undefined} oldRef @param {ReadonlyArray} paths */ -const createComparisonPatchArgs = (newRef, oldRef, paths) => - oldRef - ? ['diff', '--patch', '--no-ext-diff', '--find-renames', oldRef, newRef, '--', ...paths] - : ['show', '--format=', '--patch', '--no-ext-diff', '--find-renames', newRef, '--', ...paths]; +/** @param {GitSha} newSha @param {GitSha | undefined} oldSha @param {ReadonlyArray} paths */ +const createComparisonPatchArgs = (newSha, oldSha, paths) => + oldSha + ? ['diff', '--patch', '--no-ext-diff', '--find-renames', oldSha, newSha, '--', ...paths] + : ['show', '--format=', '--patch', '--no-ext-diff', '--find-renames', newSha, '--', ...paths]; -/** @param {string} repoRoot @param {string} newRef @param {string | undefined} oldRef @param {string} path */ -const readComparisonPatch = (repoRoot, newRef, oldRef, path) => - git(repoRoot, createComparisonPatchArgs(newRef, oldRef, [path])); +/** @param {string} repoRoot @param {GitSha} newSha @param {GitSha | undefined} oldSha @param {string} path */ +const readComparisonPatch = (repoRoot, newSha, oldSha, path) => + git(repoRoot, createComparisonPatchArgs(newSha, oldSha, [path])); /** @param {ReadonlyArray} values @param {number} size */ const chunk = (values, size) => { @@ -48,11 +49,11 @@ const splitCommitPatch = (patch) => /** * @param {string} repoRoot - * @param {string} newRef - * @param {string | undefined} oldRef + * @param {GitSha} newSha + * @param {GitSha | undefined} oldSha * @param {ReadonlyArray>} items */ -const readComparisonPatches = async (repoRoot, newRef, oldRef, items) => { +const readComparisonPatches = async (repoRoot, newSha, oldSha, items) => { /** @type {Map} */ const patches = new Map(); @@ -64,7 +65,7 @@ const readComparisonPatches = async (repoRoot, newRef, oldRef, items) => { continue; } - const patch = await git(repoRoot, createComparisonPatchArgs(newRef, oldRef, itemChunk)); + const patch = await git(repoRoot, createComparisonPatchArgs(newSha, oldSha, itemChunk)); const patchChunks = splitCommitPatch(patch); if (patchChunks.length === itemChunk.length) { @@ -74,7 +75,7 @@ const readComparisonPatches = async (repoRoot, newRef, oldRef, items) => { } else { await Promise.all( itemChunk.map(async (path) => { - patches.set(path, await readComparisonPatch(repoRoot, newRef, oldRef, path)); + patches.set(path, await readComparisonPatch(repoRoot, newSha, oldSha, path)); }), ); } @@ -131,34 +132,34 @@ const createComparisonSection = (ref, item, oldFile, newFile, patch) => /** * @param {Map | import('./common.cjs').FileContentResult>} oldFiles - * @param {string | undefined} oldRef + * @param {GitSha | undefined} oldSha * @param {Pick} item */ -const getOldComparisonFile = (oldFiles, oldRef, item) => - oldRef +const getOldComparisonFile = (oldFiles, oldSha, item) => + oldSha ? oldFiles.get(item.oldPath || item.path) || createEmptyFileContent(item.oldPath || item.path) : createEmptyFileContent(item.oldPath || item.path); /** * @param {string} repoRoot - * @param {string} newRef - * @param {string | undefined} oldRef + * @param {GitSha} newSha + * @param {GitSha | undefined} oldSha * @param {ReadonlyArray>} status * @param {{force?: boolean}} [options] */ -const readComparisonFiles = async (repoRoot, newRef, oldRef, status, options = {}) => { +const readComparisonFiles = async (repoRoot, newSha, oldSha, status, options = {}) => { const [oldFiles, newFiles] = await Promise.all([ - oldRef + oldSha ? readGitFiles( repoRoot, - oldRef, + oldSha, status.map((item) => item.oldPath || item.path), options, ) : Promise.resolve(new Map()), readGitFiles( repoRoot, - newRef, + newSha, status.map((item) => item.path), options, ), @@ -170,29 +171,29 @@ const readComparisonFiles = async (repoRoot, newRef, oldRef, status, options = { /** * @param {{ * launchPath: string; - * newRef: string; - * oldRef?: string; + * newSha: GitSha; + * oldSha?: GitSha; * repoRoot: string; - * source: ReviewSource; + * source: import('../../core/types.ts').ResolvedReviewSource; * status: ReadonlyArray>; * }} input - * @returns {Promise} + * @returns {Promise>} */ -const readComparisonState = async ({ launchPath, newRef, oldRef, repoRoot, source, status }) => { - const { oldFiles, newFiles } = await readComparisonFiles(repoRoot, newRef, oldRef, status); +const readComparisonState = async ({ launchPath, newSha, oldSha, repoRoot, source, status }) => { + const { oldFiles, newFiles } = await readComparisonFiles(repoRoot, newSha, oldSha, status); const readyItems = status.filter((item) => { - const oldFile = getOldComparisonFile(oldFiles, oldRef, item); + const oldFile = getOldComparisonFile(oldFiles, oldSha, item); const newFile = newFiles.get(item.path) || createEmptyFileContent(item.path); return summarizeContent(oldFile, newFile).loadState === 'ready'; }); - const patches = await readComparisonPatches(repoRoot, newRef, oldRef, readyItems); + const patches = await readComparisonPatches(repoRoot, newSha, oldSha, readyItems); /** @type {Array} */ const files = status .map((item) => createComparisonFile( - newRef, + newSha, item, - getOldComparisonFile(oldFiles, oldRef, item), + getOldComparisonFile(oldFiles, oldSha, item), newFiles.get(item.path) || createEmptyFileContent(item.path), patches.get(item.path) || '', ), @@ -210,8 +211,8 @@ const readComparisonState = async ({ launchPath, newRef, oldRef, repoRoot, sourc /** * @param {string} repoRoot - * @param {string} newRef - * @param {string | undefined} oldRef + * @param {GitSha} newSha + * @param {GitSha | undefined} oldSha * @param {ReadonlyArray>} status * @param {string} requestedPath * @param {string} sourceLabel @@ -219,8 +220,8 @@ const readComparisonState = async ({ launchPath, newRef, oldRef, repoRoot, sourc */ const readComparisonSectionContent = async ( repoRoot, - newRef, - oldRef, + newSha, + oldSha, status, requestedPath, sourceLabel, @@ -234,26 +235,26 @@ const readComparisonSectionContent = async ( const { oldFiles, newFiles } = await readComparisonFiles( repoRoot, - newRef, - oldRef, + newSha, + oldSha, [item], options, ); - const oldFile = getOldComparisonFile(oldFiles, oldRef, item); + const oldFile = getOldComparisonFile(oldFiles, oldSha, item); const newFile = newFiles.get(item.path) || createEmptyFileContent(item.path); const summary = summarizeContent(oldFile, newFile); const patch = summary.loadState === 'ready' - ? await readComparisonPatch(repoRoot, newRef, oldRef, item.path) + ? await readComparisonPatch(repoRoot, newSha, oldSha, item.path) : ''; - return createComparisonSection(newRef, item, oldFile, newFile, patch); + return createComparisonSection(newSha, item, oldFile, newFile, patch); }; /** * @param {string} repoRoot - * @param {string} newRef - * @param {string | undefined} oldRef + * @param {GitSha} newSha + * @param {GitSha | undefined} oldSha * @param {ReadonlyArray>} status * @param {string} requestedPath * @param {string} sourceLabel @@ -261,8 +262,8 @@ const readComparisonSectionContent = async ( */ const readComparisonImageContent = async ( repoRoot, - newRef, - oldRef, + newSha, + oldSha, status, requestedPath, sourceLabel, @@ -275,8 +276,8 @@ const readComparisonImageContent = async ( } const [oldImage, newImage] = await Promise.all([ - oldRef ? readGitImageFile(repoRoot, oldRef, item.oldPath || item.path) : undefined, - readGitImageFile(repoRoot, newRef, item.path), + oldSha ? readGitImageFile(repoRoot, oldSha, item.oldPath || item.path) : undefined, + readGitImageFile(repoRoot, newSha, item.path), ]); if (!oldImage && !newImage) { diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index 3ae66600..9bc2438d 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -23,6 +23,8 @@ const { parseReviewUrl } = require('../review-source.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult + * @typedef {import('../../core/types.ts').GitSha} GitSha + * @typedef {import('../../core/types.ts').HistoryEntry} HistoryEntry * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource @@ -599,12 +601,12 @@ const readRepositoryCommits = async (repoRoot, pullRequest, sha, limit) => { return commits; }; -/** @param {GitHubCommit} commit @param {'base' | 'pull-request'} [scope] */ +/** @param {GitHubCommit} commit @param {'base' | 'pull-request'} [scope] @returns {HistoryEntry | null} */ const normalizeGitHubCommit = (commit, scope) => { - const ref = commit.sha; + const sha = commit.sha; const committedAt = Date.parse(commit.commit?.author?.date || ''); const message = commit.commit?.message || ''; - if (!ref || !message || !Number.isFinite(committedAt)) { + if (!sha || !message || !Number.isFinite(committedAt)) { return null; } @@ -612,8 +614,11 @@ const normalizeGitHubCommit = (commit, scope) => { author: commit.commit?.author?.name || '', committedAt, gravatarUrl: commit.author?.avatar_url, - parents: commit.parents?.map((parent) => parent.sha).filter(Boolean) || [], - ref, + parentShas: + commit.parents?.flatMap((parent) => + parent.sha ? [/** @type {GitSha} */ (parent.sha)] : [], + ) || [], + sha: /** @type {GitSha} */ (sha), ...(scope ? { scope } : {}), subject: message.split('\n')[0], }; @@ -637,8 +642,13 @@ const listPullRequestHistory = async (launchPath, source, limit = 200) => { : []; return { entries: [ - ...commits.map(normalizeGitHubPullRequestCommit).filter(Boolean).reverse(), - ...baseCommits.map((commit) => normalizeGitHubCommit(commit, 'base')).filter(Boolean), + ...commits + .map(normalizeGitHubPullRequestCommit) + .filter((entry) => entry != null) + .reverse(), + ...baseCommits + .map((commit) => normalizeGitHubCommit(commit, 'base')) + .filter((entry) => entry != null), ], root: repoRoot, }; diff --git a/electron/walkthrough-commit.cjs b/electron/walkthrough-commit.cjs index 8063e6ba..b9cb26f5 100644 --- a/electron/walkthrough-commit.cjs +++ b/electron/walkthrough-commit.cjs @@ -19,6 +19,7 @@ const loadPty = () => require('node-pty'); /** * @typedef {import('../core/types.ts').WalkthroughCommitRequest} WalkthroughCommitRequest * @typedef {import('../core/types.ts').WalkthroughCommitResult} WalkthroughCommitResult + * @typedef {import('../core/types.ts').GitSha} GitSha */ // Cols must match the xterm instance in @@ -150,7 +151,7 @@ const createWalkthroughCommit = async (repoPath, request, onOutput) => { rmSync(tempDirectory, { force: true, recursive: true }); } const hash = (await git(repoPath, ['rev-parse', 'HEAD'])).trim(); - return { hash, status: 'committed' }; + return { sha: /** @type {GitSha} */ (hash), status: 'committed' }; } catch (error) { return { reason: error instanceof Error ? error.message : String(error), diff --git a/electron/window-identity.cjs b/electron/window-identity.cjs index 6c0eb03c..49c9a469 100644 --- a/electron/window-identity.cjs +++ b/electron/window-identity.cjs @@ -3,13 +3,16 @@ const { execFileSync } = require('node:child_process'); const { realpathSync } = require('node:fs'); const { dirname, resolve } = require('node:path'); -const { parseReviewUrl } = require('./review-source.cjs'); +const { + decodeReviewSource, + decodeResolvedReviewSource, + formatReviewSourceIdentity, +} = require('../core/lib/review-source-codec.cjs'); /** * @typedef {import('../core/types.ts').ReviewSource} ReviewSource * @typedef {import('../core/types.ts').CodiffLaunchOptions} CodiffLaunchOptions * @typedef {{key: string; repositoryRoot: string; sourceKey: string}} WindowIdentity - * @typedef {{number: number; owner: string; repo: string}} ParsedPullRequest */ /** @param {string} path */ @@ -77,103 +80,72 @@ const resolveMergeBase = (repositoryRoot, baseRef, headRef) => { } }; -/** @param {Extract} source */ -const getPullRequestSourceKey = (source) => { - const review = parseReviewUrl(source.url); - if (review?.provider === 'gitlab') { - return `pull-request:gitlab:${review.host}/${review.projectPath.toLowerCase()}#${ - review.number - }`; - } - const pullRequest = - source.owner && source.repo && source.number - ? { - number: source.number, - owner: source.owner, - repo: source.repo, - } - : review?.provider === 'github' - ? /** @type {ParsedPullRequest} */ ({ - number: review.number, - owner: review.owner, - repo: review.repo, - }) - : null; - - return pullRequest - ? `pull-request:${pullRequest.owner.toLowerCase()}/${pullRequest.repo.toLowerCase()}#${ - pullRequest.number - }` - : null; -}; - /** @param {string} repositoryRoot @param {ReviewSource} [source] */ const getSourceKey = (repositoryRoot, source = { type: 'working-tree' }) => { - if (source.type === 'working-tree') { - return 'working-tree'; - } - if (source.type === 'commit') { const commit = resolveCommitRef(repositoryRoot, source.ref); - return commit ? `commit:${commit}` : null; + return commit + ? formatReviewSourceIdentity({ sha: commit, type: /** @type {const} */ ('commit') }) + : null; } if (source.type === 'branch') { const head = resolveCommitRef(repositoryRoot, 'HEAD'); const target = resolveCommitRef(repositoryRoot, source.ref); const nextBase = target && head ? resolveMergeBase(repositoryRoot, target, head) : null; - return nextBase && head ? `branch-diff:${source.ref}:${nextBase}:${head}` : null; + return nextBase && head + ? formatReviewSourceIdentity({ + baseSha: nextBase, + headSha: head, + ref: source.ref, + type: /** @type {const} */ ('branch-diff'), + }) + : null; } if (source.type === 'branch-diff') { - const base = resolveCommitRef(repositoryRoot, source.baseRef); - const head = resolveCommitRef(repositoryRoot, source.headRef); - return base && head ? `branch-diff:${source.ref}:${base}:${head}` : null; + const base = resolveCommitRef(repositoryRoot, source.baseSha); + const head = resolveCommitRef(repositoryRoot, source.headSha); + return base && head + ? formatReviewSourceIdentity({ ...source, baseSha: base, headSha: head }) + : null; } if (source.type === 'branch-working-tree') { if ( - typeof source.baseRef === 'string' && - typeof source.headRef === 'string' && - source.baseRef && - source.headRef + typeof source.baseSha === 'string' && + typeof source.headSha === 'string' && + source.baseSha && + source.headSha ) { - const base = resolveCommitRef(repositoryRoot, source.baseRef); - const head = resolveCommitRef(repositoryRoot, source.headRef); - return base && head ? `branch-working-tree:${source.ref}:${base}:${head}` : null; + const base = resolveCommitRef(repositoryRoot, source.baseSha); + const head = resolveCommitRef(repositoryRoot, source.headSha); + return base && head + ? formatReviewSourceIdentity({ ...source, baseSha: base, headSha: head }) + : null; } const head = resolveCommitRef(repositoryRoot, 'HEAD'); const target = resolveCommitRef(repositoryRoot, source.ref); const nextBase = target && head ? resolveMergeBase(repositoryRoot, target, head) : null; - return nextBase && head ? `branch-working-tree:${source.ref}:${nextBase}:${head}` : null; - } - - if (source.type === 'pull-request') { - return getPullRequestSourceKey(source); + return nextBase && head + ? formatReviewSourceIdentity({ + baseSha: nextBase, + headSha: head, + ref: source.ref, + type: /** @type {const} */ ('branch-working-tree'), + }) + : null; } - return null; + const decoded = decodeReviewSource(source); + return decoded ? formatReviewSourceIdentity(decoded) : null; }; -/** @param {ReviewSource} source */ +/** @param {import('../core/types.ts').ResolvedReviewSource} source */ const getResolvedSourceKey = (source) => { - if (source.type === 'working-tree') { - return 'working-tree'; - } - if (source.type === 'commit') { - return `commit:${source.ref.toLowerCase()}`; - } - if (source.type === 'branch-diff') { - return `branch-diff:${source.ref}:${source.baseRef.toLowerCase()}:${source.headRef.toLowerCase()}`; - } - if (source.type === 'branch-working-tree' && source.baseRef && source.headRef) { - return `branch-working-tree:${source.ref}:${source.baseRef.toLowerCase()}:${source.headRef.toLowerCase()}`; - } - if (source.type === 'pull-request') { - return getPullRequestSourceKey(source); - } - return null; + const resolved = decodeResolvedReviewSource(source); + return resolved ? formatReviewSourceIdentity(resolved) : null; }; /** @param {string} repositoryPath @param {Partial} [launchOptions] */ @@ -209,11 +181,8 @@ const getWindowIdentity = (repositoryPath, launchOptions = {}) => { : null; }; -/** @param {string} repositoryPath @param {ReviewSource} source */ -const getWindowIdentityForSource = (repositoryPath, source) => - getWindowIdentity(repositoryPath, { source }); -/** @param {{root: string; source: ReviewSource}} state */ +/** @param {{root: string; source: import('../core/types.ts').ResolvedReviewSource}} state */ const getWindowIdentityForRepositoryState = (state) => { const repositoryRoot = getRealPath(state.root); const sourceKey = getResolvedSourceKey(state.source); @@ -248,5 +217,4 @@ module.exports = { findMatchingWindowIdentity, getWindowIdentity, getWindowIdentityForRepositoryState, - getWindowIdentityForSource, }; diff --git a/vite.config.ts b/vite.config.ts index 2f5e327e..15a540e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -113,6 +113,7 @@ export default defineConfig({ ], maxWorkers: testWorkers, setupFiles: ['./core/__tests__/setup.ts'], + testTimeout: 15_000, }, worker: { format: 'es', From 8405748c5d6e747a30078f3f6bcad216d00ac350 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 10:34:32 -0500 Subject: [PATCH 02/17] Move existing Core types into domain files Move unchanged definitions from `core/types.ts` into `review-identity`, `review-comments`, `review-history`, `walkthrough`, and `generation`. Keep `core/types.ts` as the compatibility re-export, publish `./types`, and make no runtime behavior change. --- core/README.md | 5 + core/package.json | 15 +- core/types.ts | 887 +--------------------------------- core/types/generation.ts | 65 +++ core/types/review-comments.ts | 97 ++++ core/types/review-history.ts | 135 ++++++ core/types/review-identity.ts | 162 +++++++ core/types/walkthrough.ts | 190 ++++++++ 8 files changed, 673 insertions(+), 883 deletions(-) create mode 100644 core/types/generation.ts create mode 100644 core/types/review-comments.ts create mode 100644 core/types/review-history.ts create mode 100644 core/types/review-identity.ts create mode 100644 core/types/walkthrough.ts diff --git a/core/README.md b/core/README.md index 461c15a9..30b0c3fc 100644 --- a/core/README.md +++ b/core/README.md @@ -24,3 +24,8 @@ a new head tomorrow. `GitSha` is only a full object id. Branch names, tags, bookmarks, and PR/MR numbers stay ordinary strings. A `Revision` carries a SHA only when it is a commit. The working copy and index have no SHA. + +`RepositoryHistory.entries` is a newest-first navigation feed. Review commit +stacks are separate parent-before-child values normalized by Core, while review +version timelines remain earlier-before-later. Consumers validate those +contracts instead of reversing provider values locally. diff --git a/core/package.json b/core/package.json index c0c36fd4..9a68dcc8 100644 --- a/core/package.json +++ b/core/package.json @@ -13,7 +13,15 @@ "directory": "core" }, "files": [ - "dist" + "dist", + "*.css", + "*.ts", + "*.tsx", + "app", + "config", + "fonts", + "lib", + "types" ], "type": "module", "sideEffects": [ @@ -44,6 +52,11 @@ "@nkzw/codiff-source": "./App.css", "default": "./dist/style.css" }, + "./types": { + "types": "./dist/types.d.ts", + "@nkzw/codiff-source": "./types.ts", + "default": "./dist/types.d.ts" + }, "./walkthrough": { "types": "./dist/lib/narrative-walkthrough-diff.d.ts", "@nkzw/codiff-source": "./lib/narrative-walkthrough-diff.js", diff --git a/core/types.ts b/core/types.ts index 23034567..8cc3798f 100644 --- a/core/types.ts +++ b/core/types.ts @@ -1,835 +1,16 @@ -import type { MarkdownAnnotationAnchor } from '@nkzw/mdx-editor'; import type { CodiffDiffStyle } from './config/types.ts'; -export type DiffSection = { - binary: boolean; - id: string; - kind: 'commit' | 'pull-request' | 'staged' | 'unstaged'; - loadState?: 'binary' | 'deferred' | 'directory' | 'error' | 'ready' | 'too-large'; - newFile?: { - cacheKey?: string; - contents: string; - name: string; - }; - oldFile?: { - cacheKey?: string; - contents: string; - name: string; - }; - patch: string; - /** Provider-neutral revision range represented by this diff section. */ - range?: DiffRange; - summary?: { - canLoad?: boolean; - fileCount?: number; - fingerprint?: string; - limit?: number; - reason: string; - size?: number; - }; -}; - -export type GitFileStatus = - | 'added' - | 'conflicted' - | 'deleted' - | 'modified' - | 'renamed' - | 'untracked'; - -export type ChangedFile = { - fingerprint: string; - generated?: boolean; - oldPath?: string; - path: string; - sections: ReadonlyArray; - status: GitFileStatus; -}; - -export type ReviewAuthor = { - avatarUrl?: string; - login: string; - name?: string; - url?: string; -}; - -export type PullRequestReviewer = ReviewAuthor & { - approved: boolean; - id: string; -}; - -export type PullRequestReviewActionStatus = { - disabled?: boolean; - reason?: string; -}; - -export type PullRequestReviewStatus = { - approve?: PullRequestReviewActionStatus; - close?: PullRequestReviewActionStatus; - comment?: PullRequestReviewActionStatus; - markReady?: PullRequestReviewActionStatus; - requestChanges?: PullRequestReviewActionStatus; -}; - -export type PullRequestMergeCheckStatus = 'failed' | 'neutral' | 'pending' | 'success'; - -export type PullRequestMergeCheck = { - detail?: string; - label: string; - status: PullRequestMergeCheckStatus; - url?: string; -}; - -export type PullRequestMergeOptions = { - removeSourceBranch: boolean; - squash: boolean; -}; - -export type PullRequestCodeQualityFinding = { - description: string; - engineName?: string; - filePath: string; - fingerprint: string; - lineNumber: number; - severity: 'blocker' | 'critical' | 'info' | 'major' | 'minor' | 'unknown'; - status: 'existing' | 'new' | 'resolved'; - url?: string; -}; - -export type PullRequestMergeState = { - autoMergeEnabled: boolean; - canCancelAutoMerge: boolean; - canMerge: boolean; - canSetAutoMerge: boolean; - checks: ReadonlyArray; - detailedStatus?: string; - forceRemoveSourceBranch: boolean; - mergeError?: string; - options: PullRequestMergeOptions; - reason?: string; - sha: string; - status: 'blocked' | 'checking' | 'closed' | 'merged' | 'ready' | 'waiting'; - statusLabel: string; -}; - -/** A full, resolved Git commit object ID. Never use this for a ref selector. */ -export type GitSha = string & { readonly __gitSha: unique symbol }; - -export type ReviewSource = - | { - type: 'working-tree'; - } - | { - ref: string; - type: 'commit'; - } - | { - ref: string; - type: 'branch'; - } - | { - /** Resolved base commit for a branch diff snapshot. */ - baseSha: GitSha; - /** Resolved head commit for a branch diff snapshot. */ - headSha: GitSha; - /** Target branch the current branch was compared against. */ - ref: string; - type: 'branch-diff'; - } - | { - /** - * Resolved base commit for the branch part of the comparison. Optional - * because the CLI can construct this source from just a branch name - * (`codiff main`), before merge-base resolution happens; the resolved - * state's `source` always carries a concrete value. - */ - baseSha?: GitSha; - /** Resolved head commit for the branch part of the comparison. See {@link baseSha}. */ - headSha?: GitSha; - /** Target branch the current branch was compared against. */ - ref: string; - type: 'branch-working-tree'; - } - | { - /** Base ref (left side). For symmetric ranges the diff starts at its merge-base with head. */ - base: string; - /** Head ref (right side). */ - head: string; - /** `true` for `base...head` (merge-base), `false` for `base..head` (direct). */ - symmetric: boolean; - type: 'range'; - } - | { - author?: ReviewAuthor; - canEditDescription?: boolean; - canEditReviewers?: boolean; - canEditTitle?: boolean; - description?: string; - draft?: boolean; - headSha?: string; - host?: string; - mergeState?: PullRequestMergeState; - number?: number; - owner?: string; - projectPath?: string; - provider?: 'github' | 'gitlab'; - repo?: string; - reviewers?: ReadonlyArray; - reviewStatus?: PullRequestReviewStatus; - title?: string; - type: 'pull-request'; - url: string; - }; - -/** Sources that can be entered from the palette or native application menu. */ -export type OpenReviewSourceKind = 'branch' | 'commit' | 'pull-request'; - -/** - * A source after Git resolution. Commit selectors become full SHAs, and a - * branch-working-tree snapshot always carries the exact branch comparison it - * was read against. Persisted and rendered sources use this shape so a `ref` - * never silently changes from a selector into object identity. - */ -export type ResolvedReviewSource = - | Exclude - | { - sha: GitSha; - type: 'commit'; - } - | { - baseSha: GitSha; - headSha: GitSha; - ref: string; - type: 'branch-working-tree'; - }; - -export type HistoryEntry = { - author: string; - committedAt: number; - gravatarUrl?: string; - parentShas: ReadonlyArray; - scope?: 'base' | 'pull-request'; - sha: GitSha; - subject: string; -}; - -export type CommitMetadataPerson = { - date: string; - email: string; - gravatarUrl?: string; - name: string; -}; - -export type CommitMetadataFile = { - additions?: number; - binary: boolean; - deletions?: number; - oldPath?: string; - path: string; - status: GitFileStatus; -}; - -export type CommitMetadata = { - author: CommitMetadataPerson; - body: string; - committer: CommitMetadataPerson; - files: ReadonlyArray; - parentShas: ReadonlyArray; - refs: ReadonlyArray; - sha: GitSha; - shortSha: string; - signature: { - key?: string; - signer?: string; - status: string; - }; - stats: { - additions: number; - binaryFiles: number; - deletions: number; - files: number; - renamedFiles: number; - }; - subject: string; - trailers: ReadonlyArray<{ - key: string; - value: string; - }>; -}; - -export type RepositoryHistory = { - entries: ReadonlyArray; - root: string; -}; - -export type RepositoryState = { - branch: string | null; - codeQualityFindings?: ReadonlyArray; - commitMetadata?: CommitMetadata; - files: ReadonlyArray; - generalComments?: ReadonlyArray; - generatedAt: number; - launchPath: string; - reviewComments?: ReadonlyArray; - root: string; - source: ResolvedReviewSource; -}; +export type * from './types/generation.ts'; +export type * from './types/review-comments.ts'; +export type * from './types/review-history.ts'; +export type * from './types/review-identity.ts'; +export type * from './types/walkthrough.ts'; export type CodiffFeatureFlags = { planSharing: boolean; walkthroughSharing: boolean; }; -export type WalkthroughProgressPhase = 'agent-generation' | 'response-received'; - -export type WalkthroughProgressEvent = { - phase: WalkthroughProgressPhase; -}; - -export type CodiffMarkdownDocument = { - content: string; - id: string; - kind: 'plan' | 'repository'; - path: string; - version: string; -}; - -export type SaveMarkdownDocumentRequest = { - baseVersion: string; - content: string; - kind: CodiffMarkdownDocument['kind']; - path: string; -}; - -export type SaveMarkdownDocumentResult = - | { - document: CodiffMarkdownDocument; - status: 'conflict'; - } - | { - document: CodiffMarkdownDocument; - status: 'saved'; - }; - -export type PlanCommentAuthor = { - avatarUrl?: string; - email?: string; - id: string; - name: string; - username?: string; -}; - -export type PlanCommentMessage = { - author: PlanCommentAuthor; - body: string; - canDelete?: boolean; - canEdit?: boolean; - createdAt: string; - id: string; - updatedAt: string; -}; - -export type PlanCommentThread = { - anchor: MarkdownAnnotationAnchor; - canReply?: boolean; - canResolve?: boolean; - createdAt: string; - createdBy: PlanCommentAuthor; - id: string; - messages: ReadonlyArray; - resolution?: { - reason: 'agent-handled' | 'anchor-removed'; - resolvedAt: string; - }; - status: 'open' | 'resolved'; - updatedAt: string; -}; - -export type PlanReview = { - document: { - id: string; - path: string; - version: string; - }; - threads: ReadonlyArray; - version: 1; -}; - -export type PlanHandoffStatus = 'closed' | 'done'; - -export type SharedWalkthroughSnapshot = { - branch: string | null; - codeQualityFindings?: ReadonlyArray; - codiffVersion: string; - exportedAt: string; - files: ReadonlyArray; - kind: 'codiff-walkthrough-share'; - preferences: Pick< - CodiffPreferences, - 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' - >; - repository: { - generalComments?: ReadonlyArray; - root: string; - source: ResolvedReviewSource; - title?: string; - }; - reviewComments?: ReadonlyArray; - version: 1; - walkthrough: NarrativeWalkthrough; -}; - -export type SharedPlanSnapshot = { - codiffVersion: string; - document: { - content: string; - name: string; - title: string; - }; - exportedAt: string; - kind: 'codiff-plan-share'; - preferences: Pick; - review: { - threads: ReadonlyArray; - version: 1; - }; - source?: { - agent?: 'claude' | 'codex' | 'opencode' | 'pi'; - sessionId?: string; - }; - version: 1; -}; - -export type WalkthroughShareManifestV1 = SharedWalkthroughSnapshot; - -export type ShareResult = - | { - status: 'uploaded'; - url: string; - } - | { - reason: string; - status: 'failed'; - }; - -export type SharePlanResult = ShareResult; -export type ShareWalkthroughResult = ShareResult; - -/** Mutable display text for a revision; never durable object identity. */ -export type RevisionLabel = { - kind: 'bookmark' | 'branch' | 'commit' | 'review-marker' | 'tag' | 'version'; - text: string; - url?: string; -}; - -/** Provider-neutral revision identity, distinct from mutable labels and selectors. */ -export type Revision = - | { - aliases?: ReadonlyArray; - kind?: 'commit'; - label: RevisionLabel; - sha: GitSha; - } - | { - aliases?: ReadonlyArray; - kind: 'index'; - label: RevisionLabel; - stage?: 1 | 2 | 3; - } - | { - aliases?: ReadonlyArray; - kind: 'working-copy'; - label: RevisionLabel; - }; - -/** Provider-neutral base/head identity for one review range. Null means that file side is absent. */ -export type DiffRange = { - base: Revision | null; - head: Revision | null; -}; - -export type WalkthroughContext = { - changedFiles?: ReadonlyArray<{ - path: string; - rationale?: string; - role: string; - }>; - constraints?: ReadonlyArray; - decisions?: ReadonlyArray; - implementationSummary?: string; - messages?: ReadonlyArray<{ - role: 'assistant' | 'user'; - text: string; - }>; - objective?: string; - risks?: ReadonlyArray; - source: { - generatedAt: string; - threadId?: string; - type: - | 'codex-session' - | 'codex-session-excerpt' - | 'claude-session' - | 'claude-session-excerpt' - | 'opencode-session' - | 'opencode-session-excerpt' - | 'pi-session' - | 'pi-session-excerpt'; - }; - validation?: ReadonlyArray; - version: 1; -}; - -export type CodiffLaunchOptions = { - agentBackend?: 'codex' | 'claude' | 'opencode' | 'pi'; - applyUpdate?: boolean; - claudeSessionId?: string; - codexSessionId?: string; - opencodeSessionId?: string; - piSessionId?: string; - /** Exact Markdown file opened by the blocking plan handoff. */ - planFile?: string; - /** Result file used to resume the waiting agent process. */ - planResultFile?: string; - repositoryPathProvided: boolean; - source?: ReviewSource; - walkthrough: boolean; - walkthroughContext?: WalkthroughContext; - /** Path to a pre-authored {@link NarrativeWalkthrough} JSON file (--walkthrough-file). */ - walkthroughFile?: string; -}; - -export type AgentSkillStatus = { - installed: boolean; - path: string; -}; - -/** @deprecated Use {@link AgentSkillStatus}. */ -export type CodexSkillStatus = AgentSkillStatus; - -export type TerminalHelperStatus = { - command: string; - installed: boolean; - path: string; -}; - -/** - * Narrative Walkthrough. The agent authors chapters, stops, and support groups - * around deterministic hunk ids. Codiff resolves those ids against the live diff - * and computes file paths, anchors, and line counts. - */ -export type WalkthroughIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'beaker' | 'doc' | 'gear'; - -/** Where a walkthrough hunk points into the live diff. */ -export type WalkthroughAnchor = { - /** Human-readable location, e.g. 'src/App.tsx:311' or 'src/hooks/useHunkOrder.ts (new)'. */ - display: string; - /** End line on the {@link side} (inclusive). */ - endLine?: number; - /** Matches {@link DiffSection.id}, e.g. 'src/App.tsx:staged'. */ - sectionId?: string; - sectionKind?: DiffSection['kind']; - side?: 'additions' | 'deletions' | 'both'; - /** Start line on the {@link side}. */ - startLine?: number; -}; - -/** A short header note rendered above one focused walkthrough hunk diff. */ -export type WalkthroughHunkNote = { - body: string; - hunkId: string; -}; - -/** - * Change-type tag shown on a file row in the commit composer. Mirrors the - * walkthrough's narrative roles so a reviewer recognises each file at a glance. - */ -export type WalkthroughChangeType = - | 'fix' - | 'feature' - | 'refactor' - | 'test' - | 'generated' - | 'lockfile' - | 'snapshot' - | 'i18n' - | 'docs'; - -/** One resolved hunk selected by a walkthrough item, in agent-requested order. */ -export type WalkthroughHunk = { - added: number; - additionEnd?: number; - additionStart?: number; - anchor: WalkthroughAnchor; - deleted: number; - deletionEnd?: number; - deletionStart?: number; - id: string; - /** `synthetic` hunks represent binary, deferred, or metadata-only review units. */ - kind?: 'patch' | 'synthetic'; - oldPath?: string; - path: string; - status: GitFileStatus; -}; - -/** Shared hunk-backed fields for a stop or support group. */ -export type WalkthroughHunkGroup = { - added: number; - /** Change-type tag for the commit composer's file row. */ - changeType?: WalkthroughChangeType; - /** One-line note the generated commit body uses for this file (falls back to {@link summary}). */ - commitNote?: string; - deleted: number; - /** Deterministic hunk ids selected by the authoring agent, in display order. */ - hunkIds: ReadonlyArray; - /** Resolved hunks with Codiff-computed anchors, file paths, status, and line counts. */ - hunks: ReadonlyArray; - /** Stable within the document, e.g. 's1'. */ - id: string; - /** Optional header notes for individual hunk ids in this item. */ - notes?: ReadonlyArray; - /** Short, plain-text gist of the slice. */ - summary?: string; - title?: string; -}; - -/** One stop in the main walkthrough path. */ -export type WalkthroughStop = WalkthroughHunkGroup & { - importance: 'critical' | 'normal' | 'context'; - /** Agent narration (markdown / inline code). */ - prose: string; -}; - -/** A changed hunk group kept off the main path. */ -export type WalkthroughSupportGroup = WalkthroughHunkGroup & { - note?: string; - /** Why it is off the path, e.g. 'Generated' | 'Lockfile' | 'Snapshot' | 'Mechanical'. */ - reason: string; -}; - -/** A named chapter in the walkthrough. */ -export type WalkthroughChapter = { - blurb: string; - icon: WalkthroughIcon; - id: string; - stops: ReadonlyArray; - title: string; -}; - -/** - * Marks the walkthrough's diff as a staging set that can be committed and seeds - * the commit composer Codiff renders as the walkthrough's terminal stop. Only - * honored when {@link NarrativeWalkthrough.source} is a working tree โ€” you can - * only commit a live staging set, never a past commit, branch, or pull request. - */ -export type WalkthroughCommit = { - /** - * The agent-drafted commit body โ€” a few paragraphs of prose describing the - * change as a whole. Shown editable by default; the reviewer can rewrite it, - * or ask the agent to regenerate it for a narrowed file selection. - */ - body?: string; - /** Suggested first line for the commit message. */ - title?: string; -}; - -export type NarrativeWalkthrough = { - agent: 'codex' | 'claude' | 'opencode' | 'pi'; - chapters: ReadonlyArray; - /** - * When present, the diff is a committable staging set: Codiff adds a commit - * composer at the end of the walkthrough. Stripped unless `source` is a working tree. - */ - commit?: WalkthroughCommit; - /** The originating conversation, embedded for in-app Q&A. */ - context?: WalkthroughContext; - /** 1โ€“2 sentence summary of the change. */ - focus: string; - /** ISO timestamp. */ - generatedAt: string; - kind: 'narrative'; - /** Display string, e.g. '6 stops ยท 4 chapters'. */ - meta?: string; - repo: { - branch: string | null; - root: string; - }; - source: ResolvedReviewSource; - support: ReadonlyArray; - title: string; - version: 4; -}; - -export type NarrativeWalkthroughResult = - | { - status: 'ready'; - walkthrough: NarrativeWalkthrough; - } - | { - code?: 'CODEX_NOT_FOUND' | 'CLAUDE_NOT_FOUND' | 'OPENCODE_NOT_FOUND' | 'PI_NOT_FOUND'; - reason: string; - status: 'unavailable'; - }; - -export type NarrativeWalkthroughRequestOptions = { - /** Ignore an exact cache hit and replace it with a newly generated result. */ - force?: boolean; - /** - * The walkthrough currently shown. Regeneration uses its prose as continuity - * while re-anchoring every stop against the current diff. - */ - previousWalkthrough?: NarrativeWalkthrough; -}; - -/** Commit the selected files from a walkthrough's staging set. */ -export type WalkthroughCommitRequest = { - /** Body of the commit message (everything after the subject line). */ - body: string; - /** Repo-relative paths to commit; other staged changes are left untouched. */ - paths: ReadonlyArray; - source?: ResolvedReviewSource; - /** First line of the commit message. */ - subject: string; -}; - -export type WalkthroughCommitResult = - | { - /** Full SHA of the new commit. */ - sha: GitSha; - status: 'committed'; - } - | { - reason: string; - status: 'failed'; - }; - -/** - * Ask the connected agent to rewrite the commit message for the current file - * selection โ€” used when the reviewer drops files from the staging set and the - * pre-drafted body no longer matches what is being committed. - */ -export type WalkthroughCommitMessageRequest = { - /** The current body, given to the agent as the message to revise. */ - body: string; - /** Repo-relative paths still selected for the commit. */ - paths: ReadonlyArray; - source?: ResolvedReviewSource; - /** The current subject line. */ - subject: string; -}; - -export type WalkthroughCommitMessageResult = - | { - body: string; - status: 'ready'; - subject: string; - } - | { - reason: string; - status: 'unavailable'; - }; - -export type ReviewAssistantRequest = { - comment: { - anchor?: 'file' | 'line'; - body: string; - filePath: string; - lineNumber?: number; - sectionId: string; - side?: 'additions' | 'deletions'; - startLineNumber?: number; - startSide?: 'additions' | 'deletions'; - }; - source?: ResolvedReviewSource; - walkthroughNote?: { - action: 'review' | 'scan' | 'skim'; - context: string; - groupReason: string; - groupTitle: string; - impact: 'wide' | 'contained' | 'mechanical'; - reason: string; - }; -}; - -export type ReviewAssistantResult = - | { - reply: string; - status: 'ready'; - } - | { - code?: 'CODEX_NOT_FOUND' | 'CLAUDE_NOT_FOUND' | 'OPENCODE_NOT_FOUND' | 'PI_NOT_FOUND'; - reason: string; - status: 'unavailable'; - }; - -export type GitIdentity = { - email: string; - gravatarUrl?: string; - name: string; - username?: string; -}; - -export type DiffSectionContentRequest = { - force?: boolean; - kind: DiffSection['kind']; - path: string; - showWhitespace?: boolean; - source?: ResolvedReviewSource; -}; - -export type DefinitionSearchRequest = { - identifier: string; - kind: DiffSection['kind']; - lineNumber: number; - path: string; - side: 'additions' | 'deletions'; - source: ReviewSource; -}; - -export type DefinitionCandidate = { - canOpenInEditor: boolean; - kind: string; - line: string; - lineNumber: number; - path: string; - side: 'additions' | 'deletions'; -}; - -export type DefinitionSearchResult = - | { - candidates: ReadonlyArray; - identifier: string; - status: 'ready'; - } - | { - reason: string; - status: 'unavailable'; - }; - -export type DiffImageContentRequest = { - kind: DiffSection['kind']; - path: string; - source?: ResolvedReviewSource; -}; - -export type DiffImageRevision = { - dataUrl: string; - mimeType: string; - name: string; - size: number; -}; - -export type DiffImageContentResult = - | { - newImage?: DiffImageRevision; - oldImage?: DiffImageRevision; - status: 'ready'; - } - | { - reason: string; - status: 'unavailable'; - }; - export type CodiffTheme = 'system' | 'light' | 'dark'; export type CodiffPreferences = { @@ -847,7 +28,6 @@ export type CodiffPreferences = { reviewCommentsPrefix: string; showOutdated: boolean; showWhitespace: boolean; - sidebarPosition: 'left' | 'right'; theme: CodiffTheme; walkthroughPrompt: string; wordWrap: boolean; @@ -867,60 +47,3 @@ export type CodiffUpdateStatus = { strategy?: 'download' | 'manual' | 'squirrel'; version?: string; }; - -export type PullRequestReviewComment = { - anchor?: 'file' | 'line'; - body: string; - filePath: string; - lineNumber?: number; - sectionId?: string; - side?: 'additions' | 'deletions'; - startLineNumber?: number; - startSide?: 'additions' | 'deletions'; - threadId?: string; -}; - -export type PullRequestExistingReviewComment = PullRequestReviewComment & { - author: ReviewAuthor; - canDelete?: boolean; - canEdit?: boolean; - canReplyThread?: boolean; - canResolveThread?: boolean; - id: string; - isOutdated?: boolean; - isThreadResolved?: boolean; - submittedAt?: string; - url?: string; -}; - -export type PullRequestGeneralComment = { - author: ReviewAuthor; - body: string; - canDelete?: boolean; - canEdit?: boolean; - id: string; - submittedAt?: string; - url?: string; -}; - -export type PullRequestGeneralCommentThread = { - canReply?: boolean; - canResolve?: boolean; - comments: ReadonlyArray; - id: string; - isResolved?: boolean; -}; - -export type PullRequestReviewEvent = 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; - -export type SubmitPullRequestCommentRequest = { - comment: PullRequestReviewComment; - source: Extract; -}; - -export type SubmitPullRequestReviewRequest = { - body?: string; - comments: ReadonlyArray; - event: PullRequestReviewEvent; - source: Extract; -}; diff --git a/core/types/generation.ts b/core/types/generation.ts new file mode 100644 index 00000000..b3f512db --- /dev/null +++ b/core/types/generation.ts @@ -0,0 +1,65 @@ +import type { GitSha, ResolvedReviewSource } from './review-identity.ts'; +import type { NarrativeWalkthrough } from './walkthrough.ts'; + +export type WalkthroughProgressPhase = 'agent-generation' | 'response-received'; +export type WalkthroughProgressEvent = { phase: WalkthroughProgressPhase }; + +export type NarrativeWalkthroughResult = + | { status: 'ready'; walkthrough: NarrativeWalkthrough } + | { + code?: 'CODEX_NOT_FOUND' | 'CLAUDE_NOT_FOUND' | 'OPENCODE_NOT_FOUND' | 'PI_NOT_FOUND'; + reason: string; + status: 'unavailable'; + }; +export type NarrativeWalkthroughRequestOptions = { + force?: boolean; + previousWalkthrough?: NarrativeWalkthrough; +}; + +export type WalkthroughCommitRequest = { + body: string; + paths: ReadonlyArray; + source?: ResolvedReviewSource; + subject: string; +}; +export type WalkthroughCommitResult = + | { sha: GitSha; status: 'committed' } + | { reason: string; status: 'failed' }; +export type WalkthroughCommitMessageRequest = { + body: string; + paths: ReadonlyArray; + source?: ResolvedReviewSource; + subject: string; +}; +export type WalkthroughCommitMessageResult = + | { body: string; status: 'ready'; subject: string } + | { reason: string; status: 'unavailable' }; + +export type ReviewAssistantRequest = { + comment: { + anchor?: 'file' | 'line'; + body: string; + filePath: string; + lineNumber?: number; + sectionId: string; + side?: 'additions' | 'deletions'; + startLineNumber?: number; + startSide?: 'additions' | 'deletions'; + }; + source?: ResolvedReviewSource; + walkthroughNote?: { + action: 'review' | 'scan' | 'skim'; + context: string; + groupReason: string; + groupTitle: string; + impact: 'wide' | 'contained' | 'mechanical'; + reason: string; + }; +}; +export type ReviewAssistantResult = + | { reply: string; status: 'ready' } + | { + code?: 'CODEX_NOT_FOUND' | 'CLAUDE_NOT_FOUND' | 'OPENCODE_NOT_FOUND' | 'PI_NOT_FOUND'; + reason: string; + status: 'unavailable'; + }; diff --git a/core/types/review-comments.ts b/core/types/review-comments.ts new file mode 100644 index 00000000..e3ab7706 --- /dev/null +++ b/core/types/review-comments.ts @@ -0,0 +1,97 @@ +import type { MarkdownAnnotationAnchor } from '@nkzw/mdx-editor'; +import type { ReviewAuthor } from './review-history.ts'; +import type { ReviewSource } from './review-identity.ts'; + +export type PlanCommentAuthor = { + avatarUrl?: string; + email?: string; + id: string; + name: string; + username?: string; +}; + +export type PlanCommentMessage = { + author: PlanCommentAuthor; + body: string; + canDelete?: boolean; + canEdit?: boolean; + createdAt: string; + id: string; + updatedAt: string; +}; + +export type PlanCommentThread = { + anchor: MarkdownAnnotationAnchor; + canReply?: boolean; + canResolve?: boolean; + createdAt: string; + createdBy: PlanCommentAuthor; + id: string; + messages: ReadonlyArray; + resolution?: { reason: 'agent-handled' | 'anchor-removed'; resolvedAt: string }; + status: 'open' | 'resolved'; + updatedAt: string; +}; + +export type PlanReview = { + document: { id: string; path: string; version: string }; + threads: ReadonlyArray; + version: 1; +}; + +export type PlanHandoffStatus = 'closed' | 'done'; + +export type PullRequestReviewComment = { + anchor?: 'file' | 'line'; + body: string; + filePath: string; + lineNumber?: number; + sectionId?: string; + side?: 'additions' | 'deletions'; + startLineNumber?: number; + startSide?: 'additions' | 'deletions'; + threadId?: string; +}; + +export type PullRequestExistingReviewComment = PullRequestReviewComment & { + author: ReviewAuthor; + canDelete?: boolean; + canEdit?: boolean; + canReplyThread?: boolean; + canResolveThread?: boolean; + id: string; + isOutdated?: boolean; + isThreadResolved?: boolean; + submittedAt?: string; + url?: string; +}; + +export type PullRequestGeneralComment = { + author: ReviewAuthor; + body: string; + canDelete?: boolean; + canEdit?: boolean; + id: string; + submittedAt?: string; + url?: string; +}; + +export type PullRequestGeneralCommentThread = { + canReply?: boolean; + canResolve?: boolean; + comments: ReadonlyArray; + id: string; + isResolved?: boolean; +}; + +export type PullRequestReviewEvent = 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; +export type SubmitPullRequestCommentRequest = { + comment: PullRequestReviewComment; + source: Extract; +}; +export type SubmitPullRequestReviewRequest = { + body?: string; + comments: ReadonlyArray; + event: PullRequestReviewEvent; + source: Extract; +}; diff --git a/core/types/review-history.ts b/core/types/review-history.ts new file mode 100644 index 00000000..e72a790f --- /dev/null +++ b/core/types/review-history.ts @@ -0,0 +1,135 @@ +import type { + PullRequestExistingReviewComment, + PullRequestGeneralCommentThread, +} from './review-comments.ts'; +import type { + ChangedFile, + GitFileStatus, + GitSha, + ResolvedReviewSource, +} from './review-identity.ts'; + +export type ReviewAuthor = { + avatarUrl?: string; + login: string; + name?: string; + url?: string; +}; + +export type PullRequestReviewer = ReviewAuthor & { approved: boolean; id: string }; +export type PullRequestReviewActionStatus = { disabled?: boolean; reason?: string }; +export type PullRequestReviewStatus = { + approve?: PullRequestReviewActionStatus; + close?: PullRequestReviewActionStatus; + comment?: PullRequestReviewActionStatus; + markReady?: PullRequestReviewActionStatus; + requestChanges?: PullRequestReviewActionStatus; +}; + +export type PullRequestMergeCheckStatus = 'failed' | 'neutral' | 'pending' | 'success'; +export type PullRequestMergeCheck = { + detail?: string; + label: string; + status: PullRequestMergeCheckStatus; + url?: string; +}; +export type PullRequestMergeOptions = { removeSourceBranch: boolean; squash: boolean }; +export type PullRequestMergeState = { + autoMergeEnabled: boolean; + canCancelAutoMerge: boolean; + canMerge: boolean; + canSetAutoMerge: boolean; + checks: ReadonlyArray; + detailedStatus?: string; + forceRemoveSourceBranch: boolean; + mergeError?: string; + options: PullRequestMergeOptions; + reason?: string; + sha: string; + status: 'blocked' | 'checking' | 'closed' | 'merged' | 'ready' | 'waiting'; + statusLabel: string; +}; + +export type PullRequestCodeQualityFinding = { + description: string; + engineName?: string; + filePath: string; + fingerprint: string; + lineNumber: number; + severity: 'blocker' | 'critical' | 'info' | 'major' | 'minor' | 'unknown'; + status: 'existing' | 'new' | 'resolved'; + url?: string; +}; + +export type HistoryEntry = { + author: string; + committedAt: number; + gravatarUrl?: string; + parentShas: ReadonlyArray; + scope?: 'base' | 'pull-request'; + sha: GitSha; + subject: string; +}; + +/** Canonical provider-neutral commit item in a current review stack. */ +export type ReviewCommitSummary = { + authoredAt: string; + authorName: string; + parentShas: ReadonlyArray; + sha: GitSha; + shortSha: string; + subject: string; + webUrl?: string; +}; + +export type CommitMetadataPerson = { + date: string; + email: string; + gravatarUrl?: string; + name: string; +}; + +export type CommitMetadataFile = { + additions?: number; + binary: boolean; + deletions?: number; + oldPath?: string; + path: string; + status: GitFileStatus; +}; + +export type CommitMetadata = { + author: CommitMetadataPerson; + body: string; + committer: CommitMetadataPerson; + files: ReadonlyArray; + parentShas: ReadonlyArray; + refs: ReadonlyArray; + sha: GitSha; + shortSha: string; + signature: { key?: string; signer?: string; status: string }; + stats: { + additions: number; + binaryFiles: number; + deletions: number; + files: number; + renamedFiles: number; + }; + subject: string; + trailers: ReadonlyArray<{ key: string; value: string }>; +}; + +export type RepositoryHistory = { entries: ReadonlyArray; root: string }; + +export type RepositoryState = { + branch: string | null; + codeQualityFindings?: ReadonlyArray; + commitMetadata?: CommitMetadata; + files: ReadonlyArray; + generalComments?: ReadonlyArray; + generatedAt: number; + launchPath: string; + reviewComments?: ReadonlyArray; + root: string; + source: ResolvedReviewSource; +}; diff --git a/core/types/review-identity.ts b/core/types/review-identity.ts new file mode 100644 index 00000000..a9cea9d3 --- /dev/null +++ b/core/types/review-identity.ts @@ -0,0 +1,162 @@ +import type { + PullRequestMergeState, + PullRequestReviewStatus, + PullRequestReviewer, + ReviewAuthor, +} from './review-history.ts'; + +export type DiffSection = { + binary: boolean; + id: string; + kind: 'commit' | 'pull-request' | 'staged' | 'unstaged'; + loadState?: 'binary' | 'deferred' | 'directory' | 'error' | 'ready' | 'too-large'; + newFile?: { cacheKey?: string; contents: string; name: string }; + oldFile?: { cacheKey?: string; contents: string; name: string }; + patch: string; + range?: DiffRange; + summary?: { + canLoad?: boolean; + fileCount?: number; + fingerprint?: string; + limit?: number; + reason: string; + size?: number; + }; +}; + +export type GitFileStatus = + | 'added' + | 'conflicted' + | 'deleted' + | 'modified' + | 'renamed' + | 'untracked'; + +export type ChangedFile = { + fingerprint: string; + generated?: boolean; + oldPath?: string; + path: string; + sections: ReadonlyArray; + status: GitFileStatus; +}; + +export type GitSha = string & { readonly __gitSha: unique symbol }; + +/** Sources that can be entered from the palette or native application menu. */ +export type OpenReviewSourceKind = 'branch' | 'commit' | 'pull-request'; + +export type ReviewSource = + | { type: 'working-tree' } + | { ref: string; type: 'commit' } + | { ref: string; type: 'branch' } + | { baseSha: GitSha; headSha: GitSha; ref: string; type: 'branch-diff' } + | { + baseSha?: GitSha; + headSha?: GitSha; + ref: string; + type: 'branch-working-tree'; + } + | { base: string; head: string; symmetric: boolean; type: 'range' } + | { + author?: ReviewAuthor; + canEditDescription?: boolean; + canEditReviewers?: boolean; + canEditTitle?: boolean; + description?: string; + draft?: boolean; + headSha?: string; + host?: string; + mergeState?: PullRequestMergeState; + number?: number; + owner?: string; + projectPath?: string; + provider?: 'github' | 'gitlab'; + repo?: string; + reviewers?: ReadonlyArray; + reviewStatus?: PullRequestReviewStatus; + targetBranch?: string; + title?: string; + type: 'pull-request'; + url: string; + }; + +export type ResolvedReviewSource = + | Exclude + | { sha: GitSha; type: 'commit' } + | { baseSha: GitSha; headSha: GitSha; ref: string; type: 'branch-working-tree' }; + +export type RevisionLabel = { + kind: 'bookmark' | 'branch' | 'commit' | 'review-marker' | 'tag' | 'version'; + text: string; + url?: string; +}; + +export type Revision = + | { aliases?: ReadonlyArray; kind?: 'commit'; label: RevisionLabel; sha: GitSha } + | { + aliases?: ReadonlyArray; + kind: 'index'; + label: RevisionLabel; + stage?: 1 | 2 | 3; + } + | { aliases?: ReadonlyArray; kind: 'working-copy'; label: RevisionLabel }; + +/** A null endpoint represents an absent file side, such as an unborn-repository addition. */ +export type DiffRange = { base: Revision | null; head: Revision | null }; + +export type GitIdentity = { + email: string; + gravatarUrl?: string; + name: string; + username?: string; +}; + +export type DiffSectionContentRequest = { + force?: boolean; + kind: DiffSection['kind']; + path: string; + showWhitespace?: boolean; + source?: ResolvedReviewSource; +}; + +export type DefinitionSearchRequest = { + identifier: string; + kind: DiffSection['kind']; + lineNumber: number; + path: string; + side: 'additions' | 'deletions'; + source: ReviewSource; +}; + +export type DefinitionCandidate = { + canOpenInEditor: boolean; + kind: string; + line: string; + lineNumber: number; + path: string; + side: 'additions' | 'deletions'; +}; + +export type DefinitionSearchResult = + | { + candidates: ReadonlyArray; + identifier: string; + status: 'ready'; + } + | { + reason: string; + status: 'unavailable'; + }; + +export type DiffImageContentRequest = { + kind: DiffSection['kind']; + path: string; + source?: ResolvedReviewSource; +}; + +export type DiffImageRevision = { dataUrl: string; mimeType: string; name: string; size: number }; + +export type DiffImageContentResult = + | { newImage?: DiffImageRevision; oldImage?: DiffImageRevision; status: 'ready' } + | { reason: string; status: 'unavailable' }; diff --git a/core/types/walkthrough.ts b/core/types/walkthrough.ts new file mode 100644 index 00000000..65a1e530 --- /dev/null +++ b/core/types/walkthrough.ts @@ -0,0 +1,190 @@ +import type { CodiffPreferences } from '../types.ts'; +import type { + PlanCommentThread, + PullRequestExistingReviewComment, + PullRequestGeneralCommentThread, +} from './review-comments.ts'; +import type { PullRequestCodeQualityFinding } from './review-history.ts'; +import type { + ChangedFile, + DiffSection, + GitFileStatus, + ResolvedReviewSource, + ReviewSource, +} from './review-identity.ts'; + +export type CodiffMarkdownDocument = { + content: string; + id: string; + kind: 'plan' | 'repository'; + path: string; + version: string; +}; +export type SaveMarkdownDocumentRequest = { + baseVersion: string; + content: string; + kind: CodiffMarkdownDocument['kind']; + path: string; +}; +export type SaveMarkdownDocumentResult = + | { document: CodiffMarkdownDocument; status: 'conflict' } + | { document: CodiffMarkdownDocument; status: 'saved' }; + +export type WalkthroughContext = { + changedFiles?: ReadonlyArray<{ path: string; rationale?: string; role: string }>; + constraints?: ReadonlyArray; + decisions?: ReadonlyArray; + implementationSummary?: string; + messages?: ReadonlyArray<{ role: 'assistant' | 'user'; text: string }>; + objective?: string; + risks?: ReadonlyArray; + source: { + generatedAt: string; + threadId?: string; + type: + | 'codex-session' + | 'codex-session-excerpt' + | 'claude-session' + | 'claude-session-excerpt' + | 'opencode-session' + | 'opencode-session-excerpt' + | 'pi-session' + | 'pi-session-excerpt'; + }; + validation?: ReadonlyArray; + version: 1; +}; + +export type CodiffLaunchOptions = { + agentBackend?: 'codex' | 'claude' | 'opencode' | 'pi'; + applyUpdate?: boolean; + claudeSessionId?: string; + codexSessionId?: string; + opencodeSessionId?: string; + piSessionId?: string; + planFile?: string; + planResultFile?: string; + repositoryPathProvided: boolean; + source?: ReviewSource; + walkthrough: boolean; + walkthroughContext?: WalkthroughContext; + walkthroughFile?: string; +}; + +export type AgentSkillStatus = { installed: boolean; path: string }; +/** @deprecated Use AgentSkillStatus. */ +export type CodexSkillStatus = AgentSkillStatus; +export type TerminalHelperStatus = { command: string; installed: boolean; path: string }; + +export type WalkthroughIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'beaker' | 'doc' | 'gear'; +export type WalkthroughAnchor = { + display: string; + endLine?: number; + sectionId?: string; + sectionKind?: DiffSection['kind']; + side?: 'additions' | 'deletions' | 'both'; + startLine?: number; +}; +export type WalkthroughHunkNote = { body: string; hunkId: string }; +export type WalkthroughChangeType = + | 'fix' + | 'feature' + | 'refactor' + | 'test' + | 'generated' + | 'lockfile' + | 'snapshot' + | 'i18n' + | 'docs'; +export type WalkthroughHunk = { + added: number; + additionEnd?: number; + additionStart?: number; + anchor: WalkthroughAnchor; + deleted: number; + deletionEnd?: number; + deletionStart?: number; + id: string; + kind?: 'patch' | 'synthetic'; + oldPath?: string; + path: string; + status: GitFileStatus; +}; +export type WalkthroughHunkGroup = { + added: number; + changeType?: WalkthroughChangeType; + commitNote?: string; + deleted: number; + hunkIds: ReadonlyArray; + hunks: ReadonlyArray; + id: string; + notes?: ReadonlyArray; + summary?: string; + title?: string; +}; +export type WalkthroughStop = WalkthroughHunkGroup & { + importance: 'critical' | 'normal' | 'context'; + prose: string; +}; +export type WalkthroughSupportGroup = WalkthroughHunkGroup & { note?: string; reason: string }; +export type WalkthroughChapter = { + blurb: string; + icon: WalkthroughIcon; + id: string; + stops: ReadonlyArray; + title: string; +}; +export type WalkthroughCommit = { body?: string; title?: string }; +export type NarrativeWalkthrough = { + agent: 'codex' | 'claude' | 'opencode' | 'pi'; + chapters: ReadonlyArray; + commit?: WalkthroughCommit; + context?: WalkthroughContext; + focus: string; + generatedAt: string; + kind: 'narrative'; + meta?: string; + repo: { branch: string | null; root: string }; + source: ResolvedReviewSource; + support: ReadonlyArray; + title: string; + version: 4; +}; + +export type SharedWalkthroughSnapshot = { + branch: string | null; + codeQualityFindings?: ReadonlyArray; + codiffVersion: string; + exportedAt: string; + files: ReadonlyArray; + kind: 'codiff-walkthrough-share'; + preferences: Pick< + CodiffPreferences, + 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' + >; + repository: { + generalComments?: ReadonlyArray; + root: string; + source: ResolvedReviewSource; + title?: string; + }; + reviewComments?: ReadonlyArray; + version: 1; + walkthrough: NarrativeWalkthrough; +}; +export type SharedPlanSnapshot = { + codiffVersion: string; + document: { content: string; name: string; title: string }; + exportedAt: string; + kind: 'codiff-plan-share'; + preferences: Pick; + review: { threads: ReadonlyArray; version: 1 }; + source?: { agent?: 'claude' | 'codex' | 'opencode' | 'pi'; sessionId?: string }; + version: 1; +}; +export type WalkthroughShareManifestV1 = SharedWalkthroughSnapshot; +export type ShareResult = + | { status: 'uploaded'; url: string } + | { reason: string; status: 'failed' }; +export type SharePlanResult = ShareResult; +export type ShareWalkthroughResult = ShareResult; From 6eb24bb23a18f413b407d8a0fda253e08fa62d63 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 10:38:23 -0500 Subject: [PATCH 03/17] Define immutable review artifacts for ranges, commits, and blobs Add provider-neutral `ReviewArtifactSource`, `RangeArtifact`, `StackSnapshot`, `CommitArtifact`, and `BlobArtifact` types with provenance and explicit complete, opaque, or truncated coverage. Separate requested range endpoints from the effective range returned by a provider; key each commit request and result by commit SHA plus selected parent, using `commitSha:root` for a root commit; validate stack order and endpoint consistency; and deduplicate or cancel reads within one artifact run. --- core/README.md | 16 +- core/__tests__/review-artifacts.test.ts | 556 ++++++++++++++++++++ core/__tests__/review-commit-stack.test.ts | 48 ++ core/index.ts | 32 ++ core/lib/review-artifacts.ts | 573 +++++++++++++++++++++ core/lib/review-commit-stack.ts | 92 ++++ docs/review-history-packages.md | 53 ++ 7 files changed, 1367 insertions(+), 3 deletions(-) create mode 100644 core/__tests__/review-artifacts.test.ts create mode 100644 core/__tests__/review-commit-stack.test.ts create mode 100644 core/lib/review-artifacts.ts create mode 100644 core/lib/review-commit-stack.ts create mode 100644 docs/review-history-packages.md diff --git a/core/README.md b/core/README.md index 30b0c3fc..20bac2a0 100644 --- a/core/README.md +++ b/core/README.md @@ -26,6 +26,16 @@ numbers stay ordinary strings. A `Revision` carries a SHA only when it is a commit. The working copy and index have no SHA. `RepositoryHistory.entries` is a newest-first navigation feed. Review commit -stacks are separate parent-before-child values normalized by Core, while review -version timelines remain earlier-before-later. Consumers validate those -contracts instead of reversing provider values locally. +stacks are parent-before-child values: parents come first, and a non-empty +stack ends at the declared head. + +## Commit diffs + +A merge commit `M` with parents `A` and `B` has two different diffs: `M` vs +`A`, and `M` vs `B`. Reads and in-flight maps key that work by +`commitSha:parentSha` (`M:A`, `M:B`). A root commit uses `M:root`. + +Asking GitHub for `requestedBase...head` may return a different +`merge_base_commit`. Dedupe the in-flight read by the pair we asked for, and +record the effective base GitHub actually used on the returned range and +stack. diff --git a/core/__tests__/review-artifacts.test.ts b/core/__tests__/review-artifacts.test.ts new file mode 100644 index 00000000..79b7e9ec --- /dev/null +++ b/core/__tests__/review-artifacts.test.ts @@ -0,0 +1,556 @@ +import { expect, test } from 'vite-plus/test'; +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + createReviewArtifactRun, + validateCommitArtifact, + validateRangeArtifact, + validateReviewArtifactRangeResult, + validateStackSnapshot, + type ReviewArtifactProject, + type ReviewArtifactProvenance, + type ReviewArtifactSource, + type CommitArtifactRequest, + type ReviewArtifactRangeRequest, +} from '../lib/review-artifacts.ts'; +import type { GitSha, ReviewCommitSummary } from '../types.ts'; + +const sha = (value: string) => value.repeat(40) as GitSha; +const project: ReviewArtifactProject = { + host: 'GitLab.Example.com', + project: 'group/project with spaces', + provider: 'gitlab', +}; +const provenance: ReviewArtifactProvenance = { kind: 'gitlab-api', project }; + +const commit = (value: string, parentShas: ReadonlyArray): ReviewCommitSummary => ({ + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentShas, + sha: sha(value), + shortSha: value.repeat(7), + subject: `Commit ${value}`, +}); + +test('complete artifacts reject files without complete change data', () => { + expect(() => + validateCommitArtifact({ + commitSha: sha('b'), + coverage: 'complete', + files: [{ coverage: 'truncated', path: 'src/app.ts', status: 'modified' }], + parentSha: sha('a'), + provenance, + }), + ).toThrow('cannot be complete'); + expect(() => + validateRangeArtifact({ + baseSha: sha('a'), + coverage: 'complete', + files: [{ coverage: 'complete', path: 'src/app.ts', status: 'modified' }], + headSha: sha('b'), + provenance, + }), + ).toThrow('without a patch or exact object/mode metadata'); +}); + +test('Range Artifacts preserve explicit incomplete evidence without calling it complete', () => { + const artifact = validateRangeArtifact({ + baseSha: sha('a'), + coverage: 'truncated', + files: [], + headSha: sha('b'), + incompleteReason: "The provider response reached Codiff's range budget.", + provenance, + }); + expect(artifact.incompleteReason).toContain('range budget'); + + expect(() => + validateRangeArtifact({ + baseSha: sha('a'), + coverage: 'complete', + files: [], + headSha: sha('b'), + incompleteReason: 'This must remain incomplete.', + provenance, + }), + ).toThrow('incomplete-evidence reason'); +}); + +test('Stack Snapshots are parent-first and end at the declared head', () => { + const first = commit('b', [sha('a')]); + const head = commit('c', [first.sha]); + expect( + validateStackSnapshot({ + baseSha: sha('a'), + commits: [first, head], + coverage: 'complete', + headSha: head.sha, + provenance, + }).commits, + ).toEqual([first, head]); + expect(() => + validateStackSnapshot({ + baseSha: sha('a'), + commits: [head, first], + coverage: 'complete', + headSha: head.sha, + provenance, + }), + ).toThrow(); +}); + +test('one Artifact Run deduplicates overlapping and warm immutable reads', async () => { + const base = sha('a'); + const first = sha('b'); + const head = sha('c'); + const commitCalls: Array> = []; + const blobCalls: Array> = []; + let rangeCalls = 0; + const artifact = (commitSha: GitSha) => ({ + commitSha, + coverage: 'complete' as const, + files: [], + parentSha: commitSha === first ? base : first, + provenance, + }); + const source: ReviewArtifactSource = { + readBlobs: async (objectIds) => { + blobCalls.push(objectIds); + return new Map( + objectIds.map((objectId) => [ + objectId, + { bytes: new Uint8Array([1, 2, 3]), objectId, provenance }, + ]), + ); + }, + readCommitArtifacts: async (commits) => { + commitCalls.push(commits); + await Promise.resolve(); + return new Map( + commits.map(({ commitSha, parentSha }) => { + const request = { commitSha, parentSha }; + return [createCommitArtifactRequestKey(request), { ...artifact(commitSha), parentSha }]; + }), + ); + }, + readStackAndRange: async () => { + rangeCalls += 1; + return { + range: { baseSha: base, coverage: 'complete', files: [], headSha: head, provenance }, + stack: { + baseSha: base, + commits: [commit('b', [base]), commit('c', [first])], + coverage: 'complete', + headSha: head, + provenance, + }, + }; + }, + }; + const run = createReviewArtifactRun(source); + + const [left, right, firstRange, secondRange] = await Promise.all([ + run.readCommitArtifacts( + [ + { commitSha: first, parentSha: base }, + { commitSha: head, parentSha: first }, + ], + run.signal, + ), + run.readCommitArtifacts([{ commitSha: head, parentSha: first }], run.signal), + run.readStackAndRange({ headSha: head, requestedBaseSha: base }, run.signal), + run.readStackAndRange({ headSha: head, requestedBaseSha: base }, run.signal), + ]); + await run.readCommitArtifacts( + [ + { commitSha: first, parentSha: base }, + { commitSha: head, parentSha: first }, + ], + run.signal, + ); + await Promise.all([ + run.readBlobs(['blob-a', 'blob-b'], run.signal), + run.readBlobs(['blob-b'], run.signal), + ]); + await run.readBlobs(['blob-a'], run.signal); + + expect([...left]).toHaveLength(2); + expect([...right]).toHaveLength(1); + expect(left.get(createCommitArtifactRequestKey({ commitSha: first, parentSha: base }))).toEqual( + expect.objectContaining({ commitSha: first, parentSha: base }), + ); + expect(firstRange).toBe(secondRange); + expect(commitCalls).toEqual([ + [ + { commitSha: first, parentSha: base }, + { commitSha: head, parentSha: first }, + ], + ]); + expect(blobCalls).toEqual([['blob-a', 'blob-b']]); + expect(rangeCalls).toBe(1); + expect(run.diagnostics()).toMatchObject({ + acquired: { + blobs: { 'blob-a': 1, 'blob-b': 1 }, + commits: { [`${first}:${base}`]: 1, [`${head}:${first}`]: 1 }, + stackAndRanges: { [`${base}:${head}`]: 1 }, + }, + sourceCalls: { blobs: 1, commits: 1, stackAndRanges: 1 }, + }); +}); + +test('one bulk read preserves the same merge commit relative to two parents', async () => { + const merge = sha('m'); + const firstParent = sha('a'); + const secondParent = sha('b'); + const requests = [ + { commitSha: merge, parentSha: firstParent }, + { commitSha: merge, parentSha: secondParent }, + ]; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async (commits) => + new Map( + commits.map((request) => [ + createCommitArtifactRequestKey(request), + { + commitSha: request.commitSha, + coverage: 'complete' as const, + files: [], + parentSha: request.parentSha, + provenance, + }, + ]), + ), + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + const artifacts = await run.readCommitArtifacts(requests, run.signal); + + expect(artifacts).toHaveLength(2); + expect(artifacts.get(createCommitArtifactRequestKey(requests[0]!))?.parentSha).toBe(firstParent); + expect(artifacts.get(createCommitArtifactRequestKey(requests[1]!))?.parentSha).toBe(secondParent); + expect(run.diagnostics().acquired.commits).toEqual({ + [createCommitArtifactRequestKey(requests[0]!)]: 1, + [createCommitArtifactRequestKey(requests[1]!)]: 1, + }); +}); + +test('rejects a Commit Artifact returned under the wrong request coordinate', async () => { + const request = { commitSha: sha('c'), parentSha: sha('a') }; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => + new Map([ + [ + createCommitArtifactRequestKey(request), + { + commitSha: request.commitSha, + coverage: 'complete' as const, + files: [], + parentSha: sha('b'), + provenance, + }, + ], + ]), + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + + await expect(run.readCommitArtifacts([request], run.signal)).rejects.toThrow( + 'returned different coordinates', + ); +}); + +test('uses the canonical root request key for root Commit Artifacts', async () => { + const request = { commitSha: sha('r'), parentSha: null }; + const key = createCommitArtifactRequestKey(request); + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => + new Map([ + [ + key, + { + commitSha: request.commitSha, + coverage: 'complete' as const, + files: [], + parentSha: null, + provenance, + }, + ], + ]), + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + + expect((await run.readCommitArtifacts([request], run.signal)).get(key)?.parentSha).toBeNull(); + expect(key.endsWith(':root')).toBe(true); +}); + +test('artifact range results distinguish requested and effective bases without sharing request-local cache entries', async () => { + const requestedBase = sha('a'); + const equivalentSelector = sha('b'); + const effectiveBase = sha('c'); + const head = sha('d'); + const requests: Array = []; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => new Map(), + readStackAndRange: async (request) => { + requests.push(request); + return { + range: { + baseSha: effectiveBase, + coverage: 'complete', + files: [], + headSha: request.headSha, + provenance, + }, + stack: { + baseSha: effectiveBase, + commits: [commit('d', [effectiveBase])], + coverage: 'complete', + headSha: request.headSha, + provenance, + }, + }; + }, + }; + const run = createReviewArtifactRun(source); + + const first = await run.readStackAndRange( + { headSha: head, requestedBaseSha: requestedBase }, + run.signal, + ); + expect( + await run.readStackAndRange({ headSha: head, requestedBaseSha: requestedBase }, run.signal), + ).toBe(first); + await run.readStackAndRange({ headSha: head, requestedBaseSha: equivalentSelector }, run.signal); + + expect(first.range.baseSha).toBe(effectiveBase); + expect(first.stack.baseSha).toBe(effectiveBase); + expect(requests).toEqual([ + { headSha: head, requestedBaseSha: requestedBase }, + { headSha: head, requestedBaseSha: equivalentSelector }, + ]); + expect(run.diagnostics().acquired.stackAndRanges).toEqual({ + [`${equivalentSelector}:${head}`]: 1, + [`${requestedBase}:${head}`]: 1, + }); +}); + +test('artifact range results reject mismatched endpoints, substituted heads, and provenance', () => { + const requestedBase = sha('a'); + const effectiveBase = sha('b'); + const head = sha('c'); + const result = () => ({ + range: { + baseSha: effectiveBase, + coverage: 'complete' as const, + files: [], + headSha: head, + provenance, + }, + stack: { + baseSha: effectiveBase, + commits: [commit('c', [effectiveBase])], + coverage: 'complete' as const, + headSha: head, + provenance, + }, + }); + const request = { headSha: head, requestedBaseSha: requestedBase }; + + expect(validateReviewArtifactRangeResult(request, result()).range.baseSha).toBe(effectiveBase); + expect(() => + validateReviewArtifactRangeResult(request, { + ...result(), + stack: { ...result().stack, baseSha: requestedBase }, + }), + ).toThrow('different endpoints'); + expect(() => + validateReviewArtifactRangeResult(request, { + ...result(), + range: { ...result().range, headSha: requestedBase }, + stack: { + ...result().stack, + commits: [commit('a', [effectiveBase])], + headSha: requestedBase, + }, + }), + ).toThrow('substituted a different head'); + expect(() => + validateReviewArtifactRangeResult(request, { + ...result(), + stack: { + ...result().stack, + provenance: { ...provenance, project: { ...project, project: 'other/project' } }, + }, + }), + ).toThrow('different provider projects'); +}); + +test('same-commit artifact requests remain empty same-commit results', () => { + const same = sha('a'); + const request = { headSha: same, requestedBaseSha: same }; + expect(() => + validateReviewArtifactRangeResult(request, { + range: { baseSha: same, coverage: 'complete', files: [], headSha: same, provenance }, + stack: { baseSha: same, commits: [], coverage: 'complete', headSha: same, provenance }, + }), + ).not.toThrow(); + expect(() => + validateReviewArtifactRangeResult(request, { + range: { baseSha: same, coverage: 'complete', files: [], headSha: same, provenance }, + stack: { + baseSha: same, + commits: [commit('a', [])], + coverage: 'complete', + headSha: same, + provenance, + }, + }), + ).toThrow('same-commit artifact request'); +}); + +test('Artifact Runs propagate cancellation to cooperative in-flight commit reads', async () => { + let commitCalls = 0; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async (_commits, signal) => { + commitCalls += 1; + await new Promise((resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(Object.assign(new Error('canceled'), { name: 'AbortError' })), + { once: true }, + ); + if (commitCalls > 1) { + resolve(undefined); + } + }); + return new Map(); + }, + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const controller = new AbortController(); + const run = createReviewArtifactRun(source, { signal: controller.signal }); + const pending = run.readCommitArtifacts([{ commitSha: sha('d'), parentSha: null }], run.signal); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(run.signal.aborted).toBe(true); + expect(commitCalls).toBe(1); +}); + +test('Artifact Runs reject signal-ignoring stack and range results resolved after cancellation', async () => { + const base = sha('a'); + const head = sha('b'); + let resolveRange!: ( + value: Awaited>, + ) => void; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => new Map(), + readStackAndRange: async () => + new Promise((resolve) => { + resolveRange = resolve; + }), + }; + const controller = new AbortController(); + const run = createReviewArtifactRun(source, { signal: controller.signal }); + const pending = run.readStackAndRange({ headSha: head, requestedBaseSha: base }, run.signal); + controller.abort(); + resolveRange({ + range: { baseSha: base, coverage: 'complete', files: [], headSha: head, provenance }, + stack: { + baseSha: base, + commits: [commit('b', [base])], + coverage: 'complete', + headSha: head, + provenance, + }, + }); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(run.signal.aborted).toBe(true); + expect(run.diagnostics()).toMatchObject({ + acquired: { stackAndRanges: { [`${base}:${head}`]: 1 } }, + sourceCalls: { stackAndRanges: 1 }, + }); +}); + +test('Artifact Runs deduplicate path-resolved Blob Artifacts and reuse their object IDs', async () => { + const request = { maxBytes: 16, path: 'src/app.ts', ref: sha('a') }; + const objectId = sha('f'); + let fileCalls = 0; + let objectCalls = 0; + const source: ReviewArtifactSource = { + readBlobs: async () => { + objectCalls += 1; + return new Map(); + }, + readCommitArtifacts: async () => new Map(), + readFileBlobs: async (requests) => { + fileCalls += 1; + return new Map( + requests.map((value) => [ + createFileBlobArtifactRequestKey(value), + { bytes: new TextEncoder().encode('artifact'), objectId, provenance }, + ]), + ); + }, + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + + const [first, concurrent] = await Promise.all([ + run.readFileBlobs([request], run.signal), + run.readFileBlobs([request], run.signal), + ]); + const warm = await run.readFileBlobs([request], run.signal); + const byObjectId = await run.readBlobs([objectId], run.signal); + + expect(first.get(createFileBlobArtifactRequestKey(request))?.objectId).toBe(objectId); + expect(concurrent).toEqual(first); + expect(warm).toEqual(first); + expect(byObjectId.get(objectId)?.objectId).toBe(objectId); + expect(fileCalls).toBe(1); + expect(objectCalls).toBe(0); + expect(run.diagnostics().acquired.blobs[`file:${request.ref}:${request.path}`]).toBe(1); +}); + +test('Artifact Runs do not reacquire an explicit source miss', async () => { + let commitCalls = 0; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => { + commitCalls += 1; + return new Map(); + }, + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + const missing = sha('e'); + + expect( + await run.readCommitArtifacts([{ commitSha: missing, parentSha: null }], run.signal), + ).toHaveLength(0); + expect( + await run.readCommitArtifacts([{ commitSha: missing, parentSha: null }], run.signal), + ).toHaveLength(0); + expect(commitCalls).toBe(1); + expect(run.diagnostics().acquired.commits[`${missing}:root`]).toBe(1); +}); diff --git a/core/__tests__/review-commit-stack.test.ts b/core/__tests__/review-commit-stack.test.ts new file mode 100644 index 00000000..06ec61a6 --- /dev/null +++ b/core/__tests__/review-commit-stack.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'vite-plus/test'; +import { orderReviewCommitStack, validateReviewCommitStack } from '../lib/review-commit-stack.ts'; +import type { GitSha } from '../types.ts'; + +const gitSha = (value: string) => value as GitSha; +type StackCommit = { + authoredAt: string; + parentShas: ReadonlyArray; + sha: GitSha; +}; +const stackCommit = ( + sha: string, + authoredAt: string, + parentShas: ReadonlyArray = [], +): StackCommit => ({ authoredAt, parentShas, sha: gitSha(sha) }); + +test('normalizes reversed linear commit input and validates canonical stacks', () => { + const first = stackCommit('a', '2026-01-01T00:00:00.000Z', [gitSha('base')]); + const second = stackCommit('b', '2026-01-02T00:00:00.000Z', [first.sha]); + const third = stackCommit('c', '2026-01-03T00:00:00.000Z', [second.sha]); + const ordered = orderReviewCommitStack([third, second, first]); + + expect(ordered.map(({ sha }) => sha)).toEqual(['a', 'b', 'c']); + expect(validateReviewCommitStack(ordered)).toBe(ordered); + expect(() => validateReviewCommitStack([third, second, first])).toThrow( + 'not parent-before-child', + ); +}); + +test('orders merge parents before children and parallel roots deterministically', () => { + const earlier = stackCommit('a', '2026-01-01T00:00:00.000Z', [gitSha('external')]); + const later = stackCommit('b', '2026-01-01T00:00:00.000Z', [gitSha('external')]); + const merge = stackCommit('c', '2026-01-02T00:00:00.000Z', [later.sha, earlier.sha]); + + expect(orderReviewCommitStack([merge, later, earlier]).map(({ sha }) => sha)).toEqual([ + 'a', + 'b', + 'c', + ]); +}); + +test('rejects duplicate and cyclic commit graphs', () => { + const duplicate = stackCommit('a', '2026-01-01T00:00:00.000Z'); + expect(() => orderReviewCommitStack([duplicate, { ...duplicate }])).toThrow('duplicate SHA a'); + const first = stackCommit('a', '2026-01-01T00:00:00.000Z', [gitSha('b')]); + const second = stackCommit('b', '2026-01-02T00:00:00.000Z', [gitSha('a')]); + expect(() => orderReviewCommitStack([first, second])).toThrow('contains a cycle'); +}); diff --git a/core/index.ts b/core/index.ts index fb0a3c7d..c37244c4 100644 --- a/core/index.ts +++ b/core/index.ts @@ -1,5 +1,37 @@ export { defaultReviewPreferences } from './defaults.ts'; export { diffRange, isCommitRevision, shaForRevision } from './lib/review-history.ts'; +export { + orderReviewCommitStack, + validateReviewCommitStack, + type ReviewCommitStack, + type ReviewCommitStackItem, +} from './lib/review-commit-stack.ts'; +export { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + createReviewArtifactRun, + reviewArtifactSchemaVersion, + validateCommitArtifact, + validateRangeArtifact, + validateReviewArtifactRangeResult, + validateStackSnapshot, + type ArtifactCoverage, + type ArtifactFile, + type BlobArtifact, + type CommitArtifact, + type CommitArtifactRequest, + type CommitArtifactRequestKey, + type FileBlobArtifactRequest, + type RangeArtifact, + type ReviewArtifactProject, + type ReviewArtifactRangeRequest, + type ReviewArtifactRangeResult, + type ReviewArtifactProvenance, + type ReviewArtifactRun, + type ReviewArtifactRunDiagnostics, + type ReviewArtifactSource, + type StackSnapshot, +} from './lib/review-artifacts.ts'; export { parsePlanShareManifest, parsePlanShareUpload, diff --git a/core/lib/review-artifacts.ts b/core/lib/review-artifacts.ts new file mode 100644 index 00000000..4f5f37ce --- /dev/null +++ b/core/lib/review-artifacts.ts @@ -0,0 +1,573 @@ +import type { GitFileStatus, GitSha, ReviewCommitSummary } from '../types.ts'; +import { validateReviewCommitStack } from './review-commit-stack.ts'; + +export const reviewArtifactSchemaVersion = 'review-artifact-v1'; + +export type ArtifactCoverage = 'complete' | 'opaque' | 'truncated'; + +export type ReviewArtifactProject = { + host: string; + project: string; + provider: 'git' | 'github' | 'gitlab'; +}; + +export type ReviewArtifactProvenance = { + kind: 'github-api' | 'gitlab-api' | 'native-git'; + project: ReviewArtifactProject; +}; + +export type StackSnapshot = { + baseSha: GitSha; + commits: ReadonlyArray; + coverage: ArtifactCoverage; + headSha: GitSha; + provenance: ReviewArtifactProvenance; +}; + +export type ArtifactFile = { + coverage: ArtifactCoverage; + newMode?: string; + newObjectId?: string; + oldMode?: string; + oldObjectId?: string; + oldPath?: string; + /** Complete normalized patch when textual patch data is available. */ + patch?: string; + path: string; + status: GitFileStatus; +}; + +export type CommitArtifact = { + commitSha: GitSha; + coverage: ArtifactCoverage; + files: ReadonlyArray; + /** The selected parent whose change this artifact represents; null for a root commit. */ + parentSha: GitSha | null; + provenance: ReviewArtifactProvenance; +}; + +export type CommitArtifactRequest = { + commitSha: GitSha; + parentSha: GitSha | null; +}; + +export type CommitArtifactRequestKey = string & { + readonly __commitArtifactRequestKey: unique symbol; +}; + +export type ReviewArtifactRangeRequest = { + /** Immutable requested review head; providers must not substitute this coordinate. */ + headSha: GitSha; + /** Immutable provider selector. The returned artifact base may differ after resolution. */ + requestedBaseSha: GitSha; +}; + +export type ReviewArtifactRangeResult = { + range: RangeArtifact; + stack: StackSnapshot; +}; + +export type RangeArtifact = { + baseSha: GitSha; + coverage: ArtifactCoverage; + files: ReadonlyArray; + headSha: GitSha; + /** Human-readable reason when the whole range is incomplete. */ + incompleteReason?: string; + provenance: ReviewArtifactProvenance; +}; + +export type BlobArtifact = { + bytes: Uint8Array; + objectId: string; + provenance: ReviewArtifactProvenance; +}; + +/** Immutable endpoint coordinate used to resolve a path to its Git blob. */ +export type FileBlobArtifactRequest = { + /** Per-file retained-byte limit for on-demand context or image rendering. */ + maxBytes?: number; + path: string; + ref: GitSha; +}; + +export interface ReviewArtifactSource { + readBlobs( + objectIds: ReadonlyArray, + signal: AbortSignal, + ): Promise>; + readCommitArtifacts( + commits: ReadonlyArray, + signal: AbortSignal, + ): Promise>; + /** + * Optional path resolver for sources whose caller does not yet know the Git + * object ID. Results are still Blob Artifacts keyed by immutable ref+path. + */ + readFileBlobs?( + requests: ReadonlyArray, + signal: AbortSignal, + ): Promise>; + readStackAndRange( + request: ReviewArtifactRangeRequest, + signal: AbortSignal, + ): Promise; +} + +export type ReviewArtifactRunDiagnostics = { + acquired: { + blobs: Readonly>; + commits: Readonly>; + stackAndRanges: Readonly>; + }; + cacheHits: { + blobs: number; + commits: number; + stackAndRanges: number; + }; + sourceCalls: { + blobs: number; + commits: number; + stackAndRanges: number; + }; +}; + +export type ReviewArtifactRun = ReviewArtifactSource & { + abort(reason?: unknown): void; + diagnostics(): ReviewArtifactRunDiagnostics; + readFileBlobs( + requests: ReadonlyArray, + signal: AbortSignal, + ): Promise>; + readonly signal: AbortSignal; +}; + +export const createFileBlobArtifactRequestKey = ({ path, ref }: FileBlobArtifactRequest) => + `${ref}:${path}`; + +export const createCommitArtifactRequestKey = ({ + commitSha, + parentSha, +}: CommitArtifactRequest): CommitArtifactRequestKey => + `${commitSha}:${parentSha ?? 'root'}` as CommitArtifactRequestKey; + +const validateCoverage = ( + artifact: { + coverage: ArtifactCoverage; + files: ReadonlyArray; + incompleteReason?: string; + }, + label: string, +) => { + if ( + artifact.coverage === 'complete' && + artifact.files.some((file) => file.coverage !== 'complete') + ) { + throw new Error(`${label} cannot be complete when one or more files are incomplete.`); + } + if (artifact.incompleteReason != null) { + if (!artifact.incompleteReason.trim()) { + throw new Error(`${label} has an empty incomplete-evidence reason.`); + } + if (artifact.coverage === 'complete') { + throw new Error(`${label} cannot be complete when it has an incomplete-evidence reason.`); + } + } + for (const file of artifact.files) { + if (!file.path) { + throw new Error(`${label} contains a file without a path.`); + } + if (file.coverage === 'complete' && file.patch == null) { + const hasExactMetadata = + file.oldObjectId != null || + file.newObjectId != null || + (file.oldMode != null && file.newMode != null && file.oldMode !== file.newMode); + if (!hasExactMetadata) { + throw new Error( + `${label} marks ${file.path} complete without a patch or exact object/mode metadata.`, + ); + } + } + } +}; + +export const validateCommitArtifact = (artifact: Artifact) => { + validateCoverage(artifact, `Commit Artifact ${artifact.commitSha}`); + if (artifact.parentSha === artifact.commitSha) { + throw new Error(`Commit Artifact ${artifact.commitSha} cannot select itself as its parent.`); + } + return artifact; +}; + +export const validateRangeArtifact = (artifact: Artifact) => { + validateCoverage(artifact, `Range Artifact ${artifact.baseSha}..${artifact.headSha}`); + return artifact; +}; + +export const validateStackSnapshot = (snapshot: Snapshot) => { + const commits = validateReviewCommitStack(snapshot.commits); + if (commits.length > 0 && commits.at(-1)?.sha !== snapshot.headSha) { + throw new Error('A non-empty Stack Snapshot must end at its declared head SHA.'); + } + if (commits.length === 0 && snapshot.baseSha !== snapshot.headSha) { + throw new Error('An empty Stack Snapshot must have identical base and head SHAs.'); + } + return snapshot; +}; + +const projectsMatch = (left: ReviewArtifactProject, right: ReviewArtifactProject) => + left.provider === right.provider && left.host === right.host && left.project === right.project; + +/** + * Validate one provider-resolved artifact range. `requestedBaseSha` is a + * provider selector; `RangeArtifact.baseSha` and `StackSnapshot.baseSha` are + * the authoritative effective comparison base. + */ +export const validateReviewArtifactRangeResult = ( + request: ReviewArtifactRangeRequest, + result: Result, +) => { + const range = validateRangeArtifact(result.range); + const stack = result.stack; + if (range.baseSha !== stack.baseSha || range.headSha !== stack.headSha) { + throw new Error( + 'Artifact Source returned a Range Artifact and Stack Snapshot for different endpoints.', + ); + } + if (range.headSha !== request.headSha) { + throw new Error( + 'Artifact Source substituted a different head SHA for the requested artifact range.', + ); + } + if (!projectsMatch(range.provenance.project, stack.provenance.project)) { + throw new Error( + 'Artifact Source returned range and stack artifacts for different provider projects.', + ); + } + if ( + request.requestedBaseSha === request.headSha && + (range.baseSha !== request.headSha || range.files.length !== 0 || stack.commits.length !== 0) + ) { + throw new Error('A same-commit artifact request must return an empty same-commit result.'); + } + return { range, stack: validateStackSnapshot(stack) } as Result; +}; + +const increment = (counts: Map, key: string) => + counts.set(key, (counts.get(key) ?? 0) + 1); + +const countRecord = (counts: ReadonlyMap) => Object.fromEntries(counts); + +const fileBlobCacheKey = (key: string, request: FileBlobArtifactRequest) => + `${key}:${request.maxBytes ?? 'default'}`; + +/** + * Coordinate one comparison's immutable artifact reads. Completed values and + * explicit source misses are reused only inside this run; cancelable in-flight + * work is never shared with an unrelated run. + */ +export const createReviewArtifactRun = ( + source: ReviewArtifactSource, + options: { controller?: AbortController; signal?: AbortSignal } = {}, +): ReviewArtifactRun => { + const controller = options.controller ?? new AbortController(); + const boundSignals = new WeakSet(); + const commitValues = new Map(); + const commitPending = new Map>(); + const blobValues = new Map(); + const blobPending = new Map>(); + const fileBlobValues = new Map(); + const fileBlobPending = new Map>(); + const stackAndRangeValues = new Map(); + const stackAndRangePending = new Map>(); + const acquired = { + blobs: new Map(), + commits: new Map(), + stackAndRanges: new Map(), + }; + const cacheHits = { blobs: 0, commits: 0, stackAndRanges: 0 }; + const sourceCalls = { blobs: 0, commits: 0, stackAndRanges: 0 }; + + const bindSignal = (signal?: AbortSignal) => { + if (!signal || signal === controller.signal || boundSignals.has(signal)) { + return; + } + boundSignals.add(signal); + if (signal.aborted) { + controller.abort(signal.reason); + } else { + signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true }); + } + }; + bindSignal(options.signal); + + const readCommitArtifacts = async ( + commits: ReadonlyArray, + signal: AbortSignal, + ) => { + bindSignal(signal); + controller.signal.throwIfAborted(); + const requested = [ + ...new Map( + commits.map((commit) => [createCommitArtifactRequestKey(commit), commit]), + ).entries(), + ]; + const misses = requested.filter(([key]) => { + if (commitValues.has(key) || commitPending.has(key)) { + cacheHits.commits += 1; + return false; + } + return true; + }); + if (misses.length > 0) { + sourceCalls.commits += 1; + for (const [key] of misses) { + increment(acquired.commits, key); + } + const requestedBatchKeys = new Set(misses.map(([key]) => key)); + const batch = source + .readCommitArtifacts( + misses.map(([, commit]) => commit), + controller.signal, + ) + .then((artifacts) => { + for (const [key, artifact] of artifacts) { + if (!requestedBatchKeys.has(key)) { + throw new Error(`Artifact Source returned unrequested commit coordinate ${key}.`); + } + if (createCommitArtifactRequestKey(artifact) !== key) { + throw new Error(`Artifact Source returned different coordinates for ${key}.`); + } + } + return artifacts; + }); + for (const [key, commit] of misses) { + const pending = batch + .then((artifacts) => { + const artifact = artifacts.get(key); + if (!artifact) { + return null; + } + if ( + artifact.commitSha !== commit.commitSha || + artifact.parentSha !== commit.parentSha + ) { + throw new Error( + `Artifact Source returned different coordinates for commit ${commit.commitSha}.`, + ); + } + return validateCommitArtifact(artifact); + }) + .then((artifact) => { + commitValues.set(key, artifact); + return artifact; + }) + .finally(() => { + if (commitPending.get(key) === pending) { + commitPending.delete(key); + } + }); + commitPending.set(key, pending); + } + } + await Promise.all(requested.map(([key]) => commitPending.get(key)).filter(Boolean)); + controller.signal.throwIfAborted(); + return new Map( + requested + .map(([key]) => [key, commitValues.get(key)] as const) + .filter( + (entry): entry is readonly [CommitArtifactRequestKey, CommitArtifact] => entry[1] != null, + ), + ); + }; + + const readBlobs = async (objectIds: ReadonlyArray, signal: AbortSignal) => { + bindSignal(signal); + controller.signal.throwIfAborted(); + const requested = [...new Set(objectIds)]; + const misses = requested.filter((objectId) => { + if (blobValues.has(objectId) || blobPending.has(objectId)) { + cacheHits.blobs += 1; + return false; + } + return true; + }); + if (misses.length > 0) { + sourceCalls.blobs += 1; + for (const objectId of misses) { + increment(acquired.blobs, objectId); + } + const batch = source.readBlobs(misses, controller.signal); + for (const objectId of misses) { + const pending = batch + .then((blobs) => { + const blob = blobs.get(objectId); + if (!blob) { + return null; + } + if (blob.objectId !== objectId) { + throw new Error(`Artifact Source returned ${blob.objectId} for blob ${objectId}.`); + } + return blob; + }) + .then((blob) => { + blobValues.set(objectId, blob); + return blob; + }) + .finally(() => { + if (blobPending.get(objectId) === pending) { + blobPending.delete(objectId); + } + }); + blobPending.set(objectId, pending); + } + } + await Promise.all(requested.map((objectId) => blobPending.get(objectId)).filter(Boolean)); + controller.signal.throwIfAborted(); + return new Map( + requested + .map((objectId) => [objectId, blobValues.get(objectId)] as const) + .filter((entry): entry is readonly [string, BlobArtifact] => entry[1] != null), + ); + }; + + const readFileBlobs = async ( + requests: ReadonlyArray, + signal: AbortSignal, + ) => { + bindSignal(signal); + controller.signal.throwIfAborted(); + const requested = [ + ...new Map( + requests.map((request) => { + if ( + request.maxBytes != null && + (!Number.isFinite(request.maxBytes) || request.maxBytes < 0) + ) { + throw new RangeError('File Blob Artifact byte limit must be non-negative and finite.'); + } + const normalized = { + ...request, + ...(request.maxBytes == null ? {} : { maxBytes: Math.floor(request.maxBytes) }), + }; + return [createFileBlobArtifactRequestKey(normalized), normalized] as const; + }), + ).entries(), + ]; + const misses = requested.filter(([key, request]) => { + const keyWithLimit = fileBlobCacheKey(key, request); + if (fileBlobValues.has(keyWithLimit) || fileBlobPending.has(keyWithLimit)) { + cacheHits.blobs += 1; + return false; + } + return true; + }); + if (misses.length > 0 && source.readFileBlobs) { + sourceCalls.blobs += 1; + for (const [key] of misses) { + increment(acquired.blobs, `file:${key}`); + } + const batch = source.readFileBlobs!( + misses.map(([, request]) => request), + controller.signal, + ); + for (const [key, request] of misses) { + const keyWithLimit = fileBlobCacheKey(key, request); + const pending = batch + .then((blobs) => { + const blob = blobs.get(key); + if (!blob || (request.maxBytes != null && blob.bytes.byteLength > request.maxBytes)) { + return null; + } + if (!blob.objectId) { + throw new Error( + `Artifact Source returned a file blob without an object ID for ${key}.`, + ); + } + return blob; + }) + .then((blob) => { + fileBlobValues.set(keyWithLimit, blob); + if (blob) { + blobValues.set(blob.objectId, blob); + } + return blob; + }) + .finally(() => { + if (fileBlobPending.get(keyWithLimit) === pending) { + fileBlobPending.delete(keyWithLimit); + } + }); + fileBlobPending.set(keyWithLimit, pending); + } + } else if (misses.length > 0) { + for (const [key, request] of misses) { + fileBlobValues.set(fileBlobCacheKey(key, request), null); + } + } + await Promise.all( + requested + .map(([key, request]) => fileBlobPending.get(fileBlobCacheKey(key, request))) + .filter(Boolean), + ); + controller.signal.throwIfAborted(); + return new Map( + requested + .map(([key, request]) => [key, fileBlobValues.get(fileBlobCacheKey(key, request))] as const) + .filter((entry): entry is readonly [string, BlobArtifact] => entry[1] != null), + ); + }; + + const readStackAndRange = async (request: ReviewArtifactRangeRequest, signal: AbortSignal) => { + bindSignal(signal); + controller.signal.throwIfAborted(); + const key = `${request.requestedBaseSha}:${request.headSha}`; + const cached = stackAndRangeValues.get(key); + if (cached) { + cacheHits.stackAndRanges += 1; + return cached; + } + const active = stackAndRangePending.get(key); + if (active) { + cacheHits.stackAndRanges += 1; + return active; + } + sourceCalls.stackAndRanges += 1; + increment(acquired.stackAndRanges, key); + const pending = source + .readStackAndRange(request, controller.signal) + .then((value) => { + controller.signal.throwIfAborted(); + return validateReviewArtifactRangeResult(request, value); + }) + .then((value) => { + stackAndRangeValues.set(key, value); + return value; + }) + .finally(() => { + if (stackAndRangePending.get(key) === pending) { + stackAndRangePending.delete(key); + } + }); + stackAndRangePending.set(key, pending); + return pending; + }; + + return { + abort: (reason) => controller.abort(reason), + diagnostics: () => ({ + acquired: { + blobs: countRecord(acquired.blobs), + commits: countRecord(acquired.commits), + stackAndRanges: countRecord(acquired.stackAndRanges), + }, + cacheHits: { ...cacheHits }, + sourceCalls: { ...sourceCalls }, + }), + readBlobs, + readCommitArtifacts, + readFileBlobs, + readStackAndRange, + signal: controller.signal, + }; +}; diff --git a/core/lib/review-commit-stack.ts b/core/lib/review-commit-stack.ts new file mode 100644 index 00000000..d28d404f --- /dev/null +++ b/core/lib/review-commit-stack.ts @@ -0,0 +1,92 @@ +import type { GitSha, ReviewCommitSummary } from '../types.ts'; + +/** + * A proposed change ordered from its external base toward its review head. + * Every included parent precedes its child. + */ +export type ReviewCommitStack = + ReadonlyArray; + +export type ReviewCommitStackItem = { + authoredAt?: string; + authoredDate?: string; + committedAt?: number | string; + parentShas: ReadonlyArray; + sha: GitSha; +}; + +const commitTime = (commit: ReviewCommitStackItem) => { + const value = commit.committedAt ?? commit.authoredAt ?? commit.authoredDate; + const parsed = typeof value === 'number' ? value : value ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY; +}; + +const compareReadyCommits = (first: ReviewCommitStackItem, second: ReviewCommitStackItem) => + commitTime(first) - commitTime(second) || first.sha.localeCompare(second.sha); + +const indexCommitGraph = (commits: ReadonlyArray) => { + const bySha = new Map(); + for (const commit of commits) { + if (bySha.has(commit.sha)) { + throw new Error(`Review commit stack contains duplicate SHA ${commit.sha}.`); + } + bySha.set(commit.sha, commit); + } + return bySha; +}; + +/** + * Project provider or Git output into the canonical parent-before-child stack. + * Parents outside the supplied set are valid external roots. + */ +export const orderReviewCommitStack = ( + commits: ReadonlyArray, +): ReviewCommitStack => { + const bySha = indexCommitGraph(commits); + const children = new Map>(); + const remainingParents = new Map(); + for (const commit of commits) { + const includedParents = commit.parentShas.filter((parentSha) => bySha.has(parentSha)); + remainingParents.set(commit.sha, includedParents.length); + for (const parentSha of includedParents) { + const childShas = children.get(parentSha) ?? []; + childShas.push(commit.sha); + children.set(parentSha, childShas); + } + } + + const ready = commits + .filter((commit) => remainingParents.get(commit.sha) === 0) + .toSorted(compareReadyCommits); + const ordered: Array = []; + while (ready.length > 0) { + const commit = ready.shift()!; + ordered.push(commit); + for (const childSha of children.get(commit.sha) ?? []) { + const remaining = (remainingParents.get(childSha) ?? 1) - 1; + remainingParents.set(childSha, remaining); + if (remaining === 0) { + ready.push(bySha.get(childSha)!); + ready.sort(compareReadyCommits); + } + } + } + + if (ordered.length !== commits.length) { + throw new Error('Review commit stack contains a cycle.'); + } + return ordered; +}; + +/** Assert that an existing stack already obeys the canonical ordering contract. */ +export const validateReviewCommitStack = ( + commits: ReadonlyArray, +): ReviewCommitStack => { + const ordered = orderReviewCommitStack(commits); + for (let index = 0; index < commits.length; index += 1) { + if (ordered[index]!.sha !== commits[index]!.sha) { + throw new Error(`Review commit stack is not parent-before-child at ${commits[index]!.sha}.`); + } + } + return commits; +}; diff --git a/docs/review-history-packages.md b/docs/review-history-packages.md new file mode 100644 index 00000000..f6f55ac3 --- /dev/null +++ b/docs/review-history-packages.md @@ -0,0 +1,53 @@ +# Review-history package boundaries + +Core owns commit-diff request keys, stack/range/blob schemas, and +request-local reuse. Provider packages turn GitHub and GitLab responses into +those schemas. Hosts own authentication, process I/O, and UI. + +## Ownership + +| Boundary | Responsibility | +| ------------------- | --------------------------------------------------------------------------- | +| `@nkzw/codiff-core` | Commit+parent request keys, artifact schemas, validation, and one-run reuse | +| Provider packages | Current-review normalization and `ReviewArtifactSource` implementations | +| Hosts | Authentication, network and process I/O, caching, cancellation, and UI | + +Provider wire records do not cross into Core algorithms or shared UI. Provider +packages do not authenticate requests or spawn local processes. + +## Commit diffs + +A merge commit `M` with parents `A` and `B` has two different diffs: `M` vs +`A`, and `M` vs `B`. If a cache or in-flight map is keyed only by `M`, the +second read overwrites the first. The request key is `commitSha:parentSha` +(`M:A`, `M:B`). A root commit uses `M:root`. + +GitHub compare is a second case. Asking for `requestedBase...head` may return +a different `merge_base_commit`. Dedupe the in-flight read by the pair we +asked for. Record the effective base GitHub actually used on the returned +range and stack. Two selectors that resolve to the same merge-base must not +share one pending request. + +## Immutable review artifacts + +- `StackSnapshot` records a parent-first commit stack for one exact range. +- `CommitArtifact` records the change from one selected parent. +- `RangeArtifact` records the net tree change for one base/head pair. +- `BlobArtifact` records bounded full-file bytes by Git object identity. +- `FileBlobArtifactRequest` resolves a path at an exact commit SHA when the + caller does not yet know the object ID. + +Every stack, commit, and range artifact carries provenance and explicit +completeness. Missing or truncated evidence is never represented as a complete +empty patch. + +`ReviewArtifactSource.readStackAndRange` accepts a typed request whose +`requestedBaseSha` is the selector sent to the provider and whose `headSha` is +the requested review head. Providers may resolve that selector to an effective +base: `RangeArtifact.baseSha` and `StackSnapshot.baseSha` always record that +effective base, while their heads must remain the requested head. A +same-commit request stays an empty same-commit result. + +`createReviewArtifactRun` deduplicates overlapping reads only within one +request. Range reads are cached and diagnosed by their requested selector +pair, not by a provider-resolved effective pair. From 5b5d2d08031d90e0fe93d73875ecc38972ac5232 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 10:46:18 -0500 Subject: [PATCH 04/17] Resolve current GitHub and GitLab reviews into shared artifacts Implement provider-neutral range, stack, selected-parent commit, and blob artifact reads in the GitHub and GitLab packages, with Electron retained as the CLI transport. Convert provider ranges into exact DiffRanges and adapt commit content to one native-first contract that falls back only for missing objects, rejects moved heads, and preserves provider runtime packaging. --- .github/workflows/build-app.yml | 14 +- .gitignore | 1 + core/App.tsx | 84 +++ core/__tests__/App-render.test.tsx | 57 ++ core/__tests__/git-state.test.ts | 251 +++++-- core/__tests__/gitlab.test.ts | 68 +- core/app/hooks/useAppReviewComments.ts | 1 + core/global.d.ts | 8 + core/index.ts | 1 + core/lib/review-artifacts.ts | 10 + core/types/review-history.ts | 2 + core/types/review-identity.ts | 9 + electron/__tests__/forge-package.test.ts | 41 + .../__tests__/gh-github-transport.test.ts | 231 ++++++ .../__tests__/glab-gitlab-transport.test.ts | 356 +++++++++ .../__tests__/provider-review-state.test.ts | 420 +++++++++++ .../__tests__/pull-request-command.test.ts | 36 +- .../__tests__/pull-request-remote.test.ts | 4 +- electron/git-state.cjs | 90 ++- .../github-history/gh-github-transport.cjs | 490 ++++++++++++ electron/git-state/glab-gitlab-transport.cjs | 453 ++++++++++++ electron/git-state/merge-request.cjs | 698 ++++++++++-------- .../git-state/provider-artifact-sources.cjs | 265 +++++++ electron/git-state/pull-request.cjs | 665 ++++++++--------- electron/git-state/review-range-sections.cjs | 280 +++++++ electron/github-history-bridge.cjs | 23 + electron/gitlab-history-bridge.cjs | 23 + electron/main.cjs | 15 + electron/preload.cjs | 2 + forge.config.cjs | 21 +- github/__tests__/current-review.test.ts | 359 +++++++++ github/__tests__/package.test.ts | 33 + github/package.json | 36 + github/src/current-review.ts | 554 ++++++++++++++ github/src/index.ts | 2 + github/src/transport.ts | 48 ++ github/tsconfig.build.json | 13 + github/vite.config.ts | 12 + gitlab/__tests__/current-review.test.ts | 295 ++++++++ gitlab/__tests__/transport.test.ts | 103 +++ gitlab/package.json | 36 + gitlab/src/current-review.ts | 548 ++++++++++++++ gitlab/src/index.ts | 2 + gitlab/src/transport.ts | 44 ++ gitlab/tsconfig.build.json | 13 + gitlab/vite.config.ts | 12 + package.json | 42 +- pnpm-lock.yaml | 12 + pnpm-workspace.yaml | 2 + scripts/verify-package-runtime.mjs | 121 +++ test/fake-provider-transports.ts | 165 +++++ vite.config.ts | 6 + 52 files changed, 6280 insertions(+), 797 deletions(-) create mode 100644 electron/__tests__/forge-package.test.ts create mode 100644 electron/__tests__/gh-github-transport.test.ts create mode 100644 electron/__tests__/glab-gitlab-transport.test.ts create mode 100644 electron/__tests__/provider-review-state.test.ts create mode 100644 electron/git-state/github-history/gh-github-transport.cjs create mode 100644 electron/git-state/glab-gitlab-transport.cjs create mode 100644 electron/git-state/provider-artifact-sources.cjs create mode 100644 electron/git-state/review-range-sections.cjs create mode 100644 electron/github-history-bridge.cjs create mode 100644 electron/gitlab-history-bridge.cjs create mode 100644 github/__tests__/current-review.test.ts create mode 100644 github/__tests__/package.test.ts create mode 100644 github/package.json create mode 100644 github/src/current-review.ts create mode 100644 github/src/index.ts create mode 100644 github/src/transport.ts create mode 100644 github/tsconfig.build.json create mode 100644 github/vite.config.ts create mode 100644 gitlab/__tests__/current-review.test.ts create mode 100644 gitlab/__tests__/transport.test.ts create mode 100644 gitlab/package.json create mode 100644 gitlab/src/current-review.ts create mode 100644 gitlab/src/index.ts create mode 100644 gitlab/src/transport.ts create mode 100644 gitlab/tsconfig.build.json create mode 100644 gitlab/vite.config.ts create mode 100644 scripts/verify-package-runtime.mjs create mode 100644 test/fake-provider-transports.ts diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index cb893aa1..ebe87066 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -73,8 +73,11 @@ jobs: - name: Install Node Dependencies run: pnpm install - - name: Build Renderer - run: pnpm exec vp build + - name: Build Renderer and Runtime Bridges + run: | + pnpm run build:runtime + pnpm exec vp build + node ./scripts/verify-package-runtime.mjs - name: Build Linux App timeout-minutes: 45 @@ -139,8 +142,11 @@ jobs: - name: Install Node Dependencies run: pnpm install - - name: Build Renderer - run: pnpm exec vp build + - name: Build Renderer and Runtime Bridges + run: | + pnpm run build:runtime + pnpm exec vp build + node ./scripts/verify-package-runtime.mjs - name: Build Windows App shell: bash diff --git a/.gitignore b/.gitignore index 113cc798..77d9de93 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ **/.env.*.local .cache/ .pnpm-debug.log +/.pnpm-store/ /node_modules/ **/node_modules/ coverage/ diff --git a/core/App.tsx b/core/App.tsx index b767ac42..16fe7e59 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -87,6 +87,7 @@ import { buildReviewCommentsMarkdown, getReviewCommentsFromState, getVisibleReviewComments, + mergeReviewComments, } from './lib/review-comments.ts'; import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; import { @@ -127,6 +128,10 @@ import type { const emptyReviewComments: ReadonlyArray = []; const emptyWalkthroughNotes = new Map(); +const getReviewCommentsSourceKey = (state: RepositoryState) => { + const headSha = state.source.type === 'pull-request' ? (state.source.headSha ?? '') : ''; + return `${state.root}:${getSourceKey(state.source)}:${headSha}`; +}; const disableCodeViewWorkerPool = process.env.NODE_ENV === 'test'; const getFailedSectionLoadState = (section: DiffSection): DiffSection => @@ -216,6 +221,8 @@ export default function App() { const historyRequestRef = useRef(0); const historySourceRef = useRef(null); const loadingSectionKeysRef = useRef>(new Set()); + const reviewCommentsInFlightRef = useRef(null); + const reviewCommentsRequestRef = useRef(0); const programmaticScrollPathRef = useRef(null); const programmaticScrollTimerRef = useRef(null); const sourceSessionsRef = useRef>(new Map()); @@ -283,6 +290,83 @@ export default function App() { onCommentFileChange: bumpItemVersion, stateRef, }); + const hydrateReviewComments = useCallback( + (requestedState: RepositoryState) => { + if ( + requestedState.source.type !== 'pull-request' || + requestedState.reviewCommentsLoadState !== 'not-loaded' + ) { + return; + } + const sourceKey = getReviewCommentsSourceKey(requestedState); + const generation = stateGenerationRef.current; + const inFlightKey = `${generation}:${sourceKey}`; + if (reviewCommentsInFlightRef.current === inFlightKey) { + return; + } + reviewCommentsInFlightRef.current = inFlightKey; + const request = reviewCommentsRequestRef.current + 1; + reviewCommentsRequestRef.current = request; + const isCurrent = () => { + const current = stateRef.current; + return ( + reviewCommentsRequestRef.current === request && + stateGenerationRef.current === generation && + current?.source.type === 'pull-request' && + getReviewCommentsSourceKey(current) === sourceKey + ); + }; + + void window.codiff + .getReviewComments(requestedState.source) + .then((loadedComments) => { + if (!isCurrent()) { + return; + } + const current = stateRef.current!; + const hydratedState = { + ...current, + reviewComments: loadedComments, + reviewCommentsError: undefined, + reviewCommentsLoadState: 'loaded' as const, + }; + stateRef.current = hydratedState; + setState(hydratedState); + setReviewComments((comments) => + mergeReviewComments( + getReviewCommentsFromState(hydratedState), + comments.filter((comment) => !comment.isReadOnly), + ), + ); + }) + .catch((error: unknown) => { + if (!isCurrent()) { + return; + } + const current = stateRef.current!; + const failedState = { + ...current, + reviewCommentsError: error instanceof Error ? error.message : String(error), + reviewCommentsLoadState: 'failed' as const, + }; + stateRef.current = failedState; + setState(failedState); + }) + .finally(() => { + if (reviewCommentsInFlightRef.current === inFlightKey) { + reviewCommentsInFlightRef.current = null; + } + }); + }, + [setReviewComments], + ); + + useEffect(() => { + if (state?.source.type === 'pull-request' && state.reviewCommentsLoadState === 'not-loaded') { + hydrateReviewComments(state); + } + }, [hydrateReviewComments, state]); + const collapseSidebar = useCallback(() => { setSidebarCollapsed(true); }, []); diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx index 79468e05..132d188e 100644 --- a/core/__tests__/App-render.test.tsx +++ b/core/__tests__/App-render.test.tsx @@ -150,6 +150,7 @@ const createCodiffMock = (overrides: Partial = {}): Window['co getDiffSectionContent: vi.fn(async () => { throw new Error('Unexpected diff section load.'); }), + getDiffSectionsContent: vi.fn(async () => ({ sections: [] })), getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false, @@ -200,6 +201,7 @@ const createCodiffMock = (overrides: Partial = {}): Window['co root: '/repo', })), getRepositoryState: vi.fn(async () => repositoryState), + getReviewComments: vi.fn(async () => []), getTerminalHelperStatus: vi.fn(async () => ({ command: 'codiff', installed: true, @@ -3762,3 +3764,58 @@ test('Pi not-found walkthrough errors show the agent recovery panel', async () = expect(container.textContent).toContain('Pi CLI was not found.'); expect(container.textContent).toContain('Review Files'); }); + +test('pull request comments hydrate through the public preload capability', async () => { + const file = { + fingerprint: 'src/app.ts:pull-request', + path: 'src/app.ts', + sections: [ + { + binary: false, + id: 'src/app.ts:pull-request:1', + kind: 'pull-request', + loadState: 'ready', + patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + }, + ], + status: 'modified', + } satisfies ChangedFile; + const source = { + headSha: gitSha('b'.repeat(40)), + number: 1, + owner: 'octo', + provider: 'github', + repo: 'example', + type: 'pull-request', + url: 'https://github.com/octo/example/pull/1', + } satisfies ResolvedReviewSource; + const getReviewComments = vi.fn(async () => [ + { + author: { login: 'reviewer' }, + body: 'Loaded after the initial review state.', + filePath: file.path, + id: 'github:1', + lineNumber: 1, + side: 'additions' as const, + threadId: '1', + }, + ]); + window.codiff = createCodiffMock({ + getRepositoryState: vi.fn(async () => ({ + branch: 'feature', + files: [file], + generatedAt: 1, + launchPath: '/repo', + reviewCommentsLoadState: 'not-loaded' as const, + root: '/repo', + source, + })), + getReviewComments, + }); + + await using view = await renderReact(); + await waitFor(() => { + expect(getReviewComments).toHaveBeenCalledWith(source); + expect(view.container.textContent).toContain('Loaded after the initial review state.'); + }); +}); diff --git a/core/__tests__/git-state.test.ts b/core/__tests__/git-state.test.ts index e94b0f4b..1398dc16 100644 --- a/core/__tests__/git-state.test.ts +++ b/core/__tests__/git-state.test.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; import { expect, test } from 'vite-plus/test'; import { fileHasVisibleDiff, getDiffLineCount } from '../lib/diff.ts'; +import type { ArtifactFile } from '../lib/review-artifacts.ts'; import type { DiffSection, DiffSectionContentRequest, @@ -55,14 +56,20 @@ type GitStateModule = { ) => ReadonlyArray; createPullRequestSection: ( pullRequest: { number: number; owner: string; repo: string; url: string }, - file: { filename: string; patch?: string; previous_filename?: string; status: string }, - patch: string, + file: ArtifactFile, oldFile?: PullRequestFileContent, newFile?: PullRequestFileContent, + rangeRefs?: { + base?: string; + contentAttempted?: boolean; + deferContents?: boolean; + head?: string; + }, ) => DiffSection; createPullRequestSource: ( pullRequest: { number: number; owner: string; repo: string; url: string }, metadata: { + base?: { ref?: string }; body?: string | null; head?: { sha?: string }; title?: string; @@ -120,6 +127,7 @@ type GitStateModule = { repoRoot: string, pullRequest: { number: number; owner: string; repo: string; url: string }, metadata: { base?: { ref?: string; sha?: string }; head?: { ref?: string; sha?: string } }, + expectedBaseSha: string, ) => Promise<{ base: string; head: string } | null>; selectUnresolvedReviewComments: ( comments: ReadonlyArray>, @@ -131,8 +139,9 @@ type GitStateModule = { comment: { body: string; filePath: string; - lineNumber: number; - side: 'additions' | 'deletions'; + lineNumber?: number; + side?: 'additions' | 'deletions'; + threadId?: string; }; source: Extract; }, @@ -191,7 +200,10 @@ const commitAll = async (repo: string, message: string) => { const withFakeGitHub = async ( mode: 'diagnosis-fails' | 'no-pending-review' | 'pending-review' | 'success', - callback: (repo: string, readCalls: () => ReadonlyArray>) => Promise, + callback: ( + repo: string, + readCalls: () => ReadonlyArray<{ args: ReadonlyArray; input: string }>, + ) => Promise, ) => { await using repoDirectory = await createTemporaryDirectory('codiff-git-state-'); const repo = await realpath(repoDirectory.path); @@ -214,11 +226,13 @@ process.stdin.on('end', () => { const endpoint = args.find((argument) => argument.startsWith('repos/')) || ''; if (endpoint.endsWith('/comments')) { if (process.env.CODIFF_GITHUB_TEST_MODE === 'success') { + const payload = input ? JSON.parse(input) : {}; process.stdout.write(JSON.stringify({ - body: 'Keep this comment.', + body: payload.body || 'Keep this comment.', created_at: '2026-07-10T12:00:00Z', html_url: 'https://github.com/octo/example/pull/118#discussion_r1', - id: 1, + id: payload.in_reply_to ? 2 : 1, + in_reply_to_id: payload.in_reply_to, line: 1, path: 'src/app.ts', side: 'RIGHT', @@ -259,7 +273,7 @@ process.stdin.on('end', () => { .trim() .split('\n') .filter(Boolean) - .map((line) => (JSON.parse(line) as { args: ReadonlyArray }).args), + .map((line) => JSON.parse(line) as { args: ReadonlyArray; input: string }), ); }; @@ -470,7 +484,7 @@ test('readWorkingTreeState compares delete-by-us conflicts against an empty file expect(patchOnlyState.files[0].sections[0].oldFile?.contents).toBe(''); expect(patchOnlyState.files[0].sections[0].newFile?.contents).toBe('theirs\n'); }); -}); +}, 15_000); test('readWorkingTreeState omits unmerged diagnostics when our conflict version is unchanged', async () => { await withRepo(async (repo) => { @@ -585,9 +599,16 @@ const pullRequestFixture = { url: 'https://github.com/nkzw-tech/codiff/pull/7', }; +const pullRequestArtifactFile = ( + path: string, + status: ArtifactFile['status'], + patch: string, +): ArtifactFile => ({ coverage: 'complete', patch, path, status }); + test('createPullRequestSource normalizes non-empty GitHub PR descriptions', () => { expect( createPullRequestSource(pullRequestFixture, { + base: { ref: 'main' }, body: '\n## Intent\n\nShip the focused fix.\n', head: { sha: 'head-sha' }, title: 'Focused fix', @@ -605,6 +626,7 @@ test('createPullRequestSource normalizes non-empty GitHub PR descriptions', () = }, description: '## Intent\n\nShip the focused fix.', provider: 'github', + targetBranch: 'main', title: 'Focused fix', type: 'pull-request', }); @@ -619,10 +641,14 @@ test('createPullRequestSource omits blank GitHub PR descriptions', () => { test('createPullRequestSection renders full contents so pull request diffs can expand context', () => { const section = createPullRequestSection( pullRequestFixture, - { filename: 'src/app.ts', status: 'modified' }, - 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + pullRequestArtifactFile( + 'src/app.ts', + 'modified', + 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + ), { binary: false, file: { cacheKey: 'base:src/app.ts', contents: 'old\n', name: 'src/app.ts' } }, { binary: false, file: { cacheKey: 'head:src/app.ts', contents: 'new\n', name: 'src/app.ts' } }, + { base: 'a'.repeat(40), head: 'b'.repeat(40) }, ); expect(section.id).toBe('src/app.ts:pull-request:7'); @@ -635,18 +661,61 @@ test('createPullRequestSection renders full contents so pull request diffs can e test('createPullRequestSection falls back to a non-loadable patch section without contents', () => { const section = createPullRequestSection( pullRequestFixture, - { filename: 'src/app.ts', status: 'modified' }, - 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + pullRequestArtifactFile( + 'src/app.ts', + 'modified', + 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + ), ); expect(section.oldFile).toBeUndefined(); expect(section.newFile).toBeUndefined(); expect(section.loadState).toBe('ready'); - // Pull request contents load up front, so a patch-only fallback is not - // loadable on demand (there is no pull-request section-content loader). expect(section.summary?.canLoad).toBe(false); }); +test('createPullRequestSection keeps provider patches visible while exact contents are deferred', () => { + const patch = 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n'; + const section = createPullRequestSection( + pullRequestFixture, + pullRequestArtifactFile('src/app.ts', 'modified', patch), + undefined, + undefined, + { base: 'a'.repeat(40), deferContents: true, head: 'b'.repeat(40) }, + ); + + expect(section).toMatchObject({ + binary: false, + loadState: 'deferred', + patch, + summary: { canLoad: true }, + }); +}); + +test('createPullRequestSection makes failed provider hydration terminal', () => { + const section = createPullRequestSection( + pullRequestFixture, + { + ...pullRequestArtifactFile('src/app.ts', 'modified', ''), + newObjectId: 'b'.repeat(40), + oldObjectId: 'a'.repeat(40), + }, + undefined, + undefined, + { + base: 'a'.repeat(40), + contentAttempted: true, + head: 'b'.repeat(40), + }, + ); + + expect(section).toMatchObject({ + loadState: 'error', + patch: '', + summary: { canLoad: false }, + }); +}); + test('createPullRequestSection treats the binary marker as a diff line, not patch content', () => { // The patch's content adds a line that contains the binary-marker text. It // must not be mistaken for an actual binary diff. @@ -654,8 +723,7 @@ test('createPullRequestSection treats the binary marker as a diff line, not patc 'diff --git a/re.ts b/re.ts\n@@ -1 +1 @@\n-const a = 1;\n+const re = /Binary files .* differ/;\n'; const section = createPullRequestSection( pullRequestFixture, - { filename: 're.ts', status: 'modified' }, - patch, + pullRequestArtifactFile('re.ts', 'modified', patch), { binary: false, file: { cacheKey: 'old', contents: 'const a = 1;\n', name: 're.ts' } }, { binary: false, @@ -672,8 +740,11 @@ test('createPullRequestSection treats the binary marker as a diff line, not patc test('createPullRequestSection keeps full contents for added files', () => { const section = createPullRequestSection( pullRequestFixture, - { filename: 'new.ts', status: 'added' }, - 'diff --git a/new.ts b/new.ts\n--- /dev/null\n+++ b/new.ts\n@@ -0,0 +1 @@\n+hello\n', + pullRequestArtifactFile( + 'new.ts', + 'added', + 'diff --git a/new.ts b/new.ts\n--- /dev/null\n+++ b/new.ts\n@@ -0,0 +1 @@\n+hello\n', + ), { binary: false, file: { cacheKey: 'base:new.ts:empty', contents: '', name: 'new.ts' } }, { binary: false, file: { cacheKey: 'head:new.ts', contents: 'hello\n', name: 'new.ts' } }, ); @@ -686,8 +757,11 @@ test('createPullRequestSection keeps full contents for added files', () => { test('createPullRequestSection falls back to the patch for binary files', () => { const section = createPullRequestSection( pullRequestFixture, - { filename: 'logo.png', status: 'modified' }, - 'diff --git a/logo.png b/logo.png\nBinary files a/logo.png and b/logo.png differ\n', + pullRequestArtifactFile( + 'logo.png', + 'modified', + 'diff --git a/logo.png b/logo.png\nBinary files a/logo.png and b/logo.png differ\n', + ), ); expect(section.binary).toBe(true); @@ -700,8 +774,7 @@ test('createPullRequestSection falls back to the patch when modified contents fa const patch = 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n'; const section = createPullRequestSection( pullRequestFixture, - { filename: 'src/app.ts', status: 'modified' }, - patch, + pullRequestArtifactFile('src/app.ts', 'modified', patch), { binary: false, file: { cacheKey: 'base:empty', contents: '', name: 'src/app.ts' } }, { binary: false, file: { cacheKey: 'head:empty', contents: '', name: 'src/app.ts' } }, ); @@ -734,10 +807,15 @@ test('resolvePullRequestContentRefs resolves the diff against the merge base', a const baseTip = (await git(repo, ['rev-parse', 'HEAD'])).trim(); await git(repo, ['update-ref', 'refs/codiff/pull-requests/7/base', baseTip]); - const refs = await resolvePullRequestContentRefs(repo, pullRequestFixture, { - base: { ref: 'main' }, - head: { sha: headSha }, - }); + const refs = await resolvePullRequestContentRefs( + repo, + pullRequestFixture, + { + base: { ref: 'main' }, + head: { sha: headSha }, + }, + mergeBase, + ); expect(refs).toEqual({ base: mergeBase, head: 'refs/codiff/pull-requests/7/head' }); // The base tip is intentionally not used; only the PR's own changes show. @@ -765,10 +843,15 @@ test('resolvePullRequestContentRefs refetches when the fetched base no longer ma // base branch moved or the pull request was retargeted). Resolution must // refetch rather than diff against the stale local base; with no remote // configured here the refetch fails and resolution reports it cannot resolve. - const refs = await resolvePullRequestContentRefs(repo, pullRequestFixture, { - base: { ref: 'main', sha: '0'.repeat(40) }, - head: { sha: headSha }, - }); + const refs = await resolvePullRequestContentRefs( + repo, + pullRequestFixture, + { + base: { ref: 'main', sha: '0'.repeat(40) }, + head: { sha: headSha }, + }, + mergeBase, + ); expect(refs).toBeNull(); }); @@ -846,9 +929,26 @@ test('normalizeGitHubReviewComment preserves multi-line ranges', () => { lineNumber: 8, side: 'additions', startLineNumber: 5, + threadId: '1', }); }); +test('normalizeGitHubReviewComment keeps replies in their root thread', () => { + expect( + normalizeGitHubReviewComment({ + body: 'Reply.', + created_at: '2026-05-19T00:00:00Z', + html_url: 'https://github.com/nkzw-tech/codiff/pull/1#discussion_r2', + id: 2, + in_reply_to_id: 1, + line: 8, + path: 'src/file.ts', + side: 'RIGHT', + user: { login: 'reviewer' }, + }), + ).toMatchObject({ id: 'github:2', threadId: '1' }); +}); + test('normalizeGitHubReviewComment flags comments anchored to outdated lines', () => { expect( normalizeGitHubReviewComment({ @@ -1015,7 +1115,7 @@ test('pull request comments explain when a pending GitHub review blocks submissi ); expect( - readCalls().some((args) => + readCalls().some(({ args }) => args.some((argument) => argument.includes('/reviews?per_page=100')), ), ).toBe(true); @@ -1043,13 +1143,40 @@ test('successful pull request comments skip pending review diagnosis', async () }); expect( - readCalls().some((args) => + readCalls().some(({ args }) => args.some((argument) => argument.includes('/reviews?per_page=100')), ), ).toBe(false); }); }); +test('pull request replies use GitHub thread identity without new position fields', async () => { + await withFakeGitHub('success', async (repo, readCalls) => { + await expect( + submitPullRequestComment(repo, { + comment: { + body: 'Reply in the existing thread.', + filePath: 'src/app.ts', + threadId: '7', + }, + source: pullRequestCommentRequest.source, + }), + ).resolves.toMatchObject({ + body: 'Reply in the existing thread.', + id: 'github:2', + threadId: '7', + }); + + const call = readCalls().find(({ args }) => + args.some((argument) => argument.endsWith('/comments')), + ); + expect(JSON.parse(call?.input || '{}')).toEqual({ + body: 'Reply in the existing thread.', + in_reply_to: 7, + }); + }); +}); + test('parseStatus preserves staged and unstaged flags on the same file', () => { expect(parseStatus('MM file.txt\0')).toEqual([ { @@ -1829,7 +1956,7 @@ test('readRepositoryState reads merge commits against the first parent', async ( expect(state.files[0].sections[0].newFile?.contents).toBe('feature\n'); expect(state.files[0].sections[0].patch).toContain('+feature'); }); -}); +}, 15_000); test('reads commit diffs for many changed files', async () => { await withRepo(async (repo) => { @@ -1926,30 +2053,34 @@ test('readRepositoryState rejects non-repository launch paths', async () => { await expect(readRepositoryState(directory.path)).rejects.toThrow(/not a git repository/i); }); -test('readRepositoryState builds a diff for a base...head range', () => - withRepo(async (repo) => { - await writeRepoFile(repo, 'keep.txt', 'base\n'); - await commitAll(repo, 'first'); - await git(repo, ['branch', 'base']); - await writeRepoFile(repo, 'keep.txt', 'base\nmore\n'); - await writeRepoFile(repo, 'added.txt', 'new file\n'); - await commitAll(repo, 'second'); - await git(repo, ['branch', 'head']); - // A commit made only on base must not appear in base...head (merge-base diff). - await git(repo, ['checkout', 'base']); - await writeRepoFile(repo, 'base-only.txt', 'base only\n'); - await commitAll(repo, 'base-only'); - - const state = await readRepositoryState(repo, { - base: 'base', - head: 'head', - symmetric: true, - type: 'range', - }); +test( + 'readRepositoryState builds a diff for a base...head range', + () => + withRepo(async (repo) => { + await writeRepoFile(repo, 'keep.txt', 'base\n'); + await commitAll(repo, 'first'); + await git(repo, ['branch', 'base']); + await writeRepoFile(repo, 'keep.txt', 'base\nmore\n'); + await writeRepoFile(repo, 'added.txt', 'new file\n'); + await commitAll(repo, 'second'); + await git(repo, ['branch', 'head']); + // A commit made only on base must not appear in base...head (merge-base diff). + await git(repo, ['checkout', 'base']); + await writeRepoFile(repo, 'base-only.txt', 'base only\n'); + await commitAll(repo, 'base-only'); + + const state = await readRepositoryState(repo, { + base: 'base', + head: 'head', + symmetric: true, + type: 'range', + }); - expect(state.source).toEqual({ base: 'base', head: 'head', symmetric: true, type: 'range' }); - expect(state.files.map((file) => file.path).sort()).toEqual(['added.txt', 'keep.txt']); - const added = state.files.find((file) => file.path === 'added.txt'); - expect(added?.status).toBe('added'); - expect(added?.sections[0]?.patch).toContain('new file'); - })); + expect(state.source).toEqual({ base: 'base', head: 'head', symmetric: true, type: 'range' }); + expect(state.files.map((file) => file.path).sort()).toEqual(['added.txt', 'keep.txt']); + const added = state.files.find((file) => file.path === 'added.txt'); + expect(added?.status).toBe('added'); + expect(added?.sections[0]?.patch).toContain('new file'); + }), + 15_000, +); diff --git a/core/__tests__/gitlab.test.ts b/core/__tests__/gitlab.test.ts index ffda0904..a54362f0 100644 --- a/core/__tests__/gitlab.test.ts +++ b/core/__tests__/gitlab.test.ts @@ -16,8 +16,10 @@ type GitLabPosition = Record & { const { createGitLabPosition, createMergeRequestFetchRefspecs, + createMergeRequestSource, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, + readMergeRequestReviewComments, submitMergeRequestComment, submitMergeRequestReview, } = require('../../electron/git-state/merge-request.cjs') as { @@ -30,11 +32,20 @@ const { mergeRequest: Record, metadata: Record, ) => ReadonlyArray; + createMergeRequestSource: ( + mergeRequest: Record, + metadata: Record, + ) => Record; normalizeGitLabReviewComment: ( note: Record, url: string, + threadId?: string, ) => Record | null; parseGitLabMergeRequestUrl: (url: string) => Record; + readMergeRequestReviewComments: ( + launchPath: string, + source: Record, + ) => Promise>>; submitMergeRequestComment: ( launchPath: string, request: { @@ -95,6 +106,19 @@ process.stdin.on('end', () => { ); return; } + if (endpoint.endsWith('/discussions?per_page=100')) { + process.stdout.write(JSON.stringify([{ + id: 'discussion-from-provider', + notes: [{ + author: { username: 'reviewer' }, + body: 'Loaded discussion comment.', + created_at: '2026-07-08T00:00:00Z', + id: 47, + position: { new_line: 12, new_path: 'src/new.ts', old_path: 'src/old.ts' }, + }], + }])); + return; + } if (endpoint.endsWith('/discussions/discussion%2Fwith%20spaces/notes')) { process.stdout.write(JSON.stringify({ author: { username: 'reviewer' }, @@ -176,6 +200,24 @@ describe('GitLab merge requests', () => { ]); }); + test('preserves the GitLab target branch in the review source', () => { + expect( + createMergeRequestSource( + { + host: 'gitlab.example.com', + number: 23, + projectPath: 'group/project', + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', + }, + { + sha: 'head-sha', + target_branch: 'main', + title: 'Focused fix', + }, + ), + ).toMatchObject({ provider: 'gitlab', targetBranch: 'main', type: 'pull-request' }); + }); + test('builds single-line and ranged GitLab diff positions', () => { const metadata = { diff_refs: { @@ -289,6 +331,7 @@ describe('GitLab merge requests', () => { }, }, 'https://gitlab.example.com/group/project/-/merge_requests/23', + 'discussion-44', ), ).toMatchObject({ author: { login: 'reviewer' }, @@ -297,6 +340,7 @@ describe('GitLab merge requests', () => { id: 'gitlab:44', lineNumber: 12, side: 'additions', + threadId: 'discussion-44', url: 'https://gitlab.example.com/group/project/-/merge_requests/23#note_44', }); }); @@ -389,6 +433,24 @@ describe('GitLab merge requests', () => { }); }); + test('loads GitLab discussion IDs as provider thread identity', async () => { + await withFakeGitLab(async (repo) => { + await expect( + readMergeRequestReviewComments(repo, { + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', + }), + ).resolves.toEqual([ + expect.objectContaining({ + body: 'Loaded discussion comment.', + id: 'gitlab:47', + threadId: 'discussion-from-provider', + }), + ]); + }); + }); + test('preserves submitted metadata when GitLab discussion replies omit positions', async () => { await withFakeGitLab(async (repo, readCalls) => { await expect( @@ -416,7 +478,7 @@ describe('GitLab merge requests', () => { const [call] = await readCalls(); expect(call.args.at(-1)).toBe( - 'projects/group%2Fproject/merge_requests/23/discussions/discussion%2Fwith%20spaces/notes', + '/projects/group%2Fproject/merge_requests/23/discussions/discussion%2Fwith%20spaces/notes', ); expect(call.args).toContain('Content-Type: application/json'); expect(JSON.parse(call.input)).toEqual({ body: 'Reply in the existing discussion.' }); @@ -456,7 +518,7 @@ if [ "$GITLAB_TOKEN" != 'from-login-shell' ]; then echo 'To get started with GitLab CLI, please run: glab auth login.' >&2 exit 4 fi -printf '%s' '{}' +printf '%s' '[]' `, ); await Promise.all([chmod(fakeShell, 0o755), chmod(fakeGlab, 0o755)]); @@ -507,7 +569,7 @@ if [ "$GITLAB_TOKEN" != 'from-process' ]; then echo 'Expected the process GITLAB_TOKEN to win, got:' "$GITLAB_TOKEN" >&2 exit 4 fi -printf '%s' '{}' +printf '%s' '[]' `, ); await Promise.all([chmod(fakeShell, 0o755), chmod(fakeGlab, 0o755)]); diff --git a/core/app/hooks/useAppReviewComments.ts b/core/app/hooks/useAppReviewComments.ts index 7b102409..d6a5d570 100644 --- a/core/app/hooks/useAppReviewComments.ts +++ b/core/app/hooks/useAppReviewComments.ts @@ -172,6 +172,7 @@ export function useAppReviewComments({ ...(submittedComment.side ? { side: submittedComment.side } : {}), ...getReviewCommentRangeProps(submittedComment), submittedAt: submittedComment.submittedAt, + ...(submittedComment.threadId ? { threadId: submittedComment.threadId } : {}), url: submittedComment.url, } : candidate, diff --git a/core/global.d.ts b/core/global.d.ts index afcb4f3b..1253b3c4 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -13,6 +13,8 @@ import type { DiffImageContentResult, DiffSection, DiffSectionContentRequest, + DiffSectionsContentRequest, + DiffSectionsContentResult, GitIdentity, NarrativeWalkthroughRequestOptions, NarrativeWalkthroughResult, @@ -59,6 +61,9 @@ declare global { getConfig: () => Promise; getDiffImageContent: (request: DiffImageContentRequest) => Promise; getDiffSectionContent: (request: DiffSectionContentRequest) => Promise; + getDiffSectionsContent: ( + request: DiffSectionsContentRequest, + ) => Promise; getFeatureFlags: () => Promise; getGitIdentity: () => Promise; getKeyboardLayout: () => Promise; @@ -75,6 +80,9 @@ declare global { getPreferences: () => Promise; getRepositoryHistory: (limit?: number, source?: ReviewSource) => Promise; getRepositoryState: (source?: ReviewSource) => Promise; + getReviewComments: ( + source: Extract, + ) => Promise>; getTerminalHelperStatus: () => Promise; getUpdateStatus: () => Promise; increaseCodeFontSize: () => Promise; diff --git a/core/index.ts b/core/index.ts index c37244c4..ca2a1620 100644 --- a/core/index.ts +++ b/core/index.ts @@ -58,6 +58,7 @@ export type { PullRequestReviewEvent, PullRequestReviewStatus, PullRequestReviewer, + ReviewCommitSummary, ReviewPreferences, RepositoryState, Revision, diff --git a/core/lib/review-artifacts.ts b/core/lib/review-artifacts.ts index 4f5f37ce..97627ab3 100644 --- a/core/lib/review-artifacts.ts +++ b/core/lib/review-artifacts.ts @@ -26,6 +26,7 @@ export type StackSnapshot = { export type ArtifactFile = { coverage: ArtifactCoverage; + lineCount?: { additions: number; deletions: number }; newMode?: string; newObjectId?: string; oldMode?: string; @@ -177,6 +178,15 @@ const validateCoverage = ( if (!file.path) { throw new Error(`${label} contains a file without a path.`); } + if ( + file.lineCount != null && + (!Number.isInteger(file.lineCount.additions) || + file.lineCount.additions < 0 || + !Number.isInteger(file.lineCount.deletions) || + file.lineCount.deletions < 0) + ) { + throw new Error(`${label} contains invalid line counts for ${file.path}.`); + } if (file.coverage === 'complete' && file.patch == null) { const hasExactMetadata = file.oldObjectId != null || diff --git a/core/types/review-history.ts b/core/types/review-history.ts index e72a790f..09427328 100644 --- a/core/types/review-history.ts +++ b/core/types/review-history.ts @@ -130,6 +130,8 @@ export type RepositoryState = { generatedAt: number; launchPath: string; reviewComments?: ReadonlyArray; + reviewCommentsError?: string; + reviewCommentsLoadState?: 'failed' | 'loaded' | 'not-loaded'; root: string; source: ResolvedReviewSource; }; diff --git a/core/types/review-identity.ts b/core/types/review-identity.ts index a9cea9d3..9516a3ef 100644 --- a/core/types/review-identity.ts +++ b/core/types/review-identity.ts @@ -9,6 +9,7 @@ export type DiffSection = { binary: boolean; id: string; kind: 'commit' | 'pull-request' | 'staged' | 'unstaged'; + lineCount?: { additions: number; deletions: number }; loadState?: 'binary' | 'deferred' | 'directory' | 'error' | 'ready' | 'too-large'; newFile?: { cacheKey?: string; contents: string; name: string }; oldFile?: { cacheKey?: string; contents: string; name: string }; @@ -119,6 +120,14 @@ export type DiffSectionContentRequest = { showWhitespace?: boolean; source?: ResolvedReviewSource; }; +export type DiffSectionsContentRequest = { + source: Extract; +}; + +export type DiffSectionsContentResult = { + headSha?: GitSha; + sections: ReadonlyArray<{ path: string; section: DiffSection }>; +}; export type DefinitionSearchRequest = { identifier: string; diff --git a/electron/__tests__/forge-package.test.ts b/electron/__tests__/forge-package.test.ts new file mode 100644 index 00000000..a9b59d1e --- /dev/null +++ b/electron/__tests__/forge-package.test.ts @@ -0,0 +1,41 @@ +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const forge = require('../../forge.config.cjs') as { + hooks: { + packageAfterCopy: (_config: unknown, buildPath: string) => Promise; + }; + packagerConfig: { + ignore: ReadonlyArray; + }; +}; + +const isIgnored = (path: string) => + forge.packagerConfig.ignore.some((pattern) => pattern.test(path)); + +test('Forge excludes provider sources and retains application entry points', () => { + expect(isIgnored('/github/src/index.ts')).toBe(true); + expect(isIgnored('/gitlab/src/index.ts')).toBe(true); + expect(isIgnored('/electron/main.cjs')).toBe(false); + expect(isIgnored('/dist/index.html')).toBe(false); +}); + +test('Forge restores built provider runtime artifacts after copy', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-forge-runtime-')); + try { + await forge.hooks.packageAfterCopy({}, directory); + for (const path of [ + 'core/lib/narrative-walkthrough-diff.cjs', + 'github/dist/index.mjs', + 'gitlab/dist/index.mjs', + ]) { + await expect(access(join(directory, path))).resolves.toBeUndefined(); + } + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/electron/__tests__/gh-github-transport.test.ts b/electron/__tests__/gh-github-transport.test.ts new file mode 100644 index 00000000..2b7a5fef --- /dev/null +++ b/electron/__tests__/gh-github-transport.test.ts @@ -0,0 +1,231 @@ +import { chmod, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { createGhGitHubTransport } = + require('../git-state/github-history/gh-github-transport.cjs') as { + createGhGitHubTransport: (options: { repoRoot: string }) => { + graphql: (request: { + maxBytes?: number; + query: string; + variables: Record; + }) => Promise; + request: (request: { + maxBytes?: number; + method?: string; + paginate?: boolean; + path: string; + query?: Record; + }) => Promise; + requestBuffer: (request: { + accept?: string; + maxBytes?: number; + path: string; + query?: Record; + }) => Promise; + requestText: (request: { + accept?: string; + maxBytes?: number; + paginate?: boolean; + path: string; + query?: Record; + }) => Promise; + }; + }; + +const previousGhPath = process.env.CODIFF_GH_PATH; +const previousCallsPath = process.env.CODIFF_GH_TEST_CALLS; + +afterEach(() => { + if (previousGhPath == null) delete process.env.CODIFF_GH_PATH; + else process.env.CODIFF_GH_PATH = previousGhPath; + if (previousCallsPath == null) delete process.env.CODIFF_GH_TEST_CALLS; + else process.env.CODIFF_GH_TEST_CALLS = previousCallsPath; +}); + +test('createGhGitHubTransport terminates oversized JSON, text, binary, paginated, and GraphQL responses', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'call\\n'); +process.on('SIGTERM', () => { + fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'term\\n'); + process.exit(0); +}); +const chunk = Buffer.alloc(16 * 1024, 120); +const write = () => { + if (!process.stdout.write(chunk)) { + process.stdout.once('drain', write); + return; + } + setImmediate(write); +}; +write(); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + process.env.CODIFF_GH_TEST_CALLS = callsPath; + + const transport = createGhGitHubTransport({ repoRoot: directory }); + const results = await Promise.allSettled([ + transport.request({ maxBytes: 8, path: '/repos/nkzw-tech/codiff/pulls/1' }), + transport.requestText({ + maxBytes: 8, + path: '/repos/nkzw-tech/codiff/readme', + }), + transport.requestBuffer({ + maxBytes: 8, + path: '/repos/nkzw-tech/codiff/git/blobs/deadbeef', + }), + transport.request({ + maxBytes: 8, + paginate: true, + path: '/repos/nkzw-tech/codiff/pulls/1/comments', + }), + transport.graphql({ + maxBytes: 8, + query: 'query { viewer { login } }', + variables: {}, + }), + ]); + + expect(results).toHaveLength(5); + for (const result of results) { + expect(result).toMatchObject({ + reason: expect.objectContaining({ + message: 'gh api response exceeded the 8-byte safety limit.', + name: 'ProviderOutputLimitError', + }), + status: 'rejected', + }); + } + const events = (await readFile(callsPath, 'utf8')).trim().split('\n'); + expect(events.filter((event) => event === 'call')).toHaveLength(5); + expect(events.filter((event) => event === 'term')).toHaveLength(5); +}); + +test('createGhGitHubTransport enforces each response bound on one shared GET', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'call\\n'); +setTimeout(() => process.stdout.write('[{"id":1}]'), 25); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + process.env.CODIFF_GH_TEST_CALLS = callsPath; + + const first = createGhGitHubTransport({ repoRoot: directory }); + const second = createGhGitHubTransport({ repoRoot: directory }); + const path = '/repos/nkzw-tech/codiff/pulls/1/comments'; + const [smallBound, largeBound] = await Promise.allSettled([ + first.request({ maxBytes: 2, paginate: true, path }), + second.request({ maxBytes: 1024, paginate: true, path }), + ]); + + expect(smallBound).toMatchObject({ + reason: expect.objectContaining({ name: 'ProviderOutputLimitError' }), + status: 'rejected', + }); + expect(largeBound).toEqual({ status: 'fulfilled', value: [{ id: 1 }] }); + expect((await readFile(callsPath, 'utf8')).trim()).toBe('call'); +}); + +test('createGhGitHubTransport shares paginated GETs across aliases, page sizes, and response bounds', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const alias = `${directory}-alias`; + const fakeGhPath = join(directory, 'gh'); + const callsPath = join(directory, 'calls.txt'); + await symlink(directory, alias); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'call\\n'); +setTimeout(() => process.stdout.write('[]'), 25); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + process.env.CODIFF_GH_TEST_CALLS = callsPath; + + const first = createGhGitHubTransport({ repoRoot: directory }); + const second = createGhGitHubTransport({ repoRoot: alias }); + const path = '/repos/nkzw-tech/codiff/pulls/7/comments'; + await expect( + Promise.all([ + first.request({ maxBytes: 8 * 1024 * 1024, paginate: true, path, query: { per_page: 100 } }), + second.request({ maxBytes: 2 * 1024 * 1024, paginate: true, path, query: { per_page: 50 } }), + ]), + ).resolves.toEqual([[], []]); + expect((await readFile(callsPath, 'utf8')).trim().split('\n')).toHaveLength(1); +}); + +test('createGhGitHubTransport preserves exact binary response bytes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +process.stdout.write(Buffer.from([0, 255, 10, 128])); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + + const transport = createGhGitHubTransport({ repoRoot: directory }); + const bytes = await transport.requestBuffer({ + path: '/repos/nkzw-tech/codiff/git/blobs/deadbeef', + }); + + expect([...bytes]).toEqual([0, 255, 10, 128]); +}); + +test('createGhGitHubTransport flattens all paginated response documents', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'call\\n'); +process.stdout.write('[\\n {"id": 1, "label": "nested } and \\\\"quoted\\\\" text"}\\n][{"id": 2}]'); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + process.env.CODIFF_GH_TEST_CALLS = callsPath; + + const first = createGhGitHubTransport({ repoRoot: directory }); + const second = createGhGitHubTransport({ repoRoot: directory }); + const path = '/repos/nkzw-tech/codiff/issues'; + const pages = await Promise.all([ + first.request({ paginate: true, path }), + second.request({ paginate: true, path }), + ]); + + expect(pages).toEqual([ + [{ id: 1, label: 'nested } and "quoted" text' }, { id: 2 }], + [{ id: 1, label: 'nested } and "quoted" text' }, { id: 2 }], + ]); + expect((await readFile(callsPath, 'utf8')).trim()).toBe('call'); +}); diff --git a/electron/__tests__/glab-gitlab-transport.test.ts b/electron/__tests__/glab-gitlab-transport.test.ts new file mode 100644 index 00000000..62a516ed --- /dev/null +++ b/electron/__tests__/glab-gitlab-transport.test.ts @@ -0,0 +1,356 @@ +import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { createGlabGitLabTransport } = require('../git-state/glab-gitlab-transport.cjs') as { + createGlabGitLabTransport: (options: { hostname: string; repoRoot: string }) => { + request: (request: { + maxBytes?: number; + method?: string; + path: string; + query?: Record; + body?: unknown; + }) => Promise; + requestPages: (request: { + maxBytes?: number; + path: string; + query?: Record; + }) => Promise>; + requestBuffer: (request: { + maxBytes?: number; + path: string; + query?: Record; + }) => Promise; + requestText: (request: { + maxBytes?: number; + path: string; + query?: Record; + }) => Promise; + }; +}; + +const previousGlabPath = process.env.CODIFF_GLAB_PATH; +const previousCallsPath = process.env.CODIFF_GLAB_TEST_CALLS; + +afterEach(() => { + if (previousGlabPath == null) { + delete process.env.CODIFF_GLAB_PATH; + } else { + process.env.CODIFF_GLAB_PATH = previousGlabPath; + } + if (previousCallsPath == null) { + delete process.env.CODIFF_GLAB_TEST_CALLS; + } else { + process.env.CODIFF_GLAB_TEST_CALLS = previousCallsPath; + } +}); + +test('createGlabGitLabTransport performs glab api requests through the injected executable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const callsPath = process.env.CODIFF_GLAB_TEST_CALLS; +const args = process.argv.slice(2); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + fs.appendFileSync(callsPath, JSON.stringify({ args, input }) + '\\n'); + if (args.includes('/projects/group%2Fproject/merge_requests/7/versions')) { + process.stdout.write(JSON.stringify([ + { + id: 1, + head_commit_sha: 'b'.repeat(40), + base_commit_sha: 'a'.repeat(40), + start_commit_sha: 'a'.repeat(40), + created_at: '2026-01-01T00:00:00.000Z', + }, + ])); + } else { + process.stdout.write('{}'); + } + process.exit(0); +}); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const versions = await transport.request>({ + path: '/api/v4/projects/group%2Fproject/merge_requests/7/versions', + }); + expect(versions).toEqual([expect.objectContaining({ id: 1 })]); + const calls = JSON.parse((await readFile(callsPath, 'utf8')).trim()) as { args: Array }; + expect(calls.args).toContain('/projects/group%2Fproject/merge_requests/7/versions'); + expect(calls.args).not.toContain('/api/v4/projects/group%2Fproject/merge_requests/7/versions'); +}); + +test('createGlabGitLabTransport drains oversized JSON, text, binary, and paginated responses', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const resource = process.argv.at(-1); +const chunk = Buffer.alloc(16 * 1024, 120); +let remaining = 16; +const write = () => { + while (remaining > 0) { + remaining -= 1; + if (!process.stdout.write(chunk)) { + process.stdout.once('drain', write); + return; + } + } + fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, resource + '\\n'); +}; +write(); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const results = await Promise.allSettled([ + transport.request({ maxBytes: 8, path: '/projects/group%2Fproject/merge_requests/1' }), + transport.requestText({ + maxBytes: 8, + path: '/projects/group%2Fproject/repository/files/readme/raw', + }), + transport.requestBuffer({ + maxBytes: 8, + path: '/projects/group%2Fproject/repository/blobs/deadbeef/raw', + }), + transport.requestPages({ + maxBytes: 8, + path: '/projects/group%2Fproject/merge_requests/1/discussions', + }), + ]); + + expect(results).toHaveLength(4); + for (const result of results) { + expect(result).toMatchObject({ + reason: expect.objectContaining({ + message: 'glab api response exceeded the 8-byte safety limit.', + name: 'ProviderOutputLimitError', + }), + status: 'rejected', + }); + } + expect((await readFile(callsPath, 'utf8')).trim().split('\n')).toHaveLength(4); +}); + +test('createGlabGitLabTransport bounds and drains responses without an explicit limit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const chunk = Buffer.alloc(1024 * 1024, 120); +let remaining = 9; +const write = () => { + while (remaining > 0) { + remaining -= 1; + if (!process.stdout.write(chunk)) { + process.stdout.once('drain', write); + return; + } + } + fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, 'drained\\n'); +}; +write(); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + + await expect( + transport.request({ path: '/projects/group%2Fproject/merge_requests/1' }), + ).rejects.toMatchObject({ + message: 'glab api response exceeded the 8388608-byte safety limit.', + name: 'ProviderOutputLimitError', + }); + expect((await readFile(callsPath, 'utf8')).trim()).toBe('drained'); +}); + +test('createGlabGitLabTransport enforces each response bound on one shared GET', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, 'call\\n'); +setTimeout(() => process.stdout.write('[{"id":1}]'), 25); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const first = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const second = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const path = '/projects/group%2Fproject/merge_requests/1/discussions'; + const [smallBound, largeBound] = await Promise.allSettled([ + first.requestPages({ maxBytes: 2, path }), + second.requestPages({ maxBytes: 1024, path }), + ]); + + expect(smallBound).toMatchObject({ + reason: expect.objectContaining({ name: 'ProviderOutputLimitError' }), + status: 'rejected', + }); + expect(largeBound).toEqual({ status: 'fulfilled', value: [{ id: 1 }] }); + expect((await readFile(callsPath, 'utf8')).trim()).toBe('call'); +}); + +test('createGlabGitLabTransport keeps mutation resources last', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, JSON.stringify({ args, input }) + '\\n'); + process.stdout.write('{}'); +}); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + await transport.request({ + body: { body: 'Looks good.' }, + method: 'POST', + path: '/api/v4/projects/group%2Fproject/merge_requests/7/notes', + }); + + const call = JSON.parse((await readFile(callsPath, 'utf8')).trim()) as { + args: Array; + input: string; + }; + expect(call.args.at(-1)).toBe('/projects/group%2Fproject/merge_requests/7/notes'); + expect(call.args).toContain('Content-Type: application/json'); + expect(JSON.parse(call.input)).toEqual({ body: 'Looks good.' }); +}); + +test('createGlabGitLabTransport preserves exact binary response bytes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, JSON.stringify({ args }) + '\\n'); +process.stdout.write(Buffer.from([0, 255, 10, 128])); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const bytes = await transport.requestBuffer({ + path: '/api/v4/projects/group%2Fproject/repository/blobs/deadbeef/raw', + }); + + expect([...bytes]).toEqual([0, 255, 10, 128]); + const calls = JSON.parse((await readFile(callsPath, 'utf8')).trim()) as { + args: Array; + }; + expect(calls.args).toContain('/projects/group%2Fproject/repository/blobs/deadbeef/raw'); +}); + +test('createGlabGitLabTransport flattens all glab paginated response pages', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, JSON.stringify({ args }) + '\\n'); +process.stdout.write('[\\n {"id": 1, "label": "nested } and \\\\"quoted\\\\" text"}\\n][{"id": 2}]'); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const secondTransport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const pages = await Promise.all([ + transport.requestPages({ path: '/api/v4/projects/group%2Fproject/issues' }), + secondTransport.requestPages({ path: '/api/v4/projects/group%2Fproject/issues' }), + ]); + expect(pages).toEqual([ + [{ id: 1, label: 'nested } and "quoted" text' }, { id: 2 }], + [{ id: 1, label: 'nested } and "quoted" text' }, { id: 2 }], + ]); + const calls = (await readFile(callsPath, 'utf8')).trim().split('\n'); + expect(calls).toHaveLength(1); + expect((JSON.parse(calls[0]) as { args: Array }).args).toContain('--paginate'); +}); diff --git a/electron/__tests__/provider-review-state.test.ts b/electron/__tests__/provider-review-state.test.ts new file mode 100644 index 00000000..beabc20f --- /dev/null +++ b/electron/__tests__/provider-review-state.test.ts @@ -0,0 +1,420 @@ +import { execFileSync } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; + +const { readPullRequestSectionsContent, readPullRequestState } = + require('../git-state/pull-request.cjs') as { + readPullRequestState: ( + repoRoot: string, + source: { type: 'pull-request'; url: string }, + ) => Promise; + readPullRequestSectionsContent: ( + repoRoot: string, + source: Extract, + ) => Promise; + }; +const { rangeArtifactToPullRequestFiles } = require('../git-state/review-range-sections.cjs') as { + rangeArtifactToPullRequestFiles: ( + artifact: import('../../core/index.ts').RangeArtifact, + number: number, + ) => ReadonlyArray; +}; + +test('renders returned files and a warning when overall Range Artifact coverage is truncated', () => { + const baseSha = 'a'.repeat(40); + const headSha = 'b'.repeat(40); + const files = rangeArtifactToPullRequestFiles( + { + baseSha, + coverage: 'truncated', + files: [ + { + coverage: 'complete', + lineCount: { additions: 1, deletions: 1 }, + patch: '@@ -1 +1 @@\n-old\n+new', + path: 'src/app.ts', + status: 'modified', + }, + ], + headSha, + incompleteReason: 'GitLab returned only the first changed file.', + provenance: { + kind: 'gitlab-api', + project: { host: 'gitlab.example.com', project: 'group/project', provider: 'gitlab' }, + }, + }, + 7, + ); + + expect(files[0]?.sections[0]).toMatchObject({ + lineCount: { additions: 1, deletions: 1 }, + loadState: 'ready', + patch: expect.stringContaining('+new'), + summary: { + canLoad: true, + reason: 'Showing the provider patch for this file.', + }, + }); + expect(files[0]?.sections[0]?.summary?.reason).not.toContain('Artifact coverage'); + expect(files).toHaveLength(2); + expect(files[1]).toMatchObject({ path: 'Review diff incomplete', status: 'modified' }); + expect(files[1]?.sections[0]).toMatchObject({ + id: 'review-range-incomplete:7', + loadState: 'error', + summary: { + canLoad: false, + reason: 'GitLab returned only the first changed file.', + }, + }); + expect(files[1]?.sections[0]).not.toHaveProperty('range'); +}); + +test('keeps coverage warning identity distinct from real repository paths', () => { + const files = rangeArtifactToPullRequestFiles( + { + baseSha: 'a'.repeat(40), + coverage: 'truncated', + files: [ + { + coverage: 'complete', + patch: '@@ -1 +1 @@\n-old\n+new', + path: 'Review diff incomplete', + status: 'modified', + }, + { + coverage: 'complete', + patch: '@@ -1 +1 @@\n-old\n+new', + path: 'Review diff incomplete (2)', + status: 'modified', + }, + ], + headSha: 'b'.repeat(40), + incompleteReason: 'The provider response was truncated.', + provenance: { + kind: 'gitlab-api', + project: { host: 'gitlab.example.com', project: 'group/project', provider: 'gitlab' }, + }, + }, + 7, + ); + + expect(files.map((file) => file.path)).toEqual([ + 'Review diff incomplete', + 'Review diff incomplete (2)', + 'Review diff incomplete (3)', + ]); +}); + +test('keeps a complete empty Range Artifact empty', () => { + const files = rangeArtifactToPullRequestFiles( + { + baseSha: 'a'.repeat(40), + coverage: 'complete', + files: [], + headSha: 'b'.repeat(40), + provenance: { + kind: 'gitlab-api', + project: { host: 'gitlab.example.com', project: 'group/project', provider: 'gitlab' }, + }, + }, + 7, + ); + + expect(files).toEqual([]); +}); + +test('renders a visible unavailable item for a wholly truncated Range Artifact', () => { + const files = rangeArtifactToPullRequestFiles( + { + baseSha: 'a'.repeat(40), + coverage: 'truncated', + files: [], + headSha: 'b'.repeat(40), + incompleteReason: 'GitLab merge request diffs exceeded the 8.0 MiB limit.', + provenance: { + kind: 'gitlab-api', + project: { host: 'gitlab.example.com', project: 'group/project', provider: 'gitlab' }, + }, + }, + 7, + ); + + expect(files).toEqual([ + expect.objectContaining({ path: 'Review diff unavailable', status: 'modified' }), + ]); + expect(files[0]?.sections[0]).toMatchObject({ + loadState: 'error', + summary: { + canLoad: false, + reason: 'GitLab merge request diffs exceeded the 8.0 MiB limit.', + }, + }); + expect(files[0]?.sections[0]).not.toHaveProperty('range'); +}); + +const { createGitLabPosition, readMergeRequestSectionsContent, readMergeRequestState } = + require('../git-state/merge-request.cjs') as { + createGitLabPosition: (comment: unknown, metadata: unknown, diff?: unknown) => unknown; + readMergeRequestState: ( + repoRoot: string, + source: { provider: 'gitlab'; type: 'pull-request'; url: string }, + ) => Promise; + readMergeRequestSectionsContent: ( + repoRoot: string, + source: Extract, + ) => Promise; + }; + +const git = (directory: string, args: ReadonlyArray) => + execFileSync('git', ['-C', directory, ...args], { encoding: 'utf8' }).trim(); + +const createRepository = async (remote: string) => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-provider-state-')); + execFileSync('git', ['init', '--quiet', directory]); + git(directory, ['config', 'user.email', 'codiff@example.com']); + git(directory, ['config', 'user.name', 'Codiff Test']); + git(directory, ['remote', 'add', 'origin', remote]); + return directory; +}; + +const createReviewRange = async ( + directory: string, + refs: { base: string; head: string }, + paths: ReadonlyArray = ['src/app.ts'], +) => { + await mkdir(join(directory, 'src'), { recursive: true }); + await Promise.all(paths.map((path) => writeFile(join(directory, path), `old ${path}\n`, 'utf8'))); + git(directory, ['add', '.']); + git(directory, ['commit', '--quiet', '-m', 'Create review base']); + const baseSha = git(directory, ['rev-parse', 'HEAD']); + await Promise.all(paths.map((path) => writeFile(join(directory, path), `new ${path}\n`, 'utf8'))); + git(directory, ['add', '.']); + git(directory, ['commit', '--quiet', '-m', 'Create review head']); + const headSha = git(directory, ['rev-parse', 'HEAD']); + git(directory, ['update-ref', refs.base, baseSha]); + git(directory, ['update-ref', refs.head, headSha]); + return { baseSha, headSha }; +}; + +test('returns a GitHub Range Artifact before exact file hydration', async () => { + const directory = await createRepository('https://github.com/nkzw-tech/codiff.git'); + const fakeGh = join(directory, 'gh'); + const callLog = join(directory, 'gh-calls.jsonl'); + const gitTrace = join(directory, 'git-trace.log'); + const { baseSha, headSha } = await createReviewRange( + directory, + { + base: 'refs/codiff/pull-requests/7/base', + head: 'refs/codiff/pull-requests/7/head', + }, + ['src/app.ts', 'src/other.ts'], + ); + await writeFile( + fakeGh, + `#!/usr/bin/env node +const { appendFileSync } = require('node:fs'); +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(callLog)}, JSON.stringify(args) + '\\n'); +const resource = args.find((arg) => arg.includes('repos/')) || ''; +if (resource.includes('/compare/')) { + process.stdout.write(JSON.stringify({ + commits: [{ + commit: { author: { date: '2026-01-01T00:00:00.000Z', name: 'Ada' }, message: 'Update app' }, + parents: [{ sha: '${baseSha}' }], + sha: '${headSha}', + }], + merge_base_commit: { sha: '${baseSha}' }, + files: ['src/app.ts', 'src/other.ts'].map((path) => ({ + filename: path, + patch: '@@ -1 +1 @@\\n-old\\n+new\\n', + status: 'modified', + })), + total_commits: 1, + })); +} else { + process.stdout.write(JSON.stringify({ + base: { ref: 'main', sha: '${baseSha}' }, + head: { sha: '${headSha}' }, + title: 'Review Range Artifacts', + user: { login: 'ada' }, + })); +} +`, + 'utf8', + ); + await chmod(fakeGh, 0o755); + const previousPath = process.env.PATH; + const previousGitTrace = process.env.GIT_TRACE; + process.env.PATH = `${directory}${delimiter}${previousPath ?? ''}`; + process.env.GIT_TRACE = gitTrace; + try { + const source = { + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/7', + } as const; + const state = await readPullRequestState(directory, source); + + expect(state.files).toHaveLength(2); + expect(state.files[0]).toMatchObject({ path: 'src/app.ts', status: 'modified' }); + expect(state.files[0]?.sections[0]).toMatchObject({ + id: 'src/app.ts:pull-request:7', + kind: 'pull-request', + loadState: 'ready', + range: { base: { sha: baseSha }, head: { sha: headSha } }, + summary: { canLoad: true }, + }); + expect(state.files[0]?.sections[0]?.patch).toMatch( + /^diff --git a\/src\/app\.ts b\/src\/app\.ts/, + ); + expect(state.files[0]?.sections[0]).not.toHaveProperty('newFile'); + expect(state.files[0]?.sections[0]).not.toHaveProperty('oldFile'); + expect(state.reviewComments).toBeUndefined(); + const [hydrated, joinedHydration] = await Promise.all([ + readPullRequestSectionsContent(directory, state.source), + readPullRequestSectionsContent(directory, state.source), + ]); + expect(joinedHydration).toBe(hydrated); + expect(hydrated.headSha).toBe(headSha); + expect(hydrated.sections).toHaveLength(2); + expect(hydrated.sections.map(({ path }) => path)).toEqual(['src/app.ts', 'src/other.ts']); + expect(hydrated.sections.every(({ section }) => section.loadState === 'ready')).toBe(true); + expect(hydrated.sections[0]?.section).toMatchObject({ + newFile: { contents: 'new src/app.ts\n', name: 'src/app.ts' }, + oldFile: { contents: 'old src/app.ts\n', name: 'src/app.ts' }, + }); + const calls = (await readFile(callLog, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Array); + expect(calls).toHaveLength(2); + expect(calls.filter((args) => args.some((arg) => arg.includes('/compare/')))).toHaveLength(1); + expect(calls.flat()).not.toContainEqual(expect.stringMatching(/\/pulls\/7\/files/)); + expect(calls.flat()).not.toContainEqual( + expect.stringMatching(/comments|contents|graphql|application\/vnd\.github\.v3\.diff/), + ); + const trace = (await readFile(gitTrace, 'utf8')).split('\n'); + expect(trace.filter((line) => line.includes('built-in: git ls-tree '))).toHaveLength(2); + expect( + trace.filter((line) => line.includes('git cat-file') && line.includes('--batch-check')), + ).toHaveLength(2); + expect(trace.filter((line) => line.endsWith('git cat-file --batch'))).toHaveLength(2); + } finally { + if (previousPath == null) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousGitTrace == null) delete process.env.GIT_TRACE; + else process.env.GIT_TRACE = previousGitTrace; + } +}, 30_000); + +test('returns a GitLab Range Artifact before exact file hydration', async () => { + const directory = await createRepository('https://gitlab.example.com/group/project.git'); + const fakeGlab = join(directory, 'glab'); + const callLog = join(directory, 'glab-calls.jsonl'); + const { baseSha, headSha } = await createReviewRange( + directory, + { + base: 'refs/codiff/merge-requests/7/base', + head: 'refs/codiff/merge-requests/7/head', + }, + ['src/app.ts', 'src/other.ts'], + ); + const startSha = 'd'.repeat(40); + await writeFile( + fakeGlab, + `#!/usr/bin/env node +const { appendFileSync } = require('node:fs'); +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(callLog)}, JSON.stringify(args) + '\\n'); +const resource = args.find((arg) => arg.includes('projects/')) || ''; +if (resource.includes('/repository/compare?')) { + process.stdout.write(JSON.stringify({ + commits: [{ + authored_date: '2026-01-01T00:00:00.000Z', + author_name: 'Ada', + id: '${headSha}', + parent_ids: ['${baseSha}'], + title: 'Update app', + }], + diffs: ['src/app.ts', 'src/other.ts'].map((path) => ({ + a_mode: '100644', + b_mode: '100644', + diff: '@@ -1 +1 @@\\n-old\\n+new', + new_path: path, + old_path: path, + })), + })); +} else { + process.stdout.write(JSON.stringify({ + author: { username: 'ada' }, + diff_refs: { base_sha: '${baseSha}', head_sha: '${headSha}', start_sha: '${startSha}' }, + sha: '${headSha}', + title: 'Review Range Artifacts', + web_url: 'https://gitlab.example.com/group/project/-/merge_requests/7', + })); +} +`, + 'utf8', + ); + await chmod(fakeGlab, 0o755); + const previous = process.env.CODIFF_GLAB_PATH; + process.env.CODIFF_GLAB_PATH = fakeGlab; + try { + const source = { + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/7', + } as const; + const state = await readMergeRequestState(directory, source); + + expect(state.files).toHaveLength(2); + expect(state.files[0]).toMatchObject({ path: 'src/app.ts', status: 'modified' }); + expect(state.files[0]?.sections[0]).toMatchObject({ + id: 'src/app.ts:pull-request:7', + kind: 'pull-request', + loadState: 'ready', + range: { base: { sha: baseSha }, head: { sha: headSha } }, + summary: { canLoad: true }, + }); + expect(state.files[0]?.sections[0]?.patch).toMatch( + /^diff --git a\/src\/app\.ts b\/src\/app\.ts/, + ); + expect(state.files[0]?.sections[0]).not.toHaveProperty('newFile'); + expect(state.files[0]?.sections[0]).not.toHaveProperty('oldFile'); + expect(state.reviewComments).toBeUndefined(); + expect(state.files.every((file) => !('oldFile' in (file.sections[0] ?? {})))).toBe(true); + expect( + createGitLabPosition( + { anchor: 'file', filePath: 'src/app.ts' }, + { diff_refs: { base_sha: baseSha, head_sha: headSha, start_sha: startSha } }, + { new_path: 'src/app.ts', old_path: 'src/app.ts' }, + ), + ).toMatchObject({ base_sha: baseSha, head_sha: headSha, start_sha: startSha }); + const hydrated = await readMergeRequestSectionsContent(directory, state.source); + expect(hydrated.headSha).toBe(headSha); + expect(hydrated.sections).toHaveLength(2); + expect(hydrated.sections.every(({ section }) => section.loadState === 'ready')).toBe(true); + expect(hydrated.sections[0]?.section).toMatchObject({ + newFile: { contents: 'new src/app.ts\n', name: 'src/app.ts' }, + oldFile: { contents: 'old src/app.ts\n', name: 'src/app.ts' }, + }); + const calls = (await readFile(callLog, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Array); + expect(calls).toHaveLength(2); + const compare = calls.find((args) => args.some((arg) => arg.includes('/repository/compare'))); + expect(compare).toBeDefined(); + expect(compare?.join(' ')).toContain(`from=${baseSha}`); + expect(compare?.join(' ')).not.toContain(startSha); + expect(calls.flat()).not.toContainEqual(expect.stringMatching(/merge_requests\/7\/diffs/)); + expect(calls.flat()).not.toContainEqual( + expect.stringMatching(/discussions|repository\/files|versions/), + ); + } finally { + if (previous == null) delete process.env.CODIFF_GLAB_PATH; + else process.env.CODIFF_GLAB_PATH = previous; + } +}, 30_000); diff --git a/electron/__tests__/pull-request-command.test.ts b/electron/__tests__/pull-request-command.test.ts index e573b0e8..ca97ef7e 100644 --- a/electron/__tests__/pull-request-command.test.ts +++ b/electron/__tests__/pull-request-command.test.ts @@ -10,24 +10,26 @@ import { } from '../../core/__tests__/helpers/resources.ts'; const require = createRequire(import.meta.url); -const { GH_NOT_FOUND_CODE, getGhCommand, submitPullRequestReview } = - require('../git-state/pull-request.cjs') as { +const { GH_NOT_FOUND_CODE, getGhCommand } = + require('../git-state/github-history/gh-github-transport.cjs') as { GH_NOT_FOUND_CODE: string; getGhCommand: () => string; - submitPullRequestReview: ( - launchPath: string, - request: { - body?: string; - comments: ReadonlyArray>; - event: 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; - source: { - provider: 'github'; - type: 'pull-request'; - url: string; - }; - }, - ) => Promise; }; +const { submitPullRequestReview } = require('../git-state/pull-request.cjs') as { + submitPullRequestReview: ( + launchPath: string, + request: { + body?: string; + comments: ReadonlyArray>; + event: 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; + source: { + provider: 'github'; + type: 'pull-request'; + url: string; + }; + }, + ) => Promise; +}; const execFileAsync = promisify(execFile); @@ -147,7 +149,7 @@ test('reaches the GitHub CLI when it is not on PATH', async () => { `#!/bin/sh printf '%s | %s\\n' "$*" "$(cat)" >> "$CODIFF_GITHUB_COMMAND_TEST_CALLS" for arg in "$@"; do - if [ "$arg" = 'repos/nkzw-tech/codiff/pulls/12' ]; then + if [ "$arg" = '/repos/nkzw-tech/codiff/pulls/12' ]; then printf '%s' '{"head":{"sha":"0123456789abcdef0123456789abcdef01234567"}}' exit 0 fi @@ -177,7 +179,7 @@ printf '%s' '{}' const calls = (await readFile(callsPath, 'utf8')).trim().split('\n'); expect(calls).toEqual([ - 'api repos/nkzw-tech/codiff/pulls/12 | ', + 'api /repos/nkzw-tech/codiff/pulls/12 | ', 'api -X POST repos/nkzw-tech/codiff/pulls/12/reviews --input - | ' + '{"body":"General feedback.","comments":[],"event":"COMMENT"}', ]); diff --git a/electron/__tests__/pull-request-remote.test.ts b/electron/__tests__/pull-request-remote.test.ts index 191fa4b7..40edf4b3 100644 --- a/electron/__tests__/pull-request-remote.test.ts +++ b/electron/__tests__/pull-request-remote.test.ts @@ -149,7 +149,7 @@ test('skips a mismatching origin alias and selects the remote with the exact PR } finally { await removeGitTestDirectory(directory); } -}); +}, 15_000); test('rejects opaque remotes that do not match both the repository path and PR head', async () => { const directory = await mkdtemp(join(tmpdir(), 'codiff-pull-request-remote-')); @@ -179,4 +179,4 @@ test('rejects opaque remotes that do not match both the repository path and PR h } finally { await removeGitTestDirectory(directory); } -}); +}, 15_000); diff --git a/electron/git-state.cjs b/electron/git-state.cjs index 51e7be67..9153b9d2 100644 --- a/electron/git-state.cjs +++ b/electron/git-state.cjs @@ -22,7 +22,6 @@ const { PENDING_REVIEW_COMMENT_ERROR, collectResolvedReviewCommentIds, createPullRequestHistoryFetchRefspecs, - createPullRequestSection, createPullRequestSource, getPullRequestHeadImageSource, listPullRequestHistory, @@ -31,12 +30,16 @@ const { normalizePullRequestComment, parseGitHubPullRequestUrl, readPullRequestImageContent, + readPullRequestReviewComments, + readPullRequestSectionContent, + readPullRequestSectionsContent, readPullRequestState, resolvePullRequestContentRefs, selectUnresolvedReviewComments, submitPullRequestComment, submitPullRequestReview, } = require('./git-state/pull-request.cjs'); +const { createPullRequestSection } = require('./git-state/review-range-sections.cjs'); const { createGitLabPosition, createMergeRequestFetchRefspecs, @@ -44,6 +47,9 @@ const { normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, readMergeRequestImageContent, + readMergeRequestReviewComments, + readMergeRequestSectionContent, + readMergeRequestSectionsContent, readMergeRequestState, submitMergeRequestComment, submitMergeRequestReview, @@ -60,6 +66,7 @@ const { annotateGeneratedFiles } = require('./generated-files.cjs'); /** * @typedef {import('../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest + * @typedef {import('../core/types.ts').DiffSectionsContentRequest} DiffSectionsContentRequest * @typedef {import('../core/types.ts').DiffImageContentRequest} DiffImageContentRequest * @typedef {import('../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../core/types.ts').RepositoryHistory} RepositoryHistory @@ -105,7 +112,9 @@ const readRepositoryState = async (launchPath, source = { type: 'working-tree' } label: { kind: /** @type {const} */ ('review-marker'), text: 'Working Copy' }, }; const [branch, annotatedState] = await Promise.all([ - gitOrEmpty(state.root, ['symbolic-ref', '--short', 'HEAD']), + source.type === 'pull-request' + ? Promise.resolve('') + : gitOrEmpty(state.root, ['symbolic-ref', '--short', 'HEAD']), comparisonState ? state : annotateGeneratedFiles(state, generatedRevision), ]); return { ...annotatedState, branch: branch.trim() || null }; @@ -187,33 +196,58 @@ const readRepositoryHistory = (launchPath, limit, source) => : undefined, ); +/** @param {string} launchPath @param {Extract} source */ +const readReviewComments = (launchPath, source) => + (isGitLabReviewSource(source) ? readMergeRequestReviewComments : readPullRequestReviewComments)( + launchPath, + source, + ); + /** @param {string} launchPath @param {DiffSectionContentRequest} request */ const readDiffSectionContent = async (launchPath, request) => - request.source?.type === 'range' - ? readRangeSectionContent( - launchPath, - request.source.base, - request.source.head, - request.source.symmetric, - request.path, - { force: request.force }, - ) - : request.source?.type === 'branch' || request.source?.type === 'branch-diff' - ? readBranchSectionContent(launchPath, request.source, request.path, { - force: request.force, - }) - : request.source?.type === 'branch-working-tree' - ? readBranchWorkingTreeSectionContent(launchPath, request) - : request.kind === 'commit' || request.source?.type === 'commit' - ? readCommitSectionContent( - launchPath, - request.source?.type === 'commit' ? request.source.sha : 'HEAD', - request.path, - { - force: request.force, - }, - ) - : readWorkingTreeDiffSectionContent(launchPath, request); + request.source?.type === 'pull-request' + ? (isGitLabReviewSource(request.source) + ? readMergeRequestSectionContent + : readPullRequestSectionContent)(launchPath, request.source, request.path, { + force: request.force, + }) + : request.source?.type === 'range' + ? readRangeSectionContent( + launchPath, + request.source.base, + request.source.head, + request.source.symmetric, + request.path, + { force: request.force }, + ) + : request.source?.type === 'branch' || request.source?.type === 'branch-diff' + ? readBranchSectionContent(launchPath, request.source, request.path, { + force: request.force, + }) + : request.source?.type === 'branch-working-tree' + ? readBranchWorkingTreeSectionContent(launchPath, request) + : request.kind === 'commit' || request.source?.type === 'commit' + ? readCommitSectionContent( + launchPath, + request.source?.type === 'commit' ? request.source.sha : 'HEAD', + request.path, + { + force: request.force, + }, + ) + : readWorkingTreeDiffSectionContent(launchPath, request); + +/** @param {string} launchPath @param {DiffSectionsContentRequest} request */ +const readDiffSectionsContent = (launchPath, request) => { + if (request.source?.type !== 'pull-request') { + throw new Error('Bulk diff hydration requires a pull-request source.'); + } + return ( + isGitLabReviewSource(request.source) + ? readMergeRequestSectionsContent + : readPullRequestSectionsContent + )(launchPath, request.source); +}; /** @param {string} launchPath @param {DiffImageContentRequest} request @returns {Promise} */ const readDiffImageContent = (launchPath, request) => @@ -261,9 +295,11 @@ module.exports = { selectUnresolvedReviewComments, readBranchState, readDiffSectionContent, + readDiffSectionsContent, readDiffImageContent, readGitIdentity, readRepositoryChangeSignature, + readReviewComments, readCommitState, readPullRequestState, readRepositoryState, diff --git a/electron/git-state/github-history/gh-github-transport.cjs b/electron/git-state/github-history/gh-github-transport.cjs new file mode 100644 index 00000000..03ea7af1 --- /dev/null +++ b/electron/git-state/github-history/gh-github-transport.cjs @@ -0,0 +1,490 @@ +// @ts-check + +/** + * gh-backed GitHubTransport for local Codiff. + * Keeps executable discovery, process spawning, and credentials in Electron. + */ + +const { spawn } = require('node:child_process'); +const { realpathSync } = require('node:fs'); +const { homedir } = require('node:os'); +const { join } = require('node:path'); +const { findExecutableOnPath, isExecutableFile } = require('../../agent-shared.cjs'); +const { getCommandEnvironment } = require('../../login-shell-environment.cjs'); + +const GH_NOT_FOUND_CODE = 'GH_NOT_FOUND'; +const GH_NOT_FOUND_MESSAGE = + 'GitHub support requires gh. Install gh, authenticate it, and verify `gh --version` works in Terminal. Codiff searches PATH, ~/.local/bin/gh, /opt/homebrew/bin/gh, and /usr/local/bin/gh. If gh is installed somewhere else, quit Codiff, then launch it with `CODIFF_GH_PATH=/absolute/path/to/gh codiff`.'; +const DEFAULT_PROVIDER_OUTPUT_BYTES = 8 * 1024 * 1024; +class ProviderOutputLimitError extends Error { + /** @param {number} maxBytes */ + constructor(maxBytes) { + super(`gh api response exceeded the ${maxBytes}-byte safety limit.`); + this.name = 'ProviderOutputLimitError'; + } +} + +/** @typedef {{errorName?: string, maxBytes?: number, outputLimitExceeded: boolean, promise: Promise, status: 'pending' | 'fulfilled' | 'rejected'}} SharedGetRequest */ + +const sharedGetRequests = new Map(); +const SHARED_GET_RETENTION_MS = 30_000; +const MAX_STDERR_BYTES = 1024 * 1024; + +/** @param {number | undefined} maxBytes */ +const normalizeMaxBytes = (maxBytes) => { + if (maxBytes == null) { + return DEFAULT_PROVIDER_OUTPUT_BYTES; + } + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new RangeError('maxBytes must be a finite non-negative number.'); + } + return Math.floor(maxBytes); +}; + +/** @param {number | undefined} current @param {number | undefined} requested */ +const mergeMaxBytes = (current, requested) => + current == null || requested == null ? undefined : Math.max(current, requested); + +/** @param {Buffer} bytes @param {number | undefined} maxBytes */ +const enforceOutputLimit = (bytes, maxBytes) => { + if (maxBytes != null && bytes.length > maxBytes) { + throw new ProviderOutputLimitError(maxBytes); + } + return bytes; +}; + +/** + * Share only uncancelable GETs, briefly retaining completed bytes so each + * consumer can enforce its own response bound. Concurrent consumers may raise + * the acquisition bound until output crosses it; a later larger consumer + * starts a new read only after an earlier bounded read discarded bytes. + * @param {string} key + * @param {number | undefined} maxBytes + * @param {(options: {getMaxBytes: () => number | undefined, onOutputLimit: () => void}) => Promise} read + * @returns {Promise} + */ +const readSharedGet = (key, maxBytes, read) => { + /** @type {SharedGetRequest | undefined} */ + const existing = sharedGetRequests.get(key); + if (existing) { + if (existing.status === 'fulfilled') { + return existing.promise; + } + const canReuseLimitFailure = + existing.maxBytes == null || (maxBytes != null && maxBytes <= existing.maxBytes); + if ( + existing.status === 'rejected' && + (existing.errorName !== 'ProviderOutputLimitError' || canReuseLimitFailure) + ) { + return existing.promise; + } + if (existing.status === 'pending' && (!existing.outputLimitExceeded || canReuseLimitFailure)) { + if (!existing.outputLimitExceeded) { + existing.maxBytes = mergeMaxBytes(existing.maxBytes, maxBytes); + } + return existing.promise; + } + } + + /** @type {SharedGetRequest} */ + const entry = { + maxBytes, + outputLimitExceeded: false, + promise: /** @type {Promise} */ (Promise.resolve(Buffer.alloc(0))), + status: 'pending', + }; + const request = Promise.resolve().then(() => + read({ + getMaxBytes: () => entry.maxBytes, + onOutputLimit: () => { + entry.outputLimitExceeded = true; + }, + }), + ); + entry.promise = request; + sharedGetRequests.set(key, entry); + const expire = () => { + const timeout = setTimeout(() => { + if (sharedGetRequests.get(key) === entry) { + sharedGetRequests.delete(key); + } + }, SHARED_GET_RETENTION_MS); + timeout.unref?.(); + }; + request.then( + () => { + entry.status = 'fulfilled'; + expire(); + }, + (error) => { + entry.errorName = error instanceof Error ? error.name : undefined; + entry.status = 'rejected'; + expire(); + }, + ); + return request; +}; + +/** @param {string} [detail] */ +const createGhNotFoundError = (detail) => { + const error = /** @type {Error & { code?: string }} */ ( + new Error(detail ? `${GH_NOT_FOUND_MESSAGE} ${detail}` : GH_NOT_FOUND_MESSAGE) + ); + error.code = GH_NOT_FOUND_CODE; + return error; +}; + +const getGhCommand = () => { + const ghPath = process.env.CODIFF_GH_PATH?.trim(); + if (ghPath) { + if (isExecutableFile(ghPath)) { + return ghPath; + } + throw createGhNotFoundError( + `CODIFF_GH_PATH is set to ${JSON.stringify(ghPath)}, but that file is not executable.`, + ); + } + + const pathCommand = findExecutableOnPath('gh'); + if (pathCommand) { + return pathCommand; + } + + for (const path of [ + join(homedir(), '.local/bin/gh'), + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + ]) { + if (isExecutableFile(path)) { + return path; + } + } + + throw createGhNotFoundError(); +}; + +/** + * @param {string} repoRoot + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @param {{getMaxBytes?: () => number | undefined, maxBytes?: number, onOutputLimit?: () => void, signal?: AbortSignal}} [options] + * @returns {Promise} + */ +const runGhApiBuffer = async (repoRoot, args, input, options = {}) => { + const environment = await getCommandEnvironment(); + return new Promise((resolve, reject) => { + const fixedMaxBytes = normalizeMaxBytes(options.maxBytes); + let command; + try { + command = getGhCommand(); + } catch (error) { + reject(error); + return; + } + + const child = spawn(command, ['api', ...args], { + cwd: repoRoot, + env: environment, + signal: options.signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** @type {Array} */ + const stdout = []; + /** @type {Array} */ + const stderr = []; + let stderrBytes = 0; + /** @type {ReturnType | undefined} */ + let forceKillTimeout; + let outputBytes = 0; + let outputLimit; + const terminate = () => { + child.kill('SIGTERM'); + forceKillTimeout ??= setTimeout(() => child.kill('SIGKILL'), 1_000); + forceKillTimeout.unref?.(); + }; + child.stdout.on('data', (chunk) => { + outputBytes += chunk.length; + if (outputLimit != null) { + return; + } + const maxBytes = options.getMaxBytes?.() ?? fixedMaxBytes; + if (maxBytes != null && outputBytes > maxBytes) { + outputLimit = maxBytes; + stdout.length = 0; + options.onOutputLimit?.(); + terminate(); + return; + } + stdout.push(chunk); + }); + child.stderr.on('data', (chunk) => { + if (stderrBytes >= MAX_STDERR_BYTES) return; + const retained = chunk.subarray(0, MAX_STDERR_BYTES - stderrBytes); + stderr.push(Buffer.from(retained)); + stderrBytes += retained.length; + }); + child.on('error', (error) => { + if (outputLimit != null) { + reject(new ProviderOutputLimitError(outputLimit)); + return; + } + reject( + /** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' + ? createGhNotFoundError() + : error, + ); + }); + child.on('close', (code) => { + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + } + if (outputLimit != null) { + reject(new ProviderOutputLimitError(outputLimit)); + return; + } + if (code === 0) { + resolve(Buffer.concat(stdout, outputBytes)); + } else { + const error = new Error( + Buffer.concat(stderr).toString('utf8').trim() || `gh api exited with code ${code}.`, + ); + reject(error); + } + }); + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + }); +}; + +/** + * @param {string} repoRoot + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @param {{buffer?: boolean, maxBytes?: number, signal?: AbortSignal}} [options] + * @returns {Promise} + */ +const runGhApi = async (repoRoot, args, input, options = {}) => { + const bytes = await runGhApiBuffer(repoRoot, args, input, options); + return options.buffer ? bytes : bytes.toString('utf8'); +}; + +/** + * gh writes paginated JSON documents consecutively. Parse complete arrays or + * objects without assuming a newline separator. + * @param {string} text + */ +const parseJsonDocuments = (text) => { + /** @type {Array} */ + const documents = []; + let depth = 0; + let escape = false; + let inString = false; + let start = -1; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (start === -1) { + if (/\s/.test(character)) continue; + if (character !== '[' && character !== '{') { + throw new SyntaxError(`Unexpected character in gh JSON output at position ${index}.`); + } + start = index; + depth = 1; + continue; + } + if (inString) { + if (escape) escape = false; + else if (character === '\\') escape = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') inString = true; + else if (character === '[' || character === '{') depth += 1; + else if (character === ']' || character === '}') { + depth -= 1; + if (depth === 0) { + documents.push(JSON.parse(text.slice(start, index + 1))); + start = -1; + } + } + } + if (start !== -1 || inString) { + throw new SyntaxError('Incomplete JSON document in gh output.'); + } + return documents; +}; + +/** + * @param {string} value + * @param {Readonly> | undefined} query + */ +const appendQuery = (value, query) => { + let path = value.startsWith('/') ? value : `/${value}`; + if (!query) { + return path; + } + const params = new URLSearchParams(); + for (const [key, parameter] of Object.entries(query)) { + params.set(key, String(parameter)); + } + const suffix = params.toString(); + if (suffix) { + path = `${path}${path.includes('?') ? '&' : '?'}${suffix}`; + } + return path; +}; + +/** @param {Readonly> | undefined} query */ +const withoutPageSize = (query) => + query && Object.fromEntries(Object.entries(query).filter(([key]) => key !== 'per_page')); + +/** + * @param {{ repoRoot: string }} options + */ +const createGhGitHubTransport = ({ repoRoot }) => { + const repositoryIdentity = realpathSync.native(repoRoot); + + /** + * @param {ReadonlyArray} args + * @param {unknown} input + * @param {{maxBytes?: number, sharedKey?: string, signal?: AbortSignal}} options + */ + const readApiBuffer = async (args, input, options) => { + const maxBytes = normalizeMaxBytes(options.maxBytes); + const bytes = options.sharedKey + ? await readSharedGet(options.sharedKey, maxBytes, ({ getMaxBytes, onOutputLimit }) => + runGhApiBuffer(repoRoot, args, input, { + getMaxBytes, + onOutputLimit, + signal: options.signal, + }), + ) + : await runGhApiBuffer(repoRoot, args, input, { + maxBytes, + signal: options.signal, + }); + return enforceOutputLimit(bytes, maxBytes); + }; + + /** + * @param {{maxBytes?: number, query: string, signal?: AbortSignal, variables: Readonly>}} request + */ + const graphql = async (request) => { + /** @type {Array} */ + const args = ['graphql', '-f', `query=${request.query}`]; + for (const [key, value] of Object.entries(request.variables)) { + if (value != null) { + args.push('-F', `${key}=${String(value)}`); + } + } + return JSON.parse( + ( + await readApiBuffer(args, undefined, { + maxBytes: request.maxBytes, + signal: request.signal, + }) + ).toString('utf8'), + ); + }; + + /** + * @param {{ + * maxBytes?: number, + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + * path: string, + * query?: Readonly>, + * accept?: string, + * body?: unknown, + * paginate?: boolean, + * signal?: AbortSignal, + * }} request + */ + const requestText = async (request) => { + /** @type {Array} */ + const args = []; + if (request.paginate) { + args.push('--paginate'); + } + if (request.method && request.method !== 'GET') { + args.push('--method', request.method); + } + if (request.accept) { + args.push('-H', `Accept: ${request.accept}`); + } + args.push(appendQuery(request.path, request.query)); + if (request.body != null) { + args.push('--input', '-'); + } + const sharedKey = + request.body == null && (!request.method || request.method === 'GET') && !request.signal + ? `${repositoryIdentity}\0text\0${request.paginate ? 'paginate' : 'single'}\0${request.accept || ''}\0${appendQuery(request.path, request.paginate ? withoutPageSize(request.query) : request.query)}` + : undefined; + return ( + await readApiBuffer(args, request.body, { + maxBytes: request.maxBytes, + sharedKey, + signal: request.signal, + }) + ).toString('utf8'); + }; + + return { + graphql, + /** + * @template T + * @param {{ + * maxBytes?: number, + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + * path: string, + * query?: Readonly>, + * body?: unknown, + * paginate?: boolean, + * signal?: AbortSignal, + * }} request + * @returns {Promise} + */ + async request(request) { + const text = await requestText(request); + if (!text.trim()) { + return /** @type {T} */ (null); + } + if (request.paginate) { + const documents = parseJsonDocuments(text); + return /** @type {T} */ ( + documents.flatMap((document) => (Array.isArray(document) ? document : [document])) + ); + } + return /** @type {T} */ (JSON.parse(text)); + }, + async requestBuffer(request) { + /** @type {Array} */ + const args = []; + if (request.accept) { + args.push('-H', `Accept: ${request.accept}`); + } + args.push(appendQuery(request.path, request.query)); + return readApiBuffer(args, undefined, { + maxBytes: request.maxBytes, + sharedKey: !request.signal + ? `${repositoryIdentity}\0buffer\0single\0${request.accept || ''}\0${appendQuery(request.path, request.query)}` + : undefined, + signal: request.signal, + }); + }, + requestText, + }; +}; + +module.exports = { + GH_NOT_FOUND_CODE, + ProviderOutputLimitError, + appendQuery, + createGhGitHubTransport, + createGhNotFoundError, + getGhCommand, + parseJsonDocuments, + runGhApi, + runGhApiBuffer, +}; diff --git a/electron/git-state/glab-gitlab-transport.cjs b/electron/git-state/glab-gitlab-transport.cjs new file mode 100644 index 00000000..b337e1b6 --- /dev/null +++ b/electron/git-state/glab-gitlab-transport.cjs @@ -0,0 +1,453 @@ +// @ts-check + +/** + * glab-backed GitLabTransport for local Codiff. + * Keeps executable discovery, process spawning, and credentials in Electron. + */ + +const { spawn } = require('node:child_process'); +const { homedir } = require('node:os'); +const { join } = require('node:path'); +const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); +const { getCommandEnvironment } = require('../login-shell-environment.cjs'); + +const DEFAULT_PROVIDER_OUTPUT_BYTES = 8 * 1024 * 1024; +const GLAB_NOT_FOUND_CODE = 'GLAB_NOT_FOUND'; +const GLAB_NOT_FOUND_MESSAGE = + 'GitLab support requires glab. Install glab, authenticate it, and verify `glab --version` works in Terminal. Codiff searches PATH, ~/.local/bin/glab, /opt/homebrew/bin/glab, and /usr/local/bin/glab. If glab is installed somewhere else, launch Codiff with `CODIFF_GLAB_PATH=/absolute/path/to/glab codiff -w`.'; +class ProviderOutputLimitError extends Error { + /** @param {number} maxBytes */ + constructor(maxBytes) { + super(`glab api response exceeded the ${maxBytes}-byte safety limit.`); + this.name = 'ProviderOutputLimitError'; + } +} + +/** @typedef {{errorName?: string, maxBytes?: number, outputLimitExceeded: boolean, promise: Promise, status: 'pending' | 'fulfilled' | 'rejected'}} SharedGetRequest */ + +const sharedGetRequests = new Map(); + +/** @param {number | undefined} maxBytes */ +const normalizeMaxBytes = (maxBytes) => { + if (maxBytes == null) { + return DEFAULT_PROVIDER_OUTPUT_BYTES; + } + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new RangeError('maxBytes must be a finite non-negative number.'); + } + return Math.floor(maxBytes); +}; + +/** @param {number | undefined} current @param {number | undefined} requested */ +const mergeMaxBytes = (current, requested) => + current == null || requested == null ? undefined : Math.max(current, requested); + +/** @param {Buffer} bytes @param {number | undefined} maxBytes */ +const enforceOutputLimit = (bytes, maxBytes) => { + if (maxBytes != null && bytes.length > maxBytes) { + throw new ProviderOutputLimitError(maxBytes); + } + return bytes; +}; + +/** + * Share only uncancelable GETs, briefly retaining completed bytes so the + * initial loader and history enrichment can apply their own response bounds + * to one provider read. Concurrent consumers can raise the acquisition bound + * until output crosses it; a later consumer with a larger bound starts a new + * read only when an earlier bounded read has already discarded bytes. + * @param {string} key + * @param {number | undefined} maxBytes + * @param {(options: {getMaxBytes: () => number | undefined, onOutputLimit: () => void}) => Promise} read + * @returns {Promise} + */ +const readSharedGet = (key, maxBytes, read) => { + /** @type {SharedGetRequest | undefined} */ + const existing = sharedGetRequests.get(key); + if (existing) { + if (existing.status === 'fulfilled') { + return existing.promise; + } + const canReuseLimitFailure = + existing.maxBytes == null || (maxBytes != null && maxBytes <= existing.maxBytes); + if ( + existing.status === 'rejected' && + (existing.errorName !== 'ProviderOutputLimitError' || canReuseLimitFailure) + ) { + return existing.promise; + } + if (existing.status === 'pending' && (!existing.outputLimitExceeded || canReuseLimitFailure)) { + if (!existing.outputLimitExceeded) { + existing.maxBytes = mergeMaxBytes(existing.maxBytes, maxBytes); + } + return existing.promise; + } + } + + /** @type {SharedGetRequest} */ + const entry = { + maxBytes, + outputLimitExceeded: false, + promise: /** @type {Promise} */ (Promise.resolve(Buffer.alloc(0))), + status: 'pending', + }; + const request = Promise.resolve().then(() => + read({ + getMaxBytes: () => entry.maxBytes, + onOutputLimit: () => { + entry.outputLimitExceeded = true; + }, + }), + ); + entry.promise = request; + sharedGetRequests.set(key, entry); + const expire = () => { + const timeout = setTimeout(() => { + if (sharedGetRequests.get(key) === entry) { + sharedGetRequests.delete(key); + } + }, 1000); + timeout.unref?.(); + }; + request.then( + () => { + entry.status = 'fulfilled'; + expire(); + }, + (error) => { + entry.errorName = error instanceof Error ? error.name : undefined; + entry.status = 'rejected'; + expire(); + }, + ); + return request; +}; + +/** @param {string} [detail] */ +const createGlabNotFoundError = (detail) => { + const error = /** @type {Error & { code?: string }} */ ( + new Error(detail ? `${GLAB_NOT_FOUND_MESSAGE} ${detail}` : GLAB_NOT_FOUND_MESSAGE) + ); + error.code = GLAB_NOT_FOUND_CODE; + return error; +}; + +const getGlabCommand = () => { + const glabPath = process.env.CODIFF_GLAB_PATH?.trim(); + if (glabPath) { + if (isExecutableFile(glabPath)) { + return glabPath; + } + throw createGlabNotFoundError( + `CODIFF_GLAB_PATH is set to ${JSON.stringify(glabPath)}, but that file is not executable.`, + ); + } + + const pathCommand = findExecutableOnPath('glab'); + if (pathCommand) { + return pathCommand; + } + + for (const path of [ + join(homedir(), '.local/bin/glab'), + '/opt/homebrew/bin/glab', + '/usr/local/bin/glab', + ]) { + if (isExecutableFile(path)) { + return path; + } + } + + throw createGlabNotFoundError(); +}; + +/** + * @param {string} hostname + * @param {string} path + * @param {Readonly> | undefined} query + * @param {string | undefined} method + * @param {unknown} body + */ +const createGlabApiArgs = (hostname, path, query, method, body) => { + const url = new URL(path, 'https://gitlab.local'); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, String(value)); + } + } + const apiPath = url.pathname.replace(/^\/api\/v4(?=\/|$)/, ''); + const resource = `${apiPath || '/'}${url.search}`; + /** @type {Array} */ + const args = ['api', '--hostname', hostname]; + if (method && method !== 'GET') { + args.push('--method', method); + } + if (body != null) { + args.push('--header', 'Content-Type: application/json', '--input', '-'); + } + args.push(resource); + return args; +}; + +/** + * @param {string} repoRoot + * @param {string} hostname + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @param {{getMaxBytes?: () => number | undefined, maxBytes?: number, onOutputLimit?: () => void, signal?: AbortSignal}} [options] + * @returns {Promise} + */ +const runGlabApiBuffer = async (repoRoot, hostname, args, input, options = {}) => { + const environment = await getCommandEnvironment(); + return new Promise((resolve, reject) => { + const fixedMaxBytes = normalizeMaxBytes(options.maxBytes); + let command; + try { + command = getGlabCommand(); + } catch (error) { + reject(error); + return; + } + + const child = spawn(command, args, { + cwd: repoRoot, + env: environment, + signal: options.signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + let outputBytes = 0; + let outputLimit; + child.stdout.on('data', (chunk) => { + outputBytes += chunk.length; + if (outputLimit != null) { + return; + } + const maxBytes = options.getMaxBytes?.() ?? fixedMaxBytes; + if (maxBytes != null && outputBytes > maxBytes) { + outputLimit = maxBytes; + stdout.length = 0; + options.onOutputLimit?.(); + return; + } + stdout.push(chunk); + }); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.on('error', (error) => { + reject( + /** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' + ? createGlabNotFoundError() + : error, + ); + }); + child.on('close', (code, signal) => { + if (code === 0) { + if (outputLimit != null) { + reject(new ProviderOutputLimitError(outputLimit)); + } else { + resolve(Buffer.concat(stdout, outputBytes)); + } + } else { + const error = new Error( + Buffer.concat(stderr).toString('utf8').trim() || `glab api exited with code ${code}.`, + ); + reject(error); + } + }); + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + }); +}; + +/** + * @param {string} repoRoot + * @param {string} hostname + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @param {{maxBytes?: number, signal?: AbortSignal}} [options] + */ +const runGlabApi = async (repoRoot, hostname, args, input, options = {}) => + (await runGlabApiBuffer(repoRoot, hostname, args, input, options)).toString('utf8'); + +/** + * @param {{ hostname: string, repoRoot: string, signal?: AbortSignal }} options + */ +const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal }) => { + /** + * @param {ReadonlyArray} args + * @param {unknown} input + * @param {{maxBytes?: number, sharedKey?: string, signal?: AbortSignal}} options + */ + const readApiBuffer = async (args, input, options) => { + const maxBytes = normalizeMaxBytes(options.maxBytes); + const bytes = options.sharedKey + ? await readSharedGet(options.sharedKey, maxBytes, ({ getMaxBytes, onOutputLimit }) => + runGlabApiBuffer(repoRoot, hostname, args, input, { + getMaxBytes, + onOutputLimit, + signal: options.signal, + }), + ) + : await runGlabApiBuffer(repoRoot, hostname, args, input, { + maxBytes, + signal: options.signal, + }); + return enforceOutputLimit(bytes, maxBytes); + }; + + /** + * @param {{ + * maxBytes?: number, + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + * path: string, + * query?: Readonly>, + * body?: unknown, + * signal?: AbortSignal, + * }} request + */ + const requestText = async (request) => { + const args = createGlabApiArgs( + hostname, + request.path, + request.query, + request.method, + request.body, + ); + const signal = request.signal || defaultSignal; + const sharedKey = + request.body == null && (!request.method || request.method === 'GET') && !signal + ? `${repoRoot}\0${hostname}\0text\0${args.join('\0')}` + : undefined; + return ( + await readApiBuffer(args, request.body, { + maxBytes: request.maxBytes, + sharedKey, + signal, + }) + ).toString('utf8'); + }; + + /** + * glab writes each JSON page consecutively when `--paginate` is used. Parse + * the stream as JSON values instead of assuming a particular separator. + * @param {string} text + * @returns {Array} + */ + const parseJsonPages = (text) => { + /** @type {Array} */ + const pages = []; + let depth = 0; + let escape = false; + let inString = false; + let start = -1; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (start === -1) { + if (/\s/.test(character)) { + continue; + } + if (character !== '[' && character !== '{') { + throw new SyntaxError(`Unexpected character in glab JSON output at position ${index}.`); + } + start = index; + depth = 1; + continue; + } + + if (inString) { + if (escape) { + escape = false; + } else if (character === '\\') { + escape = true; + } else if (character === '"') { + inString = false; + } + continue; + } + + if (character === '"') { + inString = true; + } else if (character === '[' || character === '{') { + depth += 1; + } else if (character === ']' || character === '}') { + depth -= 1; + if (depth === 0) { + pages.push(JSON.parse(text.slice(start, index + 1))); + start = -1; + } + } + } + + if (start !== -1 || inString) { + throw new SyntaxError('Incomplete JSON document in glab output.'); + } + return pages; + }; + + return { + /** + * @template T + * @param {{ + * maxBytes?: number, + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + * path: string, + * query?: Readonly>, + * body?: unknown, + * signal?: AbortSignal, + * }} request + * @returns {Promise} + */ + async request(request) { + const text = await requestText(request); + if (!text.trim()) { + return /** @type {T} */ (null); + } + return /** @type {T} */ (JSON.parse(text)); + }, + async requestBuffer(request) { + const args = createGlabApiArgs(hostname, request.path, request.query, undefined, undefined); + const signal = request.signal || defaultSignal; + return readApiBuffer(args, undefined, { + maxBytes: request.maxBytes, + sharedKey: !signal ? `${repoRoot}\0${hostname}\0buffer\0${args.join('\0')}` : undefined, + signal, + }); + }, + async requestPages(request) { + const args = createGlabApiArgs(hostname, request.path, request.query, undefined, undefined); + args.splice(-1, 0, '--paginate'); + const signal = request.signal || defaultSignal; + const pages = parseJsonPages( + ( + await readApiBuffer(args, undefined, { + maxBytes: request.maxBytes, + sharedKey: !signal ? `${repoRoot}\0${hostname}\0${args.join('\0')}` : undefined, + signal, + }) + ).toString('utf8'), + ); + return pages.flatMap((page) => { + if (!Array.isArray(page)) { + throw new Error('glab returned a non-array paginated response.'); + } + return page; + }); + }, + requestText, + }; +}; + +module.exports = { + GLAB_NOT_FOUND_CODE, + ProviderOutputLimitError, + createGlabGitLabTransport, + createGlabNotFoundError, + getGlabCommand, + runGlabApi, + runGlabApiBuffer, +}; diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index 2e3345a8..a4a84aeb 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -1,74 +1,35 @@ // @ts-check const { createHash } = require('node:crypto'); -const { spawn } = require('node:child_process'); -const { homedir } = require('node:os'); -const { join } = require('node:path'); -const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); -const { getCommandEnvironment } = require('../login-shell-environment.cjs'); -const { - getFingerprint, - git, - gitOrEmpty, - readGitImageFile, - validateRepositoryPath, -} = require('./common.cjs'); +const { git, gitOrEmpty, readGitImageFile, validateRepositoryPath } = require('./common.cjs'); const { readGitFiles } = require('./git-files.cjs'); +const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); +const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); +const { normalizeGitHubCommit } = require('./pull-request.cjs'); const { - createPatchFromPullRequestFile, + canHydrateArtifactFile, createPullRequestSection, - normalizeGitHubCommit, -} = require('./pull-request.cjs'); + isBinaryDiffPatch, + rangeArtifactToPullRequestFiles, +} = require('./review-range-sections.cjs'); const { parseReviewUrl, readReviewRemotes } = require('../review-source.cjs'); /** - * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource + * @typedef {import('../../core/lib/review-artifacts.ts').ArtifactFile} ArtifactFile */ -const GLAB_NOT_FOUND_CODE = 'GLAB_NOT_FOUND'; -const GLAB_NOT_FOUND_MESSAGE = - 'GitLab support requires glab. Install glab, authenticate it, and verify `glab --version` works in Terminal. Codiff searches PATH, ~/.local/bin/glab, /opt/homebrew/bin/glab, and /usr/local/bin/glab. If glab is installed somewhere else, launch Codiff with `CODIFF_GLAB_PATH=/absolute/path/to/glab codiff -w`.'; - -/** @param {string} [detail] */ -const createGlabNotFoundError = (detail) => - Object.assign( - new Error(detail ? `${GLAB_NOT_FOUND_MESSAGE} ${detail}` : GLAB_NOT_FOUND_MESSAGE), - { - code: GLAB_NOT_FOUND_CODE, - }, - ); - -const getGlabCommand = () => { - const glabPath = process.env.CODIFF_GLAB_PATH?.trim(); - if (glabPath) { - if (isExecutableFile(glabPath)) { - return glabPath; - } - - throw createGlabNotFoundError( - `CODIFF_GLAB_PATH is set to ${JSON.stringify(glabPath)}, but that file is not executable.`, - ); - } - - const pathCommand = findExecutableOnPath('glab'); - if (pathCommand) { - return pathCommand; - } - - for (const path of [ - join(homedir(), '.local/bin/glab'), - '/opt/homebrew/bin/glab', - '/usr/local/bin/glab', - ]) { - if (isExecutableFile(path)) { - return path; - } - } +/** + * Latest initial provider snapshots, keyed by local root and MR URL. + * @type {Map} + */ +const mergeRequestHydrationSnapshots = new Map(); +const MAX_MERGE_REQUEST_HYDRATION_SNAPSHOTS = 8; - throw createGlabNotFoundError(); -}; +/** @type {Map>} */ +const mergeRequestBulkHydrations = new Map(); +const MAX_MERGE_REQUEST_BULK_HYDRATIONS = 8; /** @param {string} value */ const parseGitLabMergeRequestUrl = (value) => { @@ -82,116 +43,6 @@ const parseGitLabMergeRequestUrl = (value) => { /** @param {string} projectPath */ const encodeProjectPath = (projectPath) => encodeURIComponent(projectPath); -/** - * @param {{host: string}} mergeRequest - * @param {ReadonlyArray} args - * @param {unknown} [input] - */ -const createGlabApiArgs = (mergeRequest, args, input) => [ - 'api', - '--hostname', - mergeRequest.host, - ...(input == null ? [] : ['--header', 'Content-Type: application/json']), - ...args, -]; - -/** - * @param {string} repoRoot - * @param {{host: string}} mergeRequest - * @param {ReadonlyArray} args - * @param {unknown} [input] - */ -const glabApi = async (repoRoot, mergeRequest, args, input) => { - const environment = await getCommandEnvironment(); - return new Promise((resolve, reject) => { - let command; - try { - command = getGlabCommand(); - } catch (error) { - reject(error); - return; - } - - const child = spawn(command, createGlabApiArgs(mergeRequest, args, input), { - cwd: repoRoot, - env: environment, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const stdout = []; - const stderr = []; - child.stdout.on('data', (chunk) => stdout.push(chunk)); - child.stderr.on('data', (chunk) => stderr.push(chunk)); - child.on('error', (error) => { - reject(error.code === 'ENOENT' ? createGlabNotFoundError() : error); - }); - child.on('close', (code) => { - if (code === 0) { - resolve(Buffer.concat(stdout).toString('utf8')); - } else { - reject( - new Error( - Buffer.concat(stderr).toString('utf8').trim() || `glab api exited with code ${code}.`, - ), - ); - } - }); - child.stdin.end(input == null ? undefined : JSON.stringify(input)); - }); -}; - -/** @param {string} value */ -const parseGlabJsonPages = (value) => { - const documents = []; - let depth = 0; - let escape = false; - let inString = false; - let start = -1; - - for (let index = 0; index < value.length; index += 1) { - const character = value[index]; - if (start === -1) { - if (/\s/.test(character)) { - continue; - } - if (character !== '[' && character !== '{') { - throw new SyntaxError(`Unexpected character in glab JSON output at position ${index}.`); - } - start = index; - depth = 1; - continue; - } - - if (inString) { - if (escape) { - escape = false; - } else if (character === '\\') { - escape = true; - } else if (character === '"') { - inString = false; - } - continue; - } - - if (character === '"') { - inString = true; - } else if (character === '[' || character === '{') { - depth += 1; - } else if (character === ']' || character === '}') { - depth -= 1; - if (depth === 0) { - documents.push(JSON.parse(value.slice(start, index + 1))); - start = -1; - } - } - } - - if (start !== -1 || inString) { - throw new SyntaxError('Incomplete JSON document in glab output.'); - } - - return documents.flatMap((document) => (Array.isArray(document) ? document : [document])); -}; - /** @param {{number: number; projectPath: string}} mergeRequest */ const mergeRequestEndpoint = (mergeRequest, suffix = '') => `projects/${encodeProjectPath(mergeRequest.projectPath)}/merge_requests/${ @@ -227,35 +78,98 @@ const selectMergeRequestRemote = (repoRoot, mergeRequest) => { return remote; }; -/** @param {string} repoRoot @param {ReturnType} mergeRequest */ -const readMergeRequestMetadata = async (repoRoot, mergeRequest) => - JSON.parse(await glabApi(repoRoot, mergeRequest, [mergeRequestEndpoint(mergeRequest)])); +/** Provider transport backed by the authenticated `glab` process owned by Electron. */ +const createMergeRequestTransport = (repoRoot, mergeRequest) => + createGlabGitLabTransport({ hostname: mergeRequest.host, repoRoot }); + +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {{request: (request: any) => Promise}} [transport] + */ +const readMergeRequestMetadata = (repoRoot, mergeRequest, transport) => + (transport || createMergeRequestTransport(repoRoot, mergeRequest)).request({ + path: mergeRequestEndpoint(mergeRequest), + }); + +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {ReturnType} [transport] + */ +const readMergeRequestDiffs = (repoRoot, mergeRequest, transport) => + (transport || createMergeRequestTransport(repoRoot, mergeRequest)).requestPages({ + path: mergeRequestEndpoint(mergeRequest, '/diffs'), + query: { per_page: 100 }, + }); /** @param {string} repoRoot @param {ReturnType} mergeRequest */ -const readMergeRequestDiffs = async (repoRoot, mergeRequest) => - parseGlabJsonPages( - await glabApi(repoRoot, mergeRequest, [ - '--paginate', - `${mergeRequestEndpoint(mergeRequest, '/diffs')}?per_page=100`, - ]), - ); +const mergeRequestHydrationSnapshotKey = (repoRoot, mergeRequest) => + `${repoRoot}:${mergeRequest.url}`; -/** @param {any} diff */ -const normalizeGitLabDiffFile = (diff) => ({ - filename: diff.new_path, - patch: diff.diff, - ...(diff.old_path !== diff.new_path ? { previous_filename: diff.old_path } : {}), - status: diff.new_file - ? 'added' - : diff.deleted_file - ? 'removed' - : diff.renamed_file - ? 'renamed' - : 'modified', -}); +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {{headSha?: string, metadata: any, range: import('../../core/lib/review-artifacts.ts').RangeArtifact}} snapshot + */ +const rememberMergeRequestHydrationSnapshot = (repoRoot, mergeRequest, snapshot) => { + const key = mergeRequestHydrationSnapshotKey(repoRoot, mergeRequest); + mergeRequestHydrationSnapshots.delete(key); + mergeRequestHydrationSnapshots.set(key, snapshot); + while (mergeRequestHydrationSnapshots.size > MAX_MERGE_REQUEST_HYDRATION_SNAPSHOTS) { + mergeRequestHydrationSnapshots.delete(mergeRequestHydrationSnapshots.keys().next().value); + } +}; -/** @param {any} note @param {string} url */ -const normalizeGitLabReviewComment = (note, url) => { +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {{expectedHeadSha?: string, forceRefresh?: boolean}} [options] + */ +const readMergeRequestHydrationSnapshot = async (repoRoot, mergeRequest, options = {}) => { + const key = mergeRequestHydrationSnapshotKey(repoRoot, mergeRequest); + const cached = mergeRequestHydrationSnapshots.get(key); + if ( + !options.forceRefresh && + options.expectedHeadSha && + cached?.headSha === options.expectedHeadSha + ) { + return cached; + } + const transport = createMergeRequestTransport(repoRoot, mergeRequest); + const [gitlab, metadata] = await Promise.all([ + loadGitLabHistory(), + readMergeRequestMetadata(repoRoot, mergeRequest, transport), + ]); + const baseSha = metadata.diff_refs?.base_sha; + const headSha = metadata.diff_refs?.head_sha || metadata.sha; + if (!baseSha || !headSha) { + throw new Error('GitLab did not return complete merge request range coordinates.'); + } + if (options.expectedHeadSha && headSha !== options.expectedHeadSha) { + throw new Error('The merge request head changed. Refresh before loading exact file contents.'); + } + const project = { + host: mergeRequest.host, + project: mergeRequest.projectPath, + provider: /** @type {'gitlab'} */ ('gitlab'), + }; + const artifactSource = gitlab.createGitLabArtifactSource({ + project, + projectPath: mergeRequest.projectPath, + transport, + }); + const { range } = await artifactSource.readStackAndRange( + { requestedBaseSha: baseSha, headSha }, + new AbortController().signal, + ); + const snapshot = { headSha, metadata, range }; + rememberMergeRequestHydrationSnapshot(repoRoot, mergeRequest, snapshot); + return snapshot; +}; + +/** @param {any} note @param {string} url @param {string} [threadId] */ +const normalizeGitLabReviewComment = (note, url, threadId) => { const position = note.position || note.original_position; const isFilePosition = position?.position_type === 'file'; const lineNumber = position?.new_line ?? position?.old_line; @@ -289,14 +203,15 @@ const normalizeGitLabReviewComment = (note, url) => { startSide: start.type === 'old' ? 'deletions' : 'additions', } : {}), + ...(threadId ? { threadId } : {}), submittedAt: note.created_at, url: `${url}#note_${note.id}`, }; }; -/** @param {any} note @param {PullRequestReviewComment} submittedComment @param {string} url */ -const normalizeSubmittedGitLabReviewComment = (note, submittedComment, url) => { - const normalized = normalizeGitLabReviewComment(note, url); +/** @param {any} note @param {PullRequestReviewComment} submittedComment @param {string} url @param {string} [threadId] */ +const normalizeSubmittedGitLabReviewComment = (note, submittedComment, url, threadId) => { + const normalized = normalizeGitLabReviewComment(note, url, threadId || submittedComment.threadId); if (normalized) { return normalized; } @@ -317,18 +232,24 @@ const normalizeSubmittedGitLabReviewComment = (note, submittedComment, url) => { }; }; -/** @param {string} repoRoot @param {ReturnType} mergeRequest */ -const readMergeRequestComments = async (repoRoot, mergeRequest) => { - const discussions = parseGlabJsonPages( - await glabApi(repoRoot, mergeRequest, [ - '--paginate', - `${mergeRequestEndpoint(mergeRequest, '/discussions')}?per_page=100`, - ]), - ); +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {ReturnType} [transport] + */ +const readMergeRequestComments = async (repoRoot, mergeRequest, transport) => { + const discussions = await ( + transport || createMergeRequestTransport(repoRoot, mergeRequest) + ).requestPages({ + path: mergeRequestEndpoint(mergeRequest, '/discussions'), + query: { per_page: 100 }, + }); return discussions - .flatMap((discussion) => discussion.notes || []) - .filter((note) => !note.system && !note.resolved) - .map((note) => normalizeGitLabReviewComment(note, mergeRequest.url)) + .flatMap((discussion) => + (discussion.notes || []).map((note) => ({ note, threadId: discussion.id })), + ) + .filter(({ note }) => !note.system && !note.resolved) + .map(({ note, threadId }) => normalizeGitLabReviewComment(note, mergeRequest.url, threadId)) .filter(Boolean); }; @@ -351,6 +272,7 @@ const createMergeRequestSource = (mergeRequest, metadata) => ({ number: mergeRequest.number, projectPath: mergeRequest.projectPath, provider: 'gitlab', + ...(metadata.target_branch ? { targetBranch: metadata.target_branch } : {}), title: metadata.title, type: 'pull-request', url: metadata.web_url || mergeRequest.url, @@ -389,88 +311,208 @@ const resolveMergeRequestContentRefs = async (repoRoot, mergeRequest, metadata) metadata, ); } + const expectedHead = metadata.diff_refs?.head_sha || metadata.sha; + const resolvedHead = ( + await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', head]) + ).trim(); + const resolvedBase = ( + await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', base]) + ).trim(); + if (!resolvedHead || !resolvedBase || (expectedHead && resolvedHead !== expectedHead)) { + return null; + } const metadataBase = metadata.diff_refs?.base_sha; if ( metadataBase && (await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', `${metadataBase}^{commit}`])) ) { - return { base: metadataBase, head }; + return { base: metadataBase, head: resolvedHead }; } const mergeBase = (await gitOrEmpty(repoRoot, ['merge-base', base, head])).trim(); - return mergeBase ? { base: mergeBase, head } : null; + return mergeBase ? { base: mergeBase, head: resolvedHead } : null; }; -/** @param {string} launchPath @param {Extract} source */ -const readMergeRequestState = async (launchPath, source) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const mergeRequest = parseGitLabMergeRequestUrl(source.url); - selectMergeRequestRemote(repoRoot, mergeRequest); - const [metadata, diffs, reviewComments] = await Promise.all([ - readMergeRequestMetadata(repoRoot, mergeRequest), - readMergeRequestDiffs(repoRoot, mergeRequest), - readMergeRequestComments(repoRoot, mergeRequest), - ]); +/** + * Hydrate eligible files from one immutable MR range with one pair of batched + * Git object reads. + * + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {any} metadata + * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range + * @param {ReadonlyArray} files + * @param {{force?: boolean}} [options] + */ +const hydrateMergeRequestSections = async ( + repoRoot, + mergeRequest, + metadata, + range, + files, + options = {}, +) => { + const candidates = files.filter( + (file) => canHydrateArtifactFile(file) && !isBinaryDiffPatch(file.patch || ''), + ); const refs = await resolveMergeRequestContentRefs(repoRoot, mergeRequest, metadata).catch( () => null, ); - const reviewFiles = diffs.map((rawDiff) => { - const file = normalizeGitLabDiffFile(rawDiff); - return { - file, - oldPath: file.previous_filename || file.filename, - patch: createPatchFromPullRequestFile(file), - }; - }); - const [oldFiles, newFiles] = refs - ? await Promise.all([ - readGitFiles( - repoRoot, - refs.base, - reviewFiles.map(({ oldPath }) => oldPath), - { refScopedEmptyCacheKey: true }, - ), - readGitFiles( - repoRoot, - refs.head, - reviewFiles.map(({ file }) => file.filename), - { refScopedEmptyCacheKey: true }, - ), - ]) - : [new Map(), new Map()]; - /** @type {Array} */ - const files = reviewFiles.map(({ file, oldPath, patch }) => { - const oldFile = refs ? oldFiles.get(oldPath) : null; - const newFile = refs ? newFiles.get(file.filename) : null; - const section = createPullRequestSection(mergeRequest, file, patch, oldFile, newFile); + if (!refs) { + return candidates.map((file) => ({ + path: file.path, + section: createPullRequestSection(mergeRequest, file, undefined, undefined, { + base: range.baseSha, + contentAttempted: true, + contentError: + 'Codiff could not resolve the immutable merge request range. Retry exact content loading.', + head: range.headSha, + }), + })); + } + + const oldPaths = candidates.map((file) => file.oldPath || file.path); + const newPaths = candidates.map((file) => file.path); + const [oldFiles, newFiles] = await Promise.all([ + readGitFiles(repoRoot, refs.base, oldPaths, { + force: options.force, + refScopedEmptyCacheKey: true, + }), + readGitFiles(repoRoot, refs.head, newPaths, { + force: options.force, + refScopedEmptyCacheKey: true, + }), + ]); + return candidates.map((file) => { + const oldPath = file.oldPath || file.path; return { - fingerprint: getFingerprint( - [metadata.sha || '', file.status, file.previous_filename || '', file.filename, patch].join( - '\n', - ), + path: file.path, + section: createPullRequestSection( + mergeRequest, + file, + oldFiles.get(oldPath), + newFiles.get(file.path), + { base: range.baseSha, contentAttempted: true, head: range.headSha }, ), - oldPath: file.previous_filename, - path: file.filename, - sections: [section], - status: - file.status === 'added' - ? 'added' - : file.status === 'removed' - ? 'deleted' - : file.status === 'renamed' - ? 'renamed' - : 'modified', }; }); +}; + +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {any} metadata + * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range +const hydrateMergeRequestSection = async ( + repoRoot, + mergeRequest, + metadata, + range, + file, + options = {}, +) => { + const [result] = await hydrateMergeRequestSections( + repoRoot, + mergeRequest, + metadata, + range, + [file], + options, + ); + return ( + result?.section ?? + createPullRequestSection(mergeRequest, file, undefined, undefined, { + base: range.baseSha, + head: range.headSha, + }) + ); +}; + options = {}, +) => (await hydrateMergeRequestSections(repoRoot, mergeRequest, metadata, range, [file], options))[0] + ?.section; + +/** @param {string} launchPath @param {Extract} source */ +const readMergeRequestSectionsContent = async (launchPath, source) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const mergeRequest = parseGitLabMergeRequestUrl(source.url); + selectMergeRequestRemote(repoRoot, mergeRequest); + const { metadata, range } = await readMergeRequestHydrationSnapshot(repoRoot, mergeRequest, { + expectedHeadSha: source.headSha, + }); + const key = `${repoRoot}:${mergeRequest.url}:${range.headSha}`; + const existing = mergeRequestBulkHydrations.get(key); + if (existing) { + return existing; + } + + const hydration = hydrateMergeRequestSections( + repoRoot, + mergeRequest, + metadata, + range, + range.files, + ) + .then((sections) => ({ headSha: range.headSha, sections })) + .catch((error) => { + mergeRequestBulkHydrations.delete(key); + throw error; + }); + mergeRequestBulkHydrations.set(key, hydration); + while (mergeRequestBulkHydrations.size > MAX_MERGE_REQUEST_BULK_HYDRATIONS) { + mergeRequestBulkHydrations.delete(mergeRequestBulkHydrations.keys().next().value); + } + return hydration; +}; + +/** @param {string} launchPath @param {Extract} source */ +const readMergeRequestState = async (launchPath, source) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const mergeRequest = parseGitLabMergeRequestUrl(source.url); + selectMergeRequestRemote(repoRoot, mergeRequest); + const { metadata, range } = await readMergeRequestHydrationSnapshot(repoRoot, mergeRequest, { + forceRefresh: true, + }); + const files = rangeArtifactToPullRequestFiles(range, mergeRequest.number, { + deferContents: true, + }); return { files: files.sort((left, right) => left.path.localeCompare(right.path)), generatedAt: Date.now(), launchPath, - reviewComments, + reviewCommentsLoadState: 'not-loaded', root: repoRoot, source: createMergeRequestSource(mergeRequest, metadata), }; }; +/** @param {string} launchPath @param {Extract} source */ +const readMergeRequestReviewComments = async (launchPath, source) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const mergeRequest = parseGitLabMergeRequestUrl(source.url); + selectMergeRequestRemote(repoRoot, mergeRequest); + return readMergeRequestComments(repoRoot, mergeRequest); +}; + +/** + * Load exact local contents for one merge-request file when explicitly retried. + * @param {string} launchPath + * @param {Extract} source + * @param {string} requestedPath + */ +const readMergeRequestSectionContent = async (launchPath, source, requestedPath, options = {}) => { + const path = validateRepositoryPath(requestedPath); + const mergeRequest = parseGitLabMergeRequestUrl(source.url); + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + selectMergeRequestRemote(repoRoot, mergeRequest); + const { metadata, range } = await readMergeRequestHydrationSnapshot(repoRoot, mergeRequest, { + expectedHeadSha: source.headSha, + }); + const file = range.files.find((candidate) => candidate.path === path); + if (!file) { + throw new Error('File is not part of this merge request.'); + } + return hydrateMergeRequestSection(repoRoot, mergeRequest, metadata, range, file, options); +}; + /** @param {any} commit @param {'base' | 'pull-request'} scope */ const normalizeGitLabCommit = (commit, scope) => normalizeGitHubCommit( @@ -488,18 +530,22 @@ const normalizeGitLabCommit = (commit, scope) => scope, ); -/** @param {string} repoRoot @param {any} mergeRequest @param {string} ref @param {number} limit */ -const readRepositoryCommits = async (repoRoot, mergeRequest, ref, limit) => { +/** + * @param {string} repoRoot + * @param {any} mergeRequest + * @param {string} ref + * @param {number} limit + * @param {ReturnType} [transport] + */ +const readRepositoryCommits = async (repoRoot, mergeRequest, ref, limit, transport) => { + const client = transport || createMergeRequestTransport(repoRoot, mergeRequest); const commits = []; for (let page = 1; commits.length < limit; page += 1) { const perPage = Math.min(limit - commits.length, 100); - const pageCommits = JSON.parse( - await glabApi(repoRoot, mergeRequest, [ - `projects/${encodeProjectPath(mergeRequest.projectPath)}/repository/commits?ref_name=${encodeURIComponent( - ref, - )}&per_page=${perPage}&page=${page}`, - ]), - ); + const pageCommits = await client.request({ + path: `projects/${encodeProjectPath(mergeRequest.projectPath)}/repository/commits`, + query: { page, per_page: perPage, ref_name: ref }, + }); if (!Array.isArray(pageCommits) || pageCommits.length === 0) { break; } @@ -515,15 +561,14 @@ const readRepositoryCommits = async (repoRoot, mergeRequest, ref, limit) => { const listMergeRequestHistory = async (launchPath, source, limit = 200) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const mergeRequest = parseGitLabMergeRequestUrl(source.url); - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest); - const commits = parseGlabJsonPages( - await glabApi(repoRoot, mergeRequest, [ - '--paginate', - `${mergeRequestEndpoint(mergeRequest, '/commits')}?per_page=100`, - ]), - ); + const transport = createMergeRequestTransport(repoRoot, mergeRequest); + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const commits = await transport.requestPages({ + path: mergeRequestEndpoint(mergeRequest, '/commits'), + query: { per_page: 100 }, + }); const baseCommits = metadata.target_branch - ? await readRepositoryCommits(repoRoot, mergeRequest, metadata.target_branch, limit) + ? await readRepositoryCommits(repoRoot, mergeRequest, metadata.target_branch, limit, transport) : []; return { entries: [ @@ -640,45 +685,40 @@ const submitMergeRequestComment = async (launchPath, request) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const mergeRequest = parseGitLabMergeRequestUrl(request.source.url); selectMergeRequestRemote(repoRoot, mergeRequest); + const transport = createMergeRequestTransport(repoRoot, mergeRequest); if (request.comment.threadId) { - const note = JSON.parse( - await glabApi( - repoRoot, - mergeRequest, - [ - '--method', - 'POST', - '--input', - '-', - getGitLabDiscussionReplyEndpoint(mergeRequest, request.comment.threadId), - ], - { body: request.comment.body }, - ), + const note = await transport.request({ + body: { body: request.comment.body }, + method: 'POST', + path: getGitLabDiscussionReplyEndpoint(mergeRequest, request.comment.threadId), + }); + const comment = normalizeSubmittedGitLabReviewComment( + note, + request.comment, + mergeRequest.url, + request.comment.threadId, ); - const comment = normalizeSubmittedGitLabReviewComment(note, request.comment, mergeRequest.url); if (!comment) { throw new Error('GitLab accepted the reply but did not return comment metadata.'); } return comment; } - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest); - const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest); + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest, transport); const diff = diffs.find((candidate) => candidate.new_path === request.comment.filePath); - const discussion = JSON.parse( - await glabApi( - repoRoot, - mergeRequest, - ['--method', 'POST', '--input', '-', mergeRequestEndpoint(mergeRequest, '/discussions')], - { - body: request.comment.body, - position: createGitLabPosition(request.comment, metadata, diff), - }, - ), - ); + const discussion = await transport.request({ + body: { + body: request.comment.body, + position: createGitLabPosition(request.comment, metadata, diff), + }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/discussions'), + }); const comment = normalizeSubmittedGitLabReviewComment( discussion.notes?.[0], request.comment, mergeRequest.url, + discussion.id, ); if (!comment) { throw new Error('GitLab accepted the comment but did not return comment metadata.'); @@ -692,28 +732,27 @@ const submitMergeRequestReview = async (launchPath, request) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const mergeRequest = parseGitLabMergeRequestUrl(request.source.url); selectMergeRequestRemote(repoRoot, mergeRequest); - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest); - const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest); + const transport = createMergeRequestTransport(repoRoot, mergeRequest); + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest, transport); for (const comment of request.comments) { const diff = diffs.find((candidate) => candidate.new_path === comment.filePath); - await glabApi( - repoRoot, - mergeRequest, - ['--method', 'POST', '--input', '-', mergeRequestEndpoint(mergeRequest, '/draft_notes')], - { + await transport.request({ + body: { note: comment.body, position: createGitLabPosition(comment, metadata, diff), }, - ); + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/draft_notes'), + }); } - await glabApi( - repoRoot, - mergeRequest, - ['--method', 'POST', '--input', '-', mergeRequestEndpoint(mergeRequest, '/notes')], - { + await transport.request({ + body: { body: `${request.body ? `${request.body}\n\n` : ''}${quickAction}`, }, - ); + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/notes'), + }); }; /** @param {string} launchPath @param {Extract} source @param {string} requestedPath */ @@ -722,16 +761,17 @@ const readMergeRequestImageContent = async (launchPath, source, requestedPath) = const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const path = validateRepositoryPath(requestedPath); const mergeRequest = parseGitLabMergeRequestUrl(source.url); - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest); - const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest); - const rawDiff = diffs.find((candidate) => candidate.new_path === path); - if (!rawDiff) { + const { metadata, range } = await readMergeRequestHydrationSnapshot(repoRoot, mergeRequest, { + expectedHeadSha: source.headSha, + }); + const file = range.files.find((candidate) => candidate.path === path); + if (!file) { throw new Error('File is not part of this merge request.'); } const refs = await resolveMergeRequestContentRefs(repoRoot, mergeRequest, metadata); const [oldImage, newImage] = await Promise.all([ - refs ? readGitImageFile(repoRoot, refs.base, rawDiff.old_path) : undefined, - refs ? readGitImageFile(repoRoot, refs.head, rawDiff.new_path) : undefined, + refs ? readGitImageFile(repoRoot, refs.base, file.oldPath || file.path) : undefined, + refs ? readGitImageFile(repoRoot, refs.head, file.path) : undefined, ]); return oldImage || newImage ? { ...(newImage ? { newImage } : {}), ...(oldImage ? { oldImage } : {}), status: 'ready' } @@ -747,10 +787,14 @@ const readMergeRequestImageContent = async (launchPath, source, requestedPath) = module.exports = { createGitLabPosition, createMergeRequestFetchRefspecs, + createMergeRequestSource, listMergeRequestHistory, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, readMergeRequestImageContent, + readMergeRequestReviewComments, + readMergeRequestSectionContent, + readMergeRequestSectionsContent, readMergeRequestState, submitMergeRequestComment, submitMergeRequestReview, diff --git a/electron/git-state/provider-artifact-sources.cjs b/electron/git-state/provider-artifact-sources.cjs new file mode 100644 index 00000000..9ceac09c --- /dev/null +++ b/electron/git-state/provider-artifact-sources.cjs @@ -0,0 +1,265 @@ +// @ts-check + +const { loadGitHubHistory } = require('../github-history-bridge.cjs'); +const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); +const { git, gitBufferWithInput } = require('./common.cjs'); +const { createGhGitHubTransport } = require('./github-history/gh-github-transport.cjs'); +const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); +const { parseReviewUrl } = require('../review-source.cjs'); + +const fileBlobReadConcurrency = 8; + +/** @param {import('../../core/types.ts').ResolvedReviewSource} source */ +const getProviderIdentity = (source) => { + if (source.type !== 'pull-request') { + return null; + } + const parsed = parseReviewUrl(source.url); + const provider = source.provider || parsed?.provider; + if (provider === 'gitlab') { + const host = source.host || (parsed?.provider === 'gitlab' ? parsed.host : undefined); + const projectPath = + source.projectPath || (parsed?.provider === 'gitlab' ? parsed.projectPath : undefined); + return host && projectPath + ? { + headSha: source.headSha, + host, + number: source.number || (parsed?.provider === 'gitlab' ? parsed.number : undefined), + projectPath, + provider: /** @type {const} */ ('gitlab'), + } + : null; + } + if (provider === 'github') { + const projectParts = source.projectPath?.split('/') || []; + const owner = + source.owner || projectParts[0] || (parsed?.provider === 'github' ? parsed.owner : undefined); + const repo = + source.repo || + projectParts.slice(1).join('/') || + (parsed?.provider === 'github' ? parsed.repo : undefined); + return owner && repo + ? { + host: source.host || 'github.com', + headSha: source.headSha, + number: source.number || (parsed?.provider === 'github' ? parsed.number : undefined), + owner, + provider: /** @type {const} */ ('github'), + repo, + } + : null; + } + return null; +}; + +/** + * One native-first commit-content adapter for every resolved review source. + * Provider-specific modules retain metadata and mutations; exact bytes use + * this common shape after the source has been normalized. + * @param {string} repoRoot + * @param {import('../../core/types.ts').ResolvedReviewSource} source + */ +const createCommitContentAdapter = (repoRoot, source) => { + const provider = getProviderIdentity(source); + if (provider?.provider === 'github') { + return { + identity: githubArtifactProject(provider), + readFileBlobs: (requests) => readGitHubFileBlobArtifacts(repoRoot, provider, requests), + }; + } + if (provider?.provider === 'gitlab') { + return { + identity: gitlabArtifactProject(provider), + readFileBlobs: (requests) => readGitLabFileBlobArtifacts(repoRoot, provider, requests), + }; + } + const identity = { host: 'local', project: repoRoot, provider: /** @type {const} */ ('git') }; + return { + identity, + readFileBlobs: (requests) => readNativeFileBlobArtifacts(repoRoot, identity, requests), + }; +}; + +/** @param {{host?: string, owner: string, repo: string}} pull */ +const githubArtifactProject = (pull) => ({ + host: pull.host || 'github.com', + project: `${pull.owner}/${pull.repo}`, + provider: /** @type {const} */ ('github'), +}); + +/** @param {{host: string, projectPath: string}} mergeRequest */ +const gitlabArtifactProject = (mergeRequest) => ({ + host: mergeRequest.host, + project: mergeRequest.projectPath, + provider: /** @type {const} */ ('gitlab'), +}); + +/** @param {{path: string, ref: string}} request */ +const fileBlobRequestKey = (request) => `${request.ref}:${request.path}`; + +/** + * Provider fallback is allowed only while the logical review still points at + * the immutable head captured by RepositoryState. Native hits stay offline. + * @param {ReturnType} transport + * @param {{headSha?: string, number?: number, owner: string, repo: string}} pull + * @param {AbortSignal} signal + */ +const assertGitHubHeadCurrent = async (transport, pull, signal) => { + if (!pull.headSha || !pull.number) return; + const metadata = await transport.request({ + path: `repos/${pull.owner}/${pull.repo}/pulls/${pull.number}`, + signal, + }); + signal.throwIfAborted(); + if (metadata?.head?.sha !== pull.headSha) { + throw new Error('The pull request head changed. Refresh before loading exact content.'); + } +}; + +/** + * @param {ReturnType} transport + * @param {{headSha?: string, number?: number, projectPath: string}} mergeRequest + * @param {AbortSignal} signal + */ +const assertGitLabHeadCurrent = async (transport, mergeRequest, signal) => { + if (!mergeRequest.headSha || !mergeRequest.number) return; + const metadata = await transport.request({ + path: `projects/${encodeURIComponent(mergeRequest.projectPath)}/merge_requests/${ + mergeRequest.number + }`, + signal, + }); + signal.throwIfAborted(); + const headSha = metadata?.diff_refs?.head_sha || metadata?.sha; + if (headSha !== mergeRequest.headSha) { + throw new Error('The merge request head changed. Refresh before loading exact content.'); + } +}; + +/** + * Resolve immutable ref+path coordinates from native Git in one bounded batch. + * @param {string} repoRoot + * @param {import('../../core/lib/review-artifacts.ts').ReviewArtifactProject} project + * @param {ReadonlyArray<{maxBytes: number, path: string, ref: string, signal?: AbortSignal}>} requests + */ +const readNativeFileBlobArtifacts = async (repoRoot, project, requests) => { + const pending = [ + ...new Map(requests.map((request) => [fileBlobRequestKey(request), request])).values(), + ]; + /** @type {Map} */ + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + const request = pending[nextIndex++]; + request.signal?.throwIfAborted(); + try { + const coordinate = `${request.ref}:${request.path}`; + const objectId = (await git(repoRoot, ['rev-parse', '--verify', coordinate])).trim(); + if (!/^[\da-f]{40,64}$/i.test(objectId)) { + continue; + } + const size = Number.parseInt( + (await git(repoRoot, ['cat-file', '-s', objectId])).trim(), + 10, + ); + if (!Number.isFinite(size) || size > request.maxBytes) { + continue; + } + const bytes = await gitBufferWithInput(repoRoot, ['show', coordinate], ''); + if (bytes.byteLength <= request.maxBytes) { + blobs.set(fileBlobRequestKey(request), { + bytes, + objectId, + provenance: { kind: 'native-git', project }, + }); + } + } catch { + request.signal?.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(fileBlobReadConcurrency, pending.length) }, worker), + ); + return blobs; +}; + +/** + * Resolve GitHub ref+path coordinates through native Git first and the + * canonical API Artifact Source second. Returned bytes carry Git object IDs. + * @param {string} repoRoot + * @param {{headSha?: string, host?: string, number?: number, owner: string, repo: string}} pull + * @param {ReadonlyArray<{maxBytes: number, path: string, ref: string, signal?: AbortSignal}>} requests + * @param {ReturnType} [transport] + */ +const readGitHubFileBlobArtifacts = async (repoRoot, pull, requests, transport) => { + const signal = requests.find((request) => request.signal)?.signal || new AbortController().signal; + const coordinates = requests.map(({ maxBytes, path, ref }) => ({ maxBytes, path, ref })); + const project = githubArtifactProject(pull); + const maxBytes = Math.max(0, ...coordinates.map((request) => request.maxBytes)); + const local = await readNativeFileBlobArtifacts( + repoRoot, + project, + coordinates.map((coordinate) => ({ ...coordinate, signal })), + ); + const missing = coordinates.filter((coordinate) => !local.has(fileBlobRequestKey(coordinate))); + if (missing.length === 0) { + return local; + } + const providerTransport = transport || createGhGitHubTransport({ repoRoot }); + await assertGitHubHeadCurrent(providerTransport, pull, signal); + const source = (await loadGitHubHistory()).createGitHubArtifactSource({ + maxBlobArtifactBytes: maxBytes, + project, + pull: { ...pull, number: pull.number || 0 }, + transport: providerTransport, + }); + const provider = (await source.readFileBlobs?.(missing, signal)) || new Map(); + return new Map([...local, ...provider]); +}; + +/** + * Resolve GitLab ref+path coordinates through native Git first and the + * canonical API Artifact Source second. Returned bytes carry Git object IDs. + * @param {string} repoRoot + * @param {{headSha?: string, host: string, number?: number, projectPath: string}} mergeRequest + * @param {ReadonlyArray<{maxBytes: number, path: string, ref: string, signal?: AbortSignal}>} requests + * @param {ReturnType} [transport] + */ +const readGitLabFileBlobArtifacts = async (repoRoot, mergeRequest, requests, transport) => { + const signal = requests.find((request) => request.signal)?.signal || new AbortController().signal; + const coordinates = requests.map(({ maxBytes, path, ref }) => ({ maxBytes, path, ref })); + const project = gitlabArtifactProject(mergeRequest); + const maxBytes = Math.max(0, ...coordinates.map((request) => request.maxBytes)); + const local = await readNativeFileBlobArtifacts( + repoRoot, + project, + coordinates.map((coordinate) => ({ ...coordinate, signal })), + ); + const missing = coordinates.filter((coordinate) => !local.has(fileBlobRequestKey(coordinate))); + if (missing.length === 0) { + return local; + } + const providerTransport = + transport || createGlabGitLabTransport({ hostname: mergeRequest.host, repoRoot }); + await assertGitLabHeadCurrent(providerTransport, mergeRequest, signal); + const source = (await loadGitLabHistory()).createGitLabArtifactSource({ + maxBlobArtifactBytes: maxBytes, + project, + projectPath: mergeRequest.projectPath, + transport: providerTransport, + }); + const provider = (await source.readFileBlobs?.(missing, signal)) || new Map(); + return new Map([...local, ...provider]); +}; + +module.exports = { + createCommitContentAdapter, + getProviderIdentity, + githubArtifactProject, + gitlabArtifactProject, + readGitHubFileBlobArtifacts, + readGitLabFileBlobArtifacts, + readNativeFileBlobArtifacts, +}; diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index 9bc2438d..6c39bc6e 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -1,27 +1,30 @@ // @ts-check -const { spawn } = require('node:child_process'); -const { homedir } = require('node:os'); -const { join } = require('node:path'); -const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); -const { getCommandEnvironment } = require('../login-shell-environment.cjs'); const { IMAGE_FILE_LIMIT, bufferToImageRevision, - createSummary, formatBytes, - getFingerprint, getImageMimeType, git, gitOrEmpty, - summarizeContent, validateRepositoryPath, } = require('./common.cjs'); const { readGitFiles } = require('./git-files.cjs'); +const { + canHydrateArtifactFile, + createPullRequestSection, + isBinaryDiffPatch, + rangeArtifactToPullRequestFiles, +} = require('./review-range-sections.cjs'); +const { + createGhGitHubTransport, + runGhApi, + runGhApiBuffer, +} = require('./github-history/gh-github-transport.cjs'); +const { loadGitHubHistory } = require('../github-history-bridge.cjs'); const { parseReviewUrl } = require('../review-source.cjs'); /** - * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('../../core/types.ts').HistoryEntry} HistoryEntry @@ -30,63 +33,31 @@ const { parseReviewUrl } = require('../review-source.cjs'); * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource * @typedef {import('../../core/types.ts').SubmitPullRequestCommentRequest} SubmitPullRequestCommentRequest * @typedef {import('../../core/types.ts').SubmitPullRequestReviewRequest} SubmitPullRequestReviewRequest + * @typedef {import('../../core/lib/review-artifacts.ts').ArtifactFile} ArtifactFile * @typedef {{owner: string; repo: string}} GitHubRepositoryReference * @typedef {{name: string; url: string}} LocalGitRemote * @typedef {{full_name?: string; name?: string; owner?: {login?: string}}} GitHubRepositoryMetadata * @typedef {{number: number; owner: string; repo: string; url: string}} PullRequestReference * @typedef {{direction: 'fetch' | 'push'; name: string; owner: string; repo: string}} GitHubRemote - * @typedef {{filename: string; patch?: string; previous_filename?: string; status: string}} GitHubPullRequestFile * @typedef {{base?: {ref?: string; repo?: GitHubRepositoryMetadata | null; sha?: string}; body?: string | null; head?: {ref?: string; repo?: GitHubRepositoryMetadata | null; sha?: string}; title?: string; user?: {avatar_url?: string; html_url?: string; login?: string}}} GitHubPullRequestMetadata * @typedef {{author?: {avatar_url?: string}; commit?: {author?: {date?: string; email?: string; name?: string}; message?: string}; parents?: ReadonlyArray<{sha?: string}>; sha?: string}} GitHubCommit * @typedef {{[key: string]: any}} GitHubReviewComment * @typedef {{comments?: {nodes?: ReadonlyArray<{databaseId?: number | null}>} | null; isResolved?: boolean}} GitHubReviewThread */ -const GH_NOT_FOUND_CODE = 'GH_NOT_FOUND'; -const GH_NOT_FOUND_MESSAGE = - 'GitHub support requires gh. Install gh, authenticate it, and verify `gh --version` works in Terminal. Codiff searches PATH, ~/.local/bin/gh, /opt/homebrew/bin/gh, and /usr/local/bin/gh. If gh is installed somewhere else, quit Codiff, then launch it with `CODIFF_GH_PATH=/absolute/path/to/gh codiff`.'; - -/** @param {string} [detail] */ -const createGhNotFoundError = (detail) => - Object.assign(new Error(detail ? `${GH_NOT_FOUND_MESSAGE} ${detail}` : GH_NOT_FOUND_MESSAGE), { - code: GH_NOT_FOUND_CODE, - }); - /** - * A Dock or Spotlight launch gives the app a minimal PATH that omits the - * directories Homebrew and manual installs use, and later `codiff` invocations - * hand their arguments to that first process rather than replacing it, so `gh` - * has to be located the same way the other CLIs Codiff shells out to are. + * The first usable state already has the exact provider metadata and patches + * needed to hydrate a section. Keep only the latest immutable-head snapshot + * for a small number of open review roots so the first deferred file does not + * repeat those provider reads. + * @type {Map} */ -const getGhCommand = () => { - const ghPath = process.env.CODIFF_GH_PATH?.trim(); - if (ghPath) { - if (isExecutableFile(ghPath)) { - return ghPath; - } - - throw createGhNotFoundError( - `CODIFF_GH_PATH is set to ${JSON.stringify(ghPath)}, but that file is not executable.`, - ); - } - - const pathCommand = findExecutableOnPath('gh'); - if (pathCommand) { - return pathCommand; - } +const pullRequestHydrationSnapshots = new Map(); +const MAX_PULL_REQUEST_HYDRATION_SNAPSHOTS = 8; - for (const path of [ - join(homedir(), '.local/bin/gh'), - '/opt/homebrew/bin/gh', - '/usr/local/bin/gh', - ]) { - if (isExecutableFile(path)) { - return path; - } - } - - throw createGhNotFoundError(); -}; +/** @type {Map>} */ +const pullRequestBulkHydrations = new Map(); +const MAX_PULL_REQUEST_BULK_HYDRATIONS = 8; /** @param {string} value @returns {PullRequestReference} */ const parseGitHubPullRequestUrl = (value) => { @@ -184,6 +155,22 @@ const readLocalGitRemotes = async (repoRoot) => { return remotes.filter((remote) => remote != null); }; +/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ +const assertPullRequestMatchesRepository = async (repoRoot, pullRequest) => { + const matchesRepository = (await readLocalGitRemotes(repoRoot)).some(({ url }) => { + const repository = parseGitHubRemoteUrl(url) ?? parseRemoteRepositoryPath(url); + return ( + repository?.owner.toLowerCase() === pullRequest.owner.toLowerCase() && + repository.repo.toLowerCase() === pullRequest.repo.toLowerCase() + ); + }); + if (!matchesRepository) { + throw new Error( + `Pull request ${pullRequest.owner}/${pullRequest.repo} does not match a GitHub remote in this repository.`, + ); + } +}; + /** @param {LocalGitRemote} remote */ const getRemotePriority = (remote) => (remote.name === 'origin' ? 0 : 1); @@ -271,93 +258,112 @@ const fetchPullRequestHistoryRefs = (repoRoot, remote, pullRequest, metadata) => ]); /** - * Runs `gh api` and reports how it exited. Callers decide which exit codes are - * failures because a missing file is a valid answer for content lookups. - * * @param {string} repoRoot * @param {ReadonlyArray} args * @param {unknown} [input] - * @returns {Promise<{code: number | null; stderr: string; stdout: Buffer}>} + * @returns {Promise} */ -const runGhApi = async (repoRoot, args, input) => { - const environment = await getCommandEnvironment(); - return new Promise((resolve, reject) => { - const child = spawn(getGhCommand(), ['api', ...args], { - cwd: repoRoot, - env: environment, - stdio: ['pipe', 'pipe', 'pipe'], - }); - /** @type {Array} */ - const stdout = []; - /** @type {Array} */ - const stderr = []; - - child.stdout.on('data', (chunk) => stdout.push(chunk)); - child.stderr.on('data', (chunk) => stderr.push(chunk)); - child.on('error', (error) => reject(error.code === 'ENOENT' ? createGhNotFoundError() : error)); - child.on('close', (code) => - resolve({ - code, - stderr: Buffer.concat(stderr).toString('utf8'), - stdout: Buffer.concat(stdout), - }), - ); - - child.stdin.end(input == null ? undefined : JSON.stringify(input)); - }); -}; +const ghApi = (repoRoot, args, input) => runGhApi(repoRoot, args, input); /** * @param {string} repoRoot * @param {ReadonlyArray} args - * @param {unknown} [input] - * @returns {Promise} + * @returns {Promise} */ -const ghApi = async (repoRoot, args, input) => { - const { code, stderr, stdout } = await runGhApi(repoRoot, args, input); - if (code === 0) { - return stdout.toString('utf8'); +const ghApiBuffer = async (repoRoot, args) => { + try { + return await runGhApiBuffer(repoRoot, args, undefined, { maxBytes: IMAGE_FILE_LIMIT }); + } catch (error) { + if (error instanceof Error && /not found|404/i.test(error.message)) { + return undefined; + } + throw error; } - - throw new Error(stderr.trim() || `gh api exited with code ${code}.`); }; +/** Provider transport backed by the authenticated `gh` process owned by Electron. */ +const createPullRequestTransport = (repoRoot) => createGhGitHubTransport({ repoRoot }); + /** * @param {string} repoRoot - * @param {ReadonlyArray} args - * @returns {Promise} + * @param {PullRequestReference} pullRequest + * @param {{request: (request: any) => Promise}} [transport] + * @returns {Promise} */ -const ghApiBuffer = async (repoRoot, args) => { - const { code, stderr, stdout } = await runGhApi(repoRoot, args); - if (code === 0) { - return stdout; - } +const readPullRequestMetadata = (repoRoot, pullRequest, transport) => + (transport || createPullRequestTransport(repoRoot)).request({ + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, + }); - if (code === 1 && /not found|404/i.test(stderr)) { - return undefined; - } +/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ +const pullRequestHydrationSnapshotKey = (repoRoot, pullRequest) => `${repoRoot}:${pullRequest.url}`; - throw new Error(stderr.trim() || `gh api exited with code ${code}.`); +/** + * @param {string} repoRoot + * @param {PullRequestReference} pullRequest + * @param {{headSha?: string, metadata: GitHubPullRequestMetadata, range: import('../../core/lib/review-artifacts.ts').RangeArtifact}} snapshot + */ +const rememberPullRequestHydrationSnapshot = (repoRoot, pullRequest, snapshot) => { + const key = pullRequestHydrationSnapshotKey(repoRoot, pullRequest); + pullRequestHydrationSnapshots.delete(key); + pullRequestHydrationSnapshots.set(key, snapshot); + while (pullRequestHydrationSnapshots.size > MAX_PULL_REQUEST_HYDRATION_SNAPSHOTS) { + pullRequestHydrationSnapshots.delete(pullRequestHydrationSnapshots.keys().next().value); + } }; -/** @param {string} repoRoot @param {PullRequestReference} pullRequest @returns {Promise} */ -const readPullRequestMetadata = async (repoRoot, pullRequest) => - JSON.parse( - await ghApi(repoRoot, [ - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, - ]), - ); - -/** @param {string} repoRoot @param {PullRequestReference} pullRequest @returns {Promise>} */ -const readPullRequestFiles = async (repoRoot, pullRequest) => { - const pages = JSON.parse( - await ghApi(repoRoot, [ - '--paginate', - '--slurp', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/files?per_page=100`, - ]), +/** + * Read the provider data required to create or hydrate the current PR range. + * A deferred section accepts an in-process snapshot only when its immutable + * head is exactly the source's head; a fresh state read always refreshes it. + * + * @param {string} repoRoot + * @param {PullRequestReference} pullRequest + * @param {{expectedHeadSha?: string, forceRefresh?: boolean}} [options] + */ +const readPullRequestHydrationSnapshot = async (repoRoot, pullRequest, options = {}) => { + const key = pullRequestHydrationSnapshotKey(repoRoot, pullRequest); + const cached = pullRequestHydrationSnapshots.get(key); + if ( + !options.forceRefresh && + options.expectedHeadSha && + cached?.headSha === options.expectedHeadSha + ) { + return cached; + } + const transport = createPullRequestTransport(repoRoot); + const [github, metadata] = await Promise.all([ + loadGitHubHistory(), + readPullRequestMetadata(repoRoot, pullRequest, transport), + ]); + const baseSha = metadata.base?.sha; + const headSha = metadata.head?.sha; + if (!baseSha || !headSha) { + throw new Error('GitHub did not return complete pull request range coordinates.'); + } + if (options.expectedHeadSha && headSha !== options.expectedHeadSha) { + throw new Error('The pull request head changed. Refresh before loading exact file contents.'); + } + const project = { + host: 'github.com', + project: `${pullRequest.owner}/${pullRequest.repo}`, + provider: /** @type {'github'} */ ('github'), + }; + const artifactSource = github.createGitHubArtifactSource({ + project, + pull: pullRequest, + transport, + }); + const { range } = await artifactSource.readStackAndRange( + { + requestedBaseSha: /** @type {GitSha} */ (baseSha), + headSha: /** @type {GitSha} */ (headSha), + }, + new AbortController().signal, ); - return pages.flat(); + const snapshot = { headSha, metadata, range }; + rememberPullRequestHydrationSnapshot(repoRoot, pullRequest, snapshot); + return snapshot; }; /** @param {string} path */ @@ -422,14 +428,6 @@ const readGitHubImageFile = async (repoRoot, repository, ref, path) => { return bufferToImageRevision(path, buffer); }; -/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ -const readPullRequestDiff = async (repoRoot, pullRequest) => - ghApi(repoRoot, [ - '-H', - 'Accept: application/vnd.github.v3.diff', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, - ]); - /** @param {unknown} side */ const fromGitHubReviewSide = (side) => (side === 'LEFT' ? 'deletions' : 'additions'); /** @param {unknown} side */ @@ -467,6 +465,7 @@ const normalizeGitHubReviewComment = (comment) => { side, ...(hasRange ? { startLineNumber } : {}), ...(hasRange && startSide != null && startSide !== side ? { startSide } : {}), + threadId: String(comment.in_reply_to_id || comment.id), submittedAt: comment.created_at, url: comment.html_url, }; @@ -654,56 +653,6 @@ const listPullRequestHistory = async (launchPath, source, limit = 200) => { }; }; -/** @param {string} diff @returns {Map} */ -const splitPullRequestDiff = (diff) => { - const chunks = diff - .split(/(?=^diff --git )/m) - .map((chunk) => chunk.trimEnd()) - .filter((chunk) => chunk.startsWith('diff --git ')); - const map = new Map(); - - for (const chunk of chunks) { - const newPath = chunk.match(/^\+\+\+\s+b\/(.+)$/m)?.[1]; - const oldPath = chunk.match(/^---\s+a\/(.+)$/m)?.[1]; - const renamePath = chunk.match(/^rename to (.+)$/m)?.[1]; - const path = newPath && newPath !== '/dev/null' ? newPath : renamePath || oldPath; - if (path) { - map.set(path, `${chunk}\n`); - } - } - - return map; -}; - -/** @param {string} path */ -const quotePatchPath = (path) => path.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); - -/** @param {GitHubPullRequestFile} file */ -const createPatchFromPullRequestFile = (file) => { - if (!file.patch) { - return ''; - } - - const oldPath = file.previous_filename || file.filename; - const header = [ - `diff --git a/${quotePatchPath(oldPath)} b/${quotePatchPath(file.filename)}`, - file.status === 'added' ? '--- /dev/null' : `--- a/${quotePatchPath(oldPath)}`, - file.status === 'removed' ? '+++ /dev/null' : `+++ b/${quotePatchPath(file.filename)}`, - ]; - - return `${header.join('\n')}\n${file.patch}\n`; -}; - -/** @param {string} status @returns {GitFileStatus} */ -const normalizePullRequestFileStatus = (status) => - status === 'added' - ? 'added' - : status === 'removed' - ? 'deleted' - : status === 'renamed' - ? 'renamed' - : 'modified'; - /** @param {PullRequestReference} pullRequest @param {GitHubPullRequestMetadata} metadata @returns {Extract} */ const createPullRequestSource = (pullRequest, metadata) => ({ ...(metadata.user?.login @@ -723,6 +672,7 @@ const createPullRequestSource = (pullRequest, metadata) => ({ projectPath: `${pullRequest.owner}/${pullRequest.repo}`, provider: 'github', repo: pullRequest.repo, + ...(metadata.base?.ref ? { targetBranch: metadata.base.ref } : {}), title: metadata.title, type: 'pull-request', url: pullRequest.url, @@ -741,10 +691,17 @@ const createPullRequestSource = (pullRequest, metadata) => ({ * @param {string} repoRoot * @param {PullRequestReference} pullRequest * @param {GitHubPullRequestMetadata} metadata + * @param {string} expectedBaseSha * @param {GitHubRemote} [selectedRemote] * @returns {Promise<{base: string; head: string} | null>} */ -const resolvePullRequestContentRefs = async (repoRoot, pullRequest, metadata, selectedRemote) => { +const resolvePullRequestContentRefs = async ( + repoRoot, + pullRequest, + metadata, + expectedBaseSha, + selectedRemote, +) => { if (!metadata.base?.ref) { return null; } @@ -780,163 +737,211 @@ const resolvePullRequestContentRefs = async (repoRoot, pullRequest, metadata, se } } - const mergeBase = (await gitOrEmpty(repoRoot, ['merge-base', baseRef, headRef])).trim(); - return mergeBase ? { base: mergeBase, head: headRef } : null; + const resolvedHead = ( + await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', headRef]) + ).trim(); + const resolvedBase = ( + await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', baseRef]) + ).trim(); + if ( + !resolvedHead || + !resolvedBase || + (headSha != null && resolvedHead !== headSha) || + (baseSha != null && resolvedBase !== baseSha) + ) { + return null; + } + + const resolvedEffectiveBase = ( + await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', `${expectedBaseSha}^{commit}`]) + ).trim(); + return resolvedEffectiveBase === expectedBaseSha + ? { base: expectedBaseSha, head: headRef } + : null; }; /** - * git/GitHub emit `Binary files a/x and b/x differ` as a diff metadata line. - * Anchor to the start of a line so the same text appearing inside a patch's - * added/removed/context lines (which are prefixed with `+`/`-`/space) does not - * misclassify a text file as binary. + * Hydrate eligible files from one immutable PR range. Ref resolution and Git + * object reads are shared across the complete file set. + * + * @param {string} repoRoot + * @param {PullRequestReference} pullRequest + * @param {GitHubPullRequestMetadata} metadata + * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range + * @param {ReadonlyArray} files + * @param {{force?: boolean}} [options] */ -const BINARY_DIFF_MARKER = /^Binary files .* differ/m; +const hydratePullRequestSections = async ( + repoRoot, + pullRequest, + metadata, + range, + files, + options = {}, +) => { + const candidates = files.filter( + (file) => canHydrateArtifactFile(file) && !isBinaryDiffPatch(file.patch || ''), + ); + const refs = await resolvePullRequestContentRefs( + repoRoot, + pullRequest, + metadata, + range.baseSha, + ).catch(() => null); + if (!refs) { + return candidates.map((file) => ({ + path: file.path, + section: createPullRequestSection(pullRequest, file, undefined, undefined, { + base: range.baseSha, + contentAttempted: true, + contentError: + 'Codiff could not resolve the immutable pull request range. Retry exact content loading.', + head: range.headSha, + }), + })); + } + + const oldPaths = candidates.map((file) => file.oldPath || file.path); + const newPaths = candidates.map((file) => file.path); + const [oldFiles, newFiles] = await Promise.all([ + readGitFiles(repoRoot, refs.base, oldPaths, { + force: options.force, + refScopedEmptyCacheKey: true, + }), + readGitFiles(repoRoot, refs.head, newPaths, { + force: options.force, + refScopedEmptyCacheKey: true, + }), + ]); + return candidates.map((file) => { + const oldPath = file.oldPath || file.path; + return { + path: file.path, + section: createPullRequestSection( + pullRequest, + file, + oldFiles.get(oldPath), + newFiles.get(file.path), + { base: range.baseSha, contentAttempted: true, head: range.headSha }, + ), + }; + }); +}; /** - * Build a diff section for a pull request file. When the full base and head - * contents are available the section carries `oldFile`/`newFile` so Codiff - * renders a recomputed diff with expandable unmodified context (matching commits - * and the working tree). Otherwise it falls back to the GitHub patch. + * Hydrate one explicitly requested file. This force path remains available for + * large-file retries after normal bulk hydration has completed. * + * @param {string} repoRoot * @param {PullRequestReference} pullRequest - * @param {GitHubPullRequestFile} file - * @param {string} patch - * @param {import('./common.cjs').FileContentResult} [oldFile] - * @param {import('./common.cjs').FileContentResult} [newFile] - * @returns {import('../../core/types.ts').DiffSection} + * @param {GitHubPullRequestMetadata} metadata + * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range + * @param {ArtifactFile} file + * @param {{force?: boolean}} [options] */ -const createPullRequestSection = (pullRequest, file, patch, oldFile, newFile) => { - const id = `${file.filename}:pull-request:${pullRequest.number}`; - const patchBinary = !patch || BINARY_DIFF_MARKER.test(patch); - // Expandable context can only be rendered when both sides' contents are present. - const attemptedContent = oldFile != null && newFile != null; - - if (attemptedContent && !patchBinary) { - const summary = summarizeContent(oldFile, newFile); - const status = normalizePullRequestFileStatus(file.status); - const oldContents = oldFile.file?.contents ?? ''; - const newContents = newFile.file?.contents ?? ''; - // A modification that reads empty on both sides means the content failed to - // load; keep the patch instead of rendering it as an empty (no-op) diff. - const contentMissing = - (status === 'modified' || status === 'renamed') && oldContents === '' && newContents === ''; - - if (!summary.binary && summary.loadState === 'ready' && !contentMissing) { - return { - binary: false, - id, - kind: 'pull-request', - loadState: 'ready', - newFile: newFile.file, - oldFile: oldFile.file, - patch, - }; - } +const hydratePullRequestSection = async ( + repoRoot, + pullRequest, + metadata, + range, + file, + options = {}, +) => { + const [result] = await hydratePullRequestSections( + repoRoot, + pullRequest, + metadata, + range, + [file], + options, + ); + return ( + result?.section ?? + createPullRequestSection(pullRequest, file, undefined, undefined, { + base: range.baseSha, + head: range.headSha, + }) + ); +}; + +/** @param {string} launchPath @param {Extract} source */ +const readPullRequestSectionsContent = async (launchPath, source) => { + const pullRequest = parseGitHubPullRequestUrl(source.url); + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const { metadata, range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { + expectedHeadSha: source.headSha, + }); + const key = `${repoRoot}:${pullRequest.url}:${range.headSha}`; + const existing = pullRequestBulkHydrations.get(key); + if (existing) { + return existing; } - // Pull request contents are loaded up front, so a file that falls back to its - // patch (binary, oversized, or refs unavailable) cannot be loaded on demand. - return { - binary: patchBinary, - id, - kind: 'pull-request', - loadState: patchBinary ? 'binary' : 'ready', - patch, - summary: createSummary( - patchBinary ? 'Binary file changed.' : 'Showing the pull request patch for this file.', - { canLoad: false }, - ), - }; + const hydration = hydratePullRequestSections(repoRoot, pullRequest, metadata, range, range.files) + .then((sections) => ({ headSha: range.headSha, sections })) + .catch((error) => { + pullRequestBulkHydrations.delete(key); + throw error; + }); + pullRequestBulkHydrations.set(key, hydration); + while (pullRequestBulkHydrations.size > MAX_PULL_REQUEST_BULK_HYDRATIONS) { + pullRequestBulkHydrations.delete(pullRequestBulkHydrations.keys().next().value); + } + return hydration; }; /** @param {string} launchPath @param {Extract} source @returns {Promise} */ const readPullRequestState = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const pullRequest = parseGitHubPullRequestUrl(source.url); - - const [metadata, apiFiles, diff, reviewComments] = await Promise.all([ - readPullRequestMetadata(repoRoot, pullRequest), - readPullRequestFiles(repoRoot, pullRequest), - readPullRequestDiff(repoRoot, pullRequest), - readPullRequestComments(repoRoot, pullRequest), - ]); - const remote = await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - const diffByPath = splitPullRequestDiff(diff); - // Load every file's base and head contents up front from the local refs, so - // each diff renders in its final collapsed layout immediately and never shifts - // as expandable context becomes available. Files larger than the eager limit - // stay patch-only. - const contentRefs = await resolvePullRequestContentRefs( - repoRoot, - pullRequest, - metadata, - remote, - ).catch(() => null); - const reviewFiles = [...apiFiles].map((file) => { - const patch = diffByPath.get(file.filename) || createPatchFromPullRequestFile(file); - return { - file, - oldPath: file.previous_filename || file.filename, - patch, - }; + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const { metadata, range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { + forceRefresh: true, }); - const contentFiles = reviewFiles.filter(({ patch }) => !BINARY_DIFF_MARKER.test(patch)); - const [oldFiles, newFiles] = contentRefs - ? await Promise.all([ - readGitFiles( - repoRoot, - contentRefs.base, - contentFiles.map(({ oldPath }) => oldPath), - { refScopedEmptyCacheKey: true }, - ), - readGitFiles( - repoRoot, - contentRefs.head, - contentFiles.map(({ file }) => file.filename), - { refScopedEmptyCacheKey: true }, - ), - ]) - : [new Map(), new Map()]; - - /** @type {Array} */ - const files = reviewFiles - .map(({ file, oldPath, patch }) => { - const oldFile = contentRefs && !BINARY_DIFF_MARKER.test(patch) ? oldFiles.get(oldPath) : null; - const newFile = - contentRefs && !BINARY_DIFF_MARKER.test(patch) ? newFiles.get(file.filename) : null; - const section = createPullRequestSection(pullRequest, file, patch, oldFile, newFile); - - return { - fingerprint: getFingerprint( - [ - metadata.head?.sha || '', - file.status, - file.previous_filename || '', - file.filename, - section.loadState || 'ready', - section.oldFile?.cacheKey || '', - section.newFile?.cacheKey || '', - patch, - ].join('\n'), - ), - oldPath: file.previous_filename, - path: file.filename, - sections: [section], - status: normalizePullRequestFileStatus(file.status), - }; - }) - .sort((left, right) => left.path.localeCompare(right.path)); - + const files = rangeArtifactToPullRequestFiles(range, pullRequest.number, { + deferContents: true, + }).toSorted((left, right) => left.path.localeCompare(right.path)); return { files, generatedAt: Date.now(), launchPath, - reviewComments, + reviewCommentsLoadState: 'not-loaded', root: repoRoot, source: createPullRequestSource(pullRequest, metadata), }; }; +/** @param {string} launchPath @param {Extract} source */ +const readPullRequestReviewComments = async (launchPath, source) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const pullRequest = parseGitHubPullRequestUrl(source.url); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + return readPullRequestComments(repoRoot, pullRequest); +}; + +/** + * Load exact local contents for one pull-request file when explicitly retried. + * @param {string} launchPath + * @param {Extract} source + * @param {string} requestedPath + * @param {{force?: boolean}} [options] + */ +const readPullRequestSectionContent = async (launchPath, source, requestedPath, options = {}) => { + const path = validateRepositoryPath(requestedPath); + const pullRequest = parseGitHubPullRequestUrl(source.url); + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const { metadata, range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { + expectedHeadSha: source.headSha, + }); + const file = range.files.find((candidate) => candidate.path === path); + if (!file) { + throw new Error('File is not part of this pull request.'); + } + return hydratePullRequestSection(repoRoot, pullRequest, metadata, range, file, options); +}; + /** * @param {string} launchPath * @param {Extract} source @@ -948,28 +953,19 @@ const readPullRequestImageContent = async (launchPath, source, requestedPath) => const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const path = validateRepositoryPath(requestedPath); const pullRequest = parseGitHubPullRequestUrl(source.url); - - const [metadata, files] = await Promise.all([ - readPullRequestMetadata(repoRoot, pullRequest), - readPullRequestFiles(repoRoot, pullRequest), - ]); - await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - const file = files.find((candidate) => candidate.filename === path); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const { metadata, range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { + expectedHeadSha: source.headSha, + }); + const file = range.files.find((candidate) => candidate.path === path); if (!file) { throw new Error('File is not part of this pull request.'); } const headImageSource = getPullRequestHeadImageSource(pullRequest, metadata); const [oldImage, newImage] = await Promise.all([ - metadata.base?.sha - ? readGitHubImageFile( - repoRoot, - pullRequest, - metadata.base.sha, - file.previous_filename || file.filename, - ) - : undefined, - readGitHubImageFile(repoRoot, headImageSource, headImageSource.ref, file.filename), + readGitHubImageFile(repoRoot, pullRequest, range.baseSha, file.oldPath || file.path), + readGitHubImageFile(repoRoot, headImageSource, headImageSource.ref, file.path), ]); if (!oldImage && !newImage) { @@ -1040,10 +1036,16 @@ const submitPullRequestComment = async (launchPath, request) => { const pullRequest = parseGitHubPullRequestUrl(request.source.url); const metadata = await readPullRequestMetadata(repoRoot, pullRequest); await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - const payload = { - ...normalizePullRequestComment(request.comment), - commit_id: metadata.head?.sha, - }; + const replyTo = request.comment.threadId ? Number(request.comment.threadId) : null; + if (request.comment.threadId && (!Number.isInteger(replyTo) || replyTo <= 0)) { + throw new Error('GitHub review replies require a numeric provider thread ID.'); + } + const payload = replyTo + ? { body: request.comment.body, in_reply_to: replyTo } + : { + ...normalizePullRequestComment(request.comment), + commit_id: metadata.head?.sha, + }; const rawComment = await ghApi( repoRoot, @@ -1112,14 +1114,10 @@ const createPullRequestReviewPayload = (request) => { }; module.exports = { - GH_NOT_FOUND_CODE, PENDING_REVIEW_COMMENT_ERROR, collectResolvedReviewCommentIds, - createPatchFromPullRequestFile, createPullRequestHistoryFetchRefspecs, - createPullRequestSection, createPullRequestSource, - getGhCommand, getPullRequestHeadImageSource, listPullRequestHistory, normalizeGitHubCommit, @@ -1128,6 +1126,9 @@ module.exports = { normalizePullRequestComment, parseGitHubPullRequestUrl, readPullRequestImageContent, + readPullRequestReviewComments, + readPullRequestSectionContent, + readPullRequestSectionsContent, readPullRequestState, resolvePullRequestContentRefs, selectPullRequestRemote, diff --git a/electron/git-state/review-range-sections.cjs b/electron/git-state/review-range-sections.cjs new file mode 100644 index 00000000..f576a629 --- /dev/null +++ b/electron/git-state/review-range-sections.cjs @@ -0,0 +1,280 @@ +// @ts-check + +const { createSummary, getFingerprint, summarizeContent } = require('./common.cjs'); + +/** + * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile + * @typedef {import('../../core/lib/review-artifacts.ts').ArtifactFile} ArtifactFile + */ + +/** + * git and forge APIs emit `Binary files a/x and b/x differ` as diff metadata. + * Anchor to the start of a line so the same text inside an added, removed, or + * context line does not misclassify a textual patch as binary. + * + * @param {string} patch + */ +const isBinaryDiffPatch = (patch) => /^Binary files .* differ/m.test(patch); + +/** + * A provider may omit a textual patch while still supplying immutable blob + * identities. Keep those files loadable; an absent patch alone is not proof + * that the file is binary. + * + * @param {ArtifactFile} file + */ +const canHydrateArtifactFile = (file) => + Boolean(file.patch || file.oldObjectId || file.newObjectId); + +/** + * A bounded provider response can leave a Range Artifact with incomplete file + * coverage. Keep that distinct from a complete range: the renderer needs one + * visible warning rather than a file tree that falsely suggests every change + * is present. + * + * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} artifact + * @param {number} number + * @returns {ChangedFile} + */ +const createCoverageWarningFile = (artifact, number) => { + const partiallyAvailable = artifact.files.length > 0; + const warningKind = partiallyAvailable ? 'incomplete' : 'unavailable'; + const warningLabel = partiallyAvailable ? 'Review diff incomplete' : 'Review diff unavailable'; + const existingPaths = new Set(artifact.files.map((file) => file.path)); + let warningPath = warningLabel; + let suffix = 2; + while (existingPaths.has(warningPath)) { + warningPath = `${warningLabel} (${suffix})`; + suffix += 1; + } + return { + fingerprint: getFingerprint( + `${artifact.baseSha}:${artifact.headSha}:review-range-${warningKind}:${artifact.incompleteReason ?? ''}`, + ), + path: warningPath, + sections: [ + { + binary: false, + id: `review-range-${warningKind}:${number}`, + kind: 'pull-request', + loadState: 'error', + patch: '', + // Deliberately omit range coordinates: this is evidence about the + // whole review, not a provider file that can accept a comment target. + summary: createSummary( + artifact.incompleteReason ?? + (partiallyAvailable + ? 'Codiff could not load the complete review diff, so some changed files may be missing.' + : 'Codiff could not load a complete review diff, so changed files are unavailable.'), + { canLoad: false }, + ), + }, + ], + status: 'modified', + }; +}; + +/** + * Project a provider-normalized Range Artifact into the review renderer shape + * without rebuilding provider diff semantics in Electron. + * + * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} artifact + * @param {number} number + * @param {{deferContents?: boolean}} [options] + * @returns {ReadonlyArray} + */ +const rangeArtifactToPullRequestFiles = (artifact, number, options = {}) => { + const files = artifact.files.map((file, index) => { + const patch = file.patch || ''; + const binary = isBinaryDiffPatch(patch); + const patchUnavailable = !patch && !canHydrateArtifactFile(file); + const deferContents = + options.deferContents === true && !patch && canHydrateArtifactFile(file) && !binary; + return { + fingerprint: getFingerprint( + `${artifact.baseSha}:${artifact.headSha}:${index}:${file.path}:${file.status}:${patch}`, + ), + ...(file.oldPath ? { oldPath: file.oldPath } : {}), + path: file.path, + sections: [ + { + binary, + id: `${file.path}:pull-request:${number}`, + kind: 'pull-request', + ...(file.lineCount ? { lineCount: file.lineCount } : {}), + loadState: patchUnavailable + ? 'error' + : binary + ? 'binary' + : deferContents + ? 'deferred' + : 'ready', + patch, + range: { + base: { + label: { kind: 'commit', text: artifact.baseSha.slice(0, 7) }, + sha: artifact.baseSha, + }, + head: { + label: { kind: 'commit', text: artifact.headSha.slice(0, 7) }, + sha: artifact.headSha, + }, + }, + summary: createSummary( + patchUnavailable + ? 'The provider did not return a patch or immutable content identity for this file.' + : binary + ? 'Binary file changed.' + : deferContents && !patch + ? 'The provider omitted the textual patch; exact file contents load on demand.' + : deferContents + ? 'Showing the provider patch while exact file contents load on demand.' + : 'Showing the provider patch for this file.', + { canLoad: !binary && canHydrateArtifactFile(file) }, + ), + }, + ], + status: file.status, + }; + }); + return artifact.coverage === 'complete' + ? files + : [...files, createCoverageWarningFile(artifact, number)]; +}; + +/** + * Build a pull-request section from one canonical ArtifactFile. When both + * contents are present, Codiff can render an expandable recomputed diff; + * otherwise it retains the provider patch. + * + * @param {{number: number}} pullRequest + * @param {ArtifactFile} file + * @param {import('./common.cjs').FileContentResult} [oldFile] + * @param {import('./common.cjs').FileContentResult} [newFile] + * @param {{base?: string, contentAttempted?: boolean, contentError?: string, deferContents?: boolean, head?: string}} [rangeRefs] + * @returns {import('../../core/types.ts').DiffSection} + */ +const createPullRequestSection = (pullRequest, file, oldFile, newFile, rangeRefs = {}) => { + const patch = file.patch || ''; + const id = `${file.path}:pull-request:${pullRequest.number}`; + const patchBinary = isBinaryDiffPatch(patch); + const contentLoadable = canHydrateArtifactFile(file); + const patchUnavailable = !patch && !contentLoadable; + const range = + rangeRefs.base && rangeRefs.head + ? { + base: { + sha: rangeRefs.base, + label: { kind: 'commit', text: rangeRefs.base.slice(0, 7) }, + }, + head: { + sha: rangeRefs.head, + label: { kind: 'commit', text: rangeRefs.head.slice(0, 7) }, + }, + } + : undefined; + const attemptedContent = oldFile != null && newFile != null; + const contentAttempted = rangeRefs.contentAttempted === true || attemptedContent; + + if (rangeRefs.deferContents && contentLoadable && !patchBinary) { + return { + binary: false, + id, + kind: 'pull-request', + loadState: 'deferred', + patch, + ...(range ? { range } : {}), + summary: createSummary( + patch + ? 'Showing the provider patch while exact file contents load on demand.' + : 'The provider omitted the textual patch; exact file contents load on demand.', + { canLoad: true }, + ), + }; + } + + if (rangeRefs.contentError && contentLoadable && !patchBinary) { + return { + binary: false, + id, + kind: 'pull-request', + loadState: 'error', + patch, + ...(range ? { range } : {}), + summary: createSummary(rangeRefs.contentError, { canLoad: true }), + }; + } + + if (attemptedContent && !patchBinary) { + const summary = summarizeContent(oldFile, newFile); + const oldContents = oldFile.file?.contents ?? ''; + const newContents = newFile.file?.contents ?? ''; + // A modification that reads empty on both sides means the content failed to + // load; keep the patch instead of rendering it as an empty (no-op) diff. + const contentMissing = + (file.status === 'modified' || file.status === 'renamed') && + oldContents === '' && + newContents === ''; + + if (summary.loadState === 'ready' && !contentMissing) { + return { + binary: false, + id, + kind: 'pull-request', + loadState: 'ready', + newFile: newFile.file, + oldFile: oldFile.file, + patch, + ...(range ? { range } : {}), + }; + } + + if (summary.loadState !== 'ready') { + return { + ...summary, + id, + kind: 'pull-request', + patch, + ...(range ? { range } : {}), + }; + } + } + + const retryOmittedPatch = !patch && contentLoadable && !patchBinary && !contentAttempted; + const loadState = patchUnavailable + ? 'error' + : patchBinary + ? 'binary' + : retryOmittedPatch + ? 'deferred' + : !patch && contentAttempted + ? 'error' + : 'ready'; + return { + binary: patchBinary, + id, + kind: 'pull-request', + loadState, + patch, + ...(range ? { range } : {}), + summary: createSummary( + patchUnavailable + ? 'The provider did not return a patch or immutable content identity for this file.' + : patchBinary + ? 'Binary file changed.' + : retryOmittedPatch + ? 'Exact contents are not available yet; retry loading this provider-omitted patch.' + : !patch && contentAttempted + ? 'The provider omitted the patch and exact file contents could not be loaded.' + : 'Showing the pull request patch for this file.', + { canLoad: retryOmittedPatch }, + ), + }; +}; + +module.exports = { + canHydrateArtifactFile, + createPullRequestSection, + isBinaryDiffPatch, + rangeArtifactToPullRequestFiles, +}; diff --git a/electron/github-history-bridge.cjs b/electron/github-history-bridge.cjs new file mode 100644 index 00000000..8493a8e1 --- /dev/null +++ b/electron/github-history-bridge.cjs @@ -0,0 +1,23 @@ +// @ts-check + +/** + * CJS bridge to @nkzw/codiff-github (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadGitHubHistory = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../github/dist/index.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +module.exports = { + loadGitHubHistory, +}; diff --git a/electron/gitlab-history-bridge.cjs b/electron/gitlab-history-bridge.cjs new file mode 100644 index 00000000..2e193f0a --- /dev/null +++ b/electron/gitlab-history-bridge.cjs @@ -0,0 +1,23 @@ +// @ts-check + +/** + * CJS bridge to @nkzw/codiff-gitlab (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadGitLabHistory = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../gitlab/dist/index.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +module.exports = { + loadGitLabHistory, +}; diff --git a/electron/main.cjs b/electron/main.cjs index 2edab12d..270ab67e 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -20,8 +20,10 @@ const { listRepositoryHistory, readDiffImageContent, readDiffSectionContent, + readDiffSectionsContent, readGitIdentity, readRepositoryState, + readReviewComments, readWalkthroughRepositoryState, submitPullRequestComment, submitPullRequestReview, @@ -1803,6 +1805,11 @@ ipcMain.handle('codiff:getDiffSectionContent', async (event, request) => { }); }); +ipcMain.handle('codiff:getDiffSectionsContent', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return readDiffSectionsContent(repositoryPath, request); +}); + ipcMain.handle('codiff:getDiffImageContent', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); return readDiffImageContent(repositoryPath, request); @@ -1813,6 +1820,14 @@ ipcMain.handle('codiff:getRepositoryHistory', async (event, limit, source) => { return listRepositoryHistory(repositoryPath, limit, source); }); +ipcMain.handle('codiff:getReviewComments', async (event, source) => { + if (source?.type !== 'pull-request') { + throw new Error('Review comments require a pull-request source.'); + } + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return readReviewComments(repositoryPath, source); +}); + ipcMain.handle('codiff:getGitIdentity', async (event) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); return readGitIdentity(repositoryPath); diff --git a/electron/preload.cjs b/electron/preload.cjs index 43eacf6d..02031f2c 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -26,6 +26,7 @@ const codiff = { getConfig: () => ipcRenderer.invoke('codiff:getConfig'), decreaseCodeFontSize: () => ipcRenderer.invoke('codiff:decreaseCodeFontSize'), getDiffSectionContent: (request) => ipcRenderer.invoke('codiff:getDiffSectionContent', request), + getDiffSectionsContent: (request) => ipcRenderer.invoke('codiff:getDiffSectionsContent', request), getFeatureFlags: () => ipcRenderer.invoke('codiff:getFeatureFlags'), getDiffImageContent: (request) => ipcRenderer.invoke('codiff:getDiffImageContent', request), getGitIdentity: () => ipcRenderer.invoke('codiff:getGitIdentity'), @@ -37,6 +38,7 @@ const codiff = { getRepositoryHistory: (limit, source) => ipcRenderer.invoke('codiff:getRepositoryHistory', limit, source), getRepositoryState: (source) => ipcRenderer.invoke('codiff:getRepositoryState', source), + getReviewComments: (source) => ipcRenderer.invoke('codiff:getReviewComments', source), getTerminalHelperStatus: () => ipcRenderer.invoke('codiff:getTerminalHelperStatus'), getUpdateStatus: () => ipcRenderer.invoke('codiff:getUpdateStatus'), getNarrativeWalkthrough: (source, options) => diff --git a/forge.config.cjs b/forge.config.cjs index 0b6cbc53..3a17ff88 100644 --- a/forge.config.cjs +++ b/forge.config.cjs @@ -1,7 +1,7 @@ // @ts-check /* eslint-disable @typescript-eslint/no-require-imports, no-undef */ -const { copyFile, mkdir } = require('node:fs/promises'); +const { cp, mkdir } = require('node:fs/promises'); const { existsSync } = require('node:fs'); const { dirname, join } = require('node:path'); @@ -16,7 +16,11 @@ const macAssetCatalogPath = existsSync(join(__dirname, 'electron/icons/Assets.ca : undefined; const linuxIconPath = './electron/icons/icon.png'; const windowsIconPath = './electron/icons/icon.ico'; -const walkthroughDiffRuntimePath = 'core/lib/narrative-walkthrough-diff.cjs'; +const runtimeCopies = [ + ['core/lib/narrative-walkthrough-diff.cjs', 'core/lib/narrative-walkthrough-diff.cjs'], + ['github/dist', 'github/dist'], + ['gitlab/dist', 'gitlab/dist'], +]; const skipSquirrel = process.env.CODIFF_SKIP_SQUIRREL === '1'; const osxNotarize = process.env.APPLE_ID && process.env.APPLE_PASSWORD && process.env.APPLE_TEAM_ID @@ -53,11 +57,12 @@ const osxNotarize = module.exports = { hooks: { packageAfterCopy: async (_forgeConfig, buildPath) => { - const source = join(__dirname, walkthroughDiffRuntimePath); - const destination = join(buildPath, walkthroughDiffRuntimePath); - - await mkdir(dirname(destination), { recursive: true }); - await copyFile(source, destination); + for (const [sourcePath, destinationPath] of runtimeCopies) { + const source = join(__dirname, sourcePath); + const destination = join(buildPath, destinationPath); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { recursive: true }); + } }, prePackage: async (forgeConfig, platform) => { if (platform !== 'darwin' || !macAssetCatalogPath) { @@ -131,6 +136,8 @@ module.exports = { /^\/docs(?:$|\/)/, /^\/examples(?:$|\/)/, /^\/forge\.config\.cjs$/, + /^\/github(?:$|\/)/, + /^\/gitlab(?:$|\/)/, /^\/index\.html$/, /^\/out(?:$|\/)/, /^\/pnpm-workspace\.yaml$/, diff --git a/github/__tests__/current-review.test.ts b/github/__tests__/current-review.test.ts new file mode 100644 index 00000000..25d9fb31 --- /dev/null +++ b/github/__tests__/current-review.test.ts @@ -0,0 +1,359 @@ +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + createReviewArtifactRun, + type ReviewArtifactProject, +} from '@nkzw/codiff-core'; +import type { GitSha } from '@nkzw/codiff-core/types'; +import { expect, test } from 'vite-plus/test'; +import { createFakeGitHubTransport } from '../../test/fake-provider-transports.ts'; +import { createGitHubArtifactSource, createGitHubRangeArtifact } from '../src/current-review.ts'; + +const gitSha = (value: string) => value as GitSha; +const project: ReviewArtifactProject = { + host: 'github.com', + project: 'nkzw-tech/codiff', + provider: 'github', +}; +const pull = { number: 12, owner: 'nkzw-tech', repo: 'codiff' }; + +test('normalizes GitHub patches into immutable Range Artifacts', () => { + const range = createGitHubRangeArtifact({ + baseSha: gitSha('a'.repeat(40)), + files: [ + { + additions: 7, + deletions: 3, + filename: 'src/app.ts', + patch: '@@ -1 +1 @@\n-old\n+new\n', + status: 'modified', + }, + ], + headSha: gitSha('b'.repeat(40)), + project, + }); + + expect(range).toMatchObject({ + coverage: 'complete', + files: [ + { + coverage: 'complete', + lineCount: { additions: 7, deletions: 3 }, + patch: + 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + }, + ], + }); +}); + +test('preserves exact zero GitHub counts when a patch is omitted', () => { + const range = createGitHubRangeArtifact({ + baseSha: gitSha('a'.repeat(40)), + files: [ + { + additions: 0, + deletions: 0, + filename: 'src/generated.txt', + sha: 'c'.repeat(40), + status: 'modified', + }, + ], + headSha: gitSha('b'.repeat(40)), + project, + }); + + expect(range.files[0]).toMatchObject({ + lineCount: { additions: 0, deletions: 0 }, + path: 'src/generated.txt', + }); +}); + +test('one GitHub source populates stack, range, commit, and blob caches', async () => { + const baseSha = gitSha('a'.repeat(40)); + const headSha = gitSha('b'.repeat(40)); + const objectId = 'c'.repeat(40); + const file = { + filename: 'src/app.ts', + patch: '@@ -1 +1 @@\n-old\n+new\n', + sha: objectId, + status: 'modified', + }; + const commit = { + commit: { + author: { date: '2026-01-01T00:00:00.000Z', name: 'Ada' }, + message: 'Update app', + }, + files: [file], + parents: [{ sha: baseSha }], + sha: headSha, + }; + const comparePath = `/repos/nkzw-tech/codiff/compare/${baseSha}...${headSha}`; + const commitPath = `/repos/nkzw-tech/codiff/commits/${headSha}`; + const blobPath = `/repos/nkzw-tech/codiff/git/blobs/${objectId}`; + const transport = createFakeGitHubTransport([ + { + path: comparePath, + response: { + commits: [commit], + files: [file], + merge_base_commit: { sha: baseSha }, + total_commits: 1, + }, + }, + { path: commitPath, response: commit }, + { bytes: new Uint8Array([0, 1, 255]), path: blobPath, response: null }, + ]); + const run = createReviewArtifactRun(createGitHubArtifactSource({ project, pull, transport })); + + const firstRange = await run.readStackAndRange( + { headSha: headSha, requestedBaseSha: baseSha }, + run.signal, + ); + expect( + await run.readStackAndRange({ headSha: headSha, requestedBaseSha: baseSha }, run.signal), + ).toBe(firstRange); + const artifacts = await run.readCommitArtifacts( + [{ commitSha: headSha, parentSha: baseSha }], + run.signal, + ); + await run.readCommitArtifacts([{ commitSha: headSha, parentSha: baseSha }], run.signal); + const blobs = await run.readBlobs([objectId], run.signal); + await run.readBlobs([objectId], run.signal); + + expect(firstRange.stack.commits.map((entry) => entry.sha)).toEqual([headSha]); + expect(firstRange.range.files).toHaveLength(1); + expect( + artifacts.get(createCommitArtifactRequestKey({ commitSha: headSha, parentSha: baseSha })), + ).toMatchObject({ + parentSha: baseSha, + provenance: { kind: 'github-api', project }, + }); + expect(blobs.get(objectId)?.bytes).toEqual(new Uint8Array([0, 1, 255])); + expect(transport.calls.filter((call) => call.path === comparePath)).toHaveLength(1); + expect(transport.calls.filter((call) => call.path === commitPath)).toHaveLength(1); + expect(transport.calls.filter((call) => call.path === blobPath)).toHaveLength(1); + expect(run.diagnostics().sourceCalls).toEqual({ blobs: 1, commits: 1, stackAndRanges: 1 }); +}); + +test('uses GitHub merge_base_commit as the effective artifact base', async () => { + const requestedBaseSha = gitSha('a'.repeat(40)); + const effectiveBaseSha = gitSha('b'.repeat(40)); + const headSha = gitSha('c'.repeat(40)); + const transport = createFakeGitHubTransport([ + { + path: `/repos/nkzw-tech/codiff/compare/${requestedBaseSha}...${headSha}`, + response: { + commits: [ + { + commit: { + author: { date: '2026-01-01T00:00:00.000Z', name: 'Ada' }, + message: 'Update app', + }, + parents: [{ sha: effectiveBaseSha }], + sha: headSha, + }, + ], + files: [], + merge_base_commit: { sha: effectiveBaseSha }, + total_commits: 1, + }, + }, + ]); + const run = createReviewArtifactRun(createGitHubArtifactSource({ project, pull, transport })); + + const result = await run.readStackAndRange({ headSha, requestedBaseSha }, run.signal); + + expect(result.range).toMatchObject({ baseSha: effectiveBaseSha, headSha }); + expect(result.stack).toMatchObject({ baseSha: effectiveBaseSha, headSha }); + expect(run.diagnostics().acquired.stackAndRanges).toEqual({ + [`${requestedBaseSha}:${headSha}`]: 1, + }); +}); + +test('resolves GitHub ref paths as bounded Blob Artifacts', async () => { + const ref = gitSha('a'.repeat(40)); + const objectId = gitSha('b'.repeat(40)); + const request = { maxBytes: 32, path: 'images/logo.png', ref }; + const path = '/repos/nkzw-tech/codiff/contents/images/logo.png'; + const transport = createFakeGitHubTransport([ + { + path, + query: { ref }, + response: { + content: btoa(String.fromCharCode(0, 1, 255)), + encoding: 'base64', + sha: objectId, + }, + }, + ]); + const run = createReviewArtifactRun(createGitHubArtifactSource({ project, pull, transport })); + + const first = await run.readFileBlobs([request], run.signal); + const warm = await run.readFileBlobs([request], run.signal); + + expect(first.get(createFileBlobArtifactRequestKey(request))).toMatchObject({ + bytes: new Uint8Array([0, 1, 255]), + objectId, + provenance: { kind: 'github-api', project }, + }); + expect(warm).toEqual(first); + expect(transport.calls).toHaveLength(1); +}); + +test('caps current GitHub commit stacks at forty', async () => { + const baseSha = gitSha('f'.repeat(40)); + const commits = Array.from({ length: 41 }, (_, index) => { + const sha = gitSha(index.toString(16).padStart(40, '0')); + const parent = index === 0 ? baseSha : gitSha((index - 1).toString(16).padStart(40, '0')); + return { + commit: { + author: { date: new Date(index * 1000).toISOString(), name: 'Ada' }, + message: `Commit ${index}`, + }, + parents: [{ sha: parent }], + sha, + }; + }); + const headSha = commits.at(-1)!.sha; + const transport = createFakeGitHubTransport([ + { + path: `/repos/nkzw-tech/codiff/compare/${baseSha}...${headSha}`, + response: { + commits, + files: [], + merge_base_commit: { sha: baseSha }, + total_commits: commits.length, + }, + }, + ]); + + const result = await createGitHubArtifactSource({ project, pull, transport }).readStackAndRange( + { headSha: headSha, requestedBaseSha: baseSha }, + new AbortController().signal, + ); + + expect(result.stack.commits).toHaveLength(40); + expect(result.stack.commits[0]?.sha).toBe(commits[1]?.sha); + expect(result.stack.coverage).toBe('truncated'); +}); + +test('retains commits 81 through 120 from a paginated GitHub comparison', async () => { + const baseSha = gitSha('f'.repeat(40)); + const commits = Array.from({ length: 120 }, (_, index) => { + const sha = gitSha((index + 1).toString(16).padStart(40, '0')); + const parent = index === 0 ? baseSha : gitSha(index.toString(16).padStart(40, '0')); + return { + commit: { + author: { date: new Date(index * 1000).toISOString(), name: 'Ada' }, + message: `Commit ${index + 1}`, + }, + parents: [{ sha: parent }], + sha, + }; + }); + const headSha = commits.at(-1)!.sha; + const comparePath = `/repos/nkzw-tech/codiff/compare/${baseSha}...${headSha}`; + const transport = createFakeGitHubTransport([ + { + path: comparePath, + response: ({ query }: { query?: Readonly> }) => ({ + commits: query?.page === 2 ? commits.slice(100) : commits.slice(0, 100), + files: [], + merge_base_commit: { sha: baseSha }, + total_commits: commits.length, + }), + }, + ]); + + const result = await createGitHubArtifactSource({ project, pull, transport }).readStackAndRange( + { headSha: headSha, requestedBaseSha: baseSha }, + new AbortController().signal, + ); + + expect(result.stack.commits.map((commit) => commit.sha)).toEqual( + commits.slice(80).map((commit) => commit.sha), + ); + expect(transport.calls.map((call) => call.query?.page)).toEqual([1, 2]); + expect(result.stack.coverage).toBe('truncated'); +}); + +test('bounds GitHub Commit Artifact reads at eight concurrent requests', async () => { + const shas = Array.from({ length: 10 }, (_, index) => gitSha(index.toString().padStart(40, '0'))); + let active = 0; + let peak = 0; + const transport = createFakeGitHubTransport( + shas.map((sha, index) => ({ + path: `/repos/nkzw-tech/codiff/commits/${sha}`, + response: async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return { + files: [ + { + filename: `src/${index}.ts`, + patch: '@@ -1 +1 @@\n-old\n+new\n', + status: 'modified', + }, + ], + parents: [], + sha, + }; + }, + })), + ); + + const artifacts = await createGitHubArtifactSource({ + project, + pull, + transport, + }).readCommitArtifacts( + shas.map((commitSha) => ({ commitSha, parentSha: null })), + new AbortController().signal, + ); + + expect(artifacts).toHaveLength(10); + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThanOrEqual(8); +}); + +test('preserves GitHub merge artifacts for two selected parents without calling mismatches complete', async () => { + const firstParent = gitSha('a'.repeat(40)); + const secondParent = gitSha('b'.repeat(40)); + const mergeSha = gitSha('c'.repeat(40)); + const path = `/repos/nkzw-tech/codiff/commits/${mergeSha}`; + const transport = createFakeGitHubTransport([ + { + path, + response: { + files: [ + { + filename: 'src/merge.ts', + patch: '@@ -1 +1 @@\n-old\n+new\n', + status: 'modified', + }, + ], + parents: [{ sha: firstParent }, { sha: secondParent }], + sha: mergeSha, + }, + }, + ]); + const requests = [ + { commitSha: mergeSha, parentSha: firstParent }, + { commitSha: mergeSha, parentSha: secondParent }, + ]; + + const artifacts = await createGitHubArtifactSource({ + project, + pull, + transport, + }).readCommitArtifacts(requests, new AbortController().signal); + + expect(artifacts).toHaveLength(2); + expect(artifacts.get(createCommitArtifactRequestKey(requests[0]!))?.coverage).toBe('complete'); + expect(artifacts.get(createCommitArtifactRequestKey(requests[1]!))?.coverage).not.toBe( + 'complete', + ); + expect(transport.calls.filter((call) => call.path === path)).toHaveLength(2); +}); diff --git a/github/__tests__/package.test.ts b/github/__tests__/package.test.ts new file mode 100644 index 00000000..c6896065 --- /dev/null +++ b/github/__tests__/package.test.ts @@ -0,0 +1,33 @@ +import { execFileSync } from 'node:child_process'; +import { expect, test } from 'vite-plus/test'; +import corePackageJson from '../../core/package.json' with { type: 'json' }; +import gitlabPackageJson from '../../gitlab/package.json' with { type: 'json' }; + +const packedFiles = (directory: string) => { + const output = execFileSync( + 'pnpm', + ['--dir', directory, '--config.ignore-scripts=true', 'pack', '--dry-run', '--json'], + { encoding: 'utf8' }, + ); + const parsed = JSON.parse(output); + const entry = Array.isArray(parsed) ? parsed[0] : parsed; + return new Set(entry.files.map((file: { path: string }) => file.path)); +}; + +const sourceTargets = (manifest: { exports: Record> }) => + Object.values(manifest.exports).flatMap((entry) => { + const target = entry['@nkzw/codiff-source']; + return target ? [target.replace(/^\.\//, '')] : []; + }); + +test('packed provider packages contain every advertised source-condition target', () => { + const coreFiles = packedFiles('core'); + const gitlabFiles = packedFiles('gitlab'); + + for (const target of sourceTargets(corePackageJson)) { + expect(coreFiles.has(target), `Core package is missing ${target}`).toBe(true); + } + for (const target of sourceTargets(gitlabPackageJson)) { + expect(gitlabFiles.has(target), `GitLab package is missing ${target}`).toBe(true); + } +}); diff --git a/github/package.json b/github/package.json new file mode 100644 index 00000000..e3df56a0 --- /dev/null +++ b/github/package.json @@ -0,0 +1,36 @@ +{ + "name": "@nkzw/codiff-github", + "version": "0.1.0", + "description": "Host-injected GitHub transport for Codiff.", + "license": "MIT", + "author": { + "name": "Christoph Nakazawa", + "email": "christoph.pojer@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/nkzw-tech/codiff.git", + "directory": "github" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "@nkzw/codiff-source": "./src/index.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "rm -rf dist && vp pack -d dist --target=node24 src/index.ts && vp exec tsc -p tsconfig.build.json", + "typecheck": "vp exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "@nkzw/codiff-core": "workspace:*" + } +} diff --git a/github/src/current-review.ts b/github/src/current-review.ts new file mode 100644 index 00000000..58c829ac --- /dev/null +++ b/github/src/current-review.ts @@ -0,0 +1,554 @@ +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + orderReviewCommitStack, + validateCommitArtifact, + validateRangeArtifact, + validateStackSnapshot, + type ArtifactCoverage, + type ArtifactFile, + type BlobArtifact, + type CommitArtifact, + type CommitArtifactRequest, + type CommitArtifactRequestKey, + type FileBlobArtifactRequest, + type RangeArtifact, + type ReviewArtifactProject, + type ReviewArtifactSource, + type StackSnapshot, +} from '@nkzw/codiff-core'; +import type { GitSha, ReviewCommitSummary } from '@nkzw/codiff-core/types'; +import type { GitHubTransport } from './transport.ts'; + +export type GitHubPullRequestRef = { + /** Optional known head from the local source; provider metadata wins when available. */ + headSha?: GitSha | null; + number: number; + owner: string; + repo: string; + updatedAt?: string | null; +}; + +const artifactReadConcurrency = 8; +const currentCommitStackLimit = 40; +const maxArtifactResponseBytes = 8 * 1024 * 1024; +const maxBlobArtifactBytes = 8 * 1024 * 1024; + +const gitSha = (value: unknown): GitSha | null => + typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value) ? (value as GitSha) : null; +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const asArray = (value: unknown): ReadonlyArray => (Array.isArray(value) ? value : []); +const asString = (value: unknown) => (typeof value === 'string' ? value : ''); +const asNumber = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const asLineCount = (additions: unknown, deletions: unknown) => { + const added = asNumber(additions); + const deleted = asNumber(deletions); + return added != null && + Number.isInteger(added) && + added >= 0 && + deleted != null && + Number.isInteger(deleted) && + deleted >= 0 + ? { additions: added, deletions: deleted } + : null; +}; + +const countPatchLines = (patch: string) => { + let additions = 0; + let deletions = 0; + for (const line of patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + deletions += 1; + } + } + return { additions, deletions }; +}; + +const normalizeBlobArtifactMaxBytes = (value: number | undefined) => { + const maxBytes = value ?? maxBlobArtifactBytes; + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new RangeError('Blob Artifact byte limit must be a finite non-negative number.'); + } + return Math.floor(maxBytes); +}; + +const githubRepositoryPath = ({ owner, repo }: GitHubPullRequestRef) => + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + +const githubComparePath = (pull: GitHubPullRequestRef, base: GitSha, head: GitSha) => + `${githubRepositoryPath(pull)}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`; + +const githubCommitPath = (pull: GitHubPullRequestRef, sha: GitSha) => + `${githubRepositoryPath(pull)}/commits/${encodeURIComponent(sha)}`; + +const githubBlobPath = (pull: GitHubPullRequestRef, objectId: string) => + `${githubRepositoryPath(pull)}/git/blobs/${encodeURIComponent(objectId)}`; + +const githubContentsPath = (pull: GitHubPullRequestRef, path: string) => + `${githubRepositoryPath(pull)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`; + +const decodeBase64File = (content: string, maxBytes: number) => { + const normalized = content.replaceAll(/\s/g, ''); + if (normalized.length > Math.ceil(maxBytes / 3) * 4 + 4) { + return null; + } + try { + const decoded = atob(normalized); + if (decoded.length > maxBytes) { + return null; + } + const bytes = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index += 1) { + bytes[index] = decoded.charCodeAt(index); + } + return bytes; + } catch { + return null; + } +}; + +const quotePatchPath = (path: string) => + path.replaceAll('\\', String.raw`\\`).replaceAll('\n', String.raw`\n`); + +const createGitHubPatch = ({ + oldPath, + patchBody, + path, + status, +}: { + oldPath: string; + patchBody: string; + path: string; + status: ArtifactFile['status']; +}) => { + if (!patchBody.trim()) { + return ''; + } + if (patchBody.startsWith('diff --git ')) { + return patchBody.endsWith('\n') ? patchBody : `${patchBody}\n`; + } + const header = [ + `diff --git a/${quotePatchPath(oldPath)} b/${quotePatchPath(path)}`, + status === 'added' ? '--- /dev/null' : `--- a/${quotePatchPath(oldPath)}`, + status === 'deleted' ? '+++ /dev/null' : `+++ b/${quotePatchPath(path)}`, + ]; + return `${header.join('\n')}\n${patchBody}${patchBody.endsWith('\n') ? '' : '\n'}`; +}; + +const normalizeGitHubArtifactFile = (value: unknown): ArtifactFile | null => { + if (!isRecord(value)) { + return null; + } + const path = asString(value.filename); + if (!path) { + return null; + } + const providerStatus = asString(value.status); + const status = + providerStatus === 'added' + ? 'added' + : providerStatus === 'removed' + ? 'deleted' + : providerStatus === 'renamed' + ? 'renamed' + : 'modified'; + const oldPath = asString(value.previous_filename); + const patchBody = asString(value.patch); + const lineCount = + asLineCount(value.additions, value.deletions) ?? + (patchBody.trim() ? countPatchLines(patchBody) : null); + const objectId = asString(value.sha) || undefined; + const oldObjectId = status === 'deleted' ? objectId : undefined; + const newObjectId = status === 'deleted' ? undefined : objectId; + const coverage: ArtifactCoverage = + value.truncated === true + ? 'truncated' + : patchBody.trim() || oldObjectId || newObjectId + ? 'complete' + : 'opaque'; + return { + coverage, + ...(lineCount ? { lineCount } : {}), + ...(newObjectId ? { newObjectId } : {}), + ...(oldObjectId ? { oldObjectId } : {}), + ...(oldPath && oldPath !== path ? { oldPath } : {}), + ...(patchBody.trim() + ? { patch: createGitHubPatch({ oldPath: oldPath || path, patchBody, path, status }) } + : {}), + path, + status, + }; +}; + +const artifactCoverage = ( + files: ReadonlyArray, + expectedCount: number, + truncated = false, +): ArtifactCoverage => + truncated || files.length !== expectedCount || files.some((file) => file.coverage === 'truncated') + ? 'truncated' + : files.some((file) => file.coverage === 'opaque') + ? 'opaque' + : 'complete'; + +/** Normalize an acquired GitHub file response into the shared Range Artifact contract. */ +export const createGitHubRangeArtifact = ({ + baseSha, + files: values, + headSha, + incompleteReason, + project, + truncated = false, +}: { + baseSha: GitSha; + files: ReadonlyArray; + headSha: GitSha; + incompleteReason?: string; + project: ReviewArtifactProject; + truncated?: boolean; +}): RangeArtifact => { + const files = values + .map(normalizeGitHubArtifactFile) + .filter((file): file is ArtifactFile => file != null); + return validateRangeArtifact({ + baseSha, + coverage: artifactCoverage(files, values.length, truncated || incompleteReason != null), + files, + headSha, + ...(incompleteReason != null ? { incompleteReason } : {}), + provenance: { kind: 'github-api', project }, + }); +}; + +const normalizeGitHubArtifactCommit = (value: unknown): ReviewCommitSummary | null => { + if (!isRecord(value)) { + return null; + } + const sha = gitSha(value.sha); + if (!sha) { + return null; + } + const commit = isRecord(value.commit) ? value.commit : {}; + const author = isRecord(commit.author) ? commit.author : {}; + const topLevelAuthor = isRecord(value.author) ? value.author : {}; + const message = asString(commit.message); + const webUrl = asString(value.html_url); + return { + authoredAt: asString(author.date) || new Date(0).toISOString(), + authorName: asString(author.name) || asString(topLevelAuthor.login), + parentShas: asArray(value.parents) + .map((parent) => (isRecord(parent) ? gitSha(parent.sha) : null)) + .filter((parent): parent is GitSha => parent != null), + sha, + shortSha: sha.slice(0, 7), + subject: message.split('\n')[0] || sha.slice(0, 7), + ...(webUrl ? { webUrl } : {}), + }; +}; + +const readGitHubCommitArtifact = async ({ + commit, + project, + pull, + signal, + transport, +}: { + commit: CommitArtifactRequest; + project: ReviewArtifactProject; + pull: GitHubPullRequestRef; + signal: AbortSignal; + transport: GitHubTransport; +}) => { + const rawFiles: Array = []; + let parentMismatch = false; + let truncated = false; + for (let page = 1; page <= 30; page += 1) { + signal.throwIfAborted(); + const value = await transport.request({ + maxBytes: maxArtifactResponseBytes, + path: githubCommitPath(pull, commit.commitSha), + query: { page, per_page: 100 }, + signal, + }); + if (!isRecord(value)) { + truncated = true; + break; + } + if (page === 1) { + const firstParent = asArray(value.parents) + .map((parent) => (isRecord(parent) ? gitSha(parent.sha) : null)) + .find((parent): parent is GitSha => parent != null); + parentMismatch = (firstParent ?? null) !== commit.parentSha; + } + const pageFiles = asArray(value.files); + rawFiles.push(...pageFiles); + if (pageFiles.length < 100) { + break; + } + if (page === 30) { + truncated = true; + } + } + const files = rawFiles + .map(normalizeGitHubArtifactFile) + .filter((file): file is ArtifactFile => file != null); + return validateCommitArtifact({ + commitSha: commit.commitSha, + coverage: artifactCoverage(files, rawFiles.length, truncated || parentMismatch), + files, + parentSha: commit.parentSha, + provenance: { kind: 'github-api' as const, project }, + }); +}; + +/** Resolve one immutable GitHub ref+path coordinate to a normalized Blob Artifact. */ +export const readGitHubFileBlobArtifact = async ({ + maxBytes, + path, + project, + pull, + ref, + signal, + transport, +}: FileBlobArtifactRequest & { + maxBytes: number; + project: ReviewArtifactProject; + pull: GitHubPullRequestRef; + signal?: AbortSignal; + transport: GitHubTransport; +}): Promise => { + const limit = normalizeBlobArtifactMaxBytes(maxBytes); + signal?.throwIfAborted(); + const value = await transport.request({ + maxBytes: Math.ceil(limit / 3) * 4 + 64 * 1024, + path: githubContentsPath(pull, path), + query: { ref }, + signal, + }); + signal?.throwIfAborted(); + if (!isRecord(value)) { + return null; + } + const objectId = gitSha(value.sha); + if (!objectId) { + return null; + } + const content = asString(value.content); + const bytes = + asString(value.encoding).toLowerCase() === 'base64' && content + ? decodeBase64File(content, limit) + : null; + if (bytes) { + return { bytes, objectId, provenance: { kind: 'github-api', project } }; + } + if (!transport.requestBuffer) { + return null; + } + const raw = await transport.requestBuffer({ + accept: 'application/vnd.github.raw+json', + maxBytes: limit, + path: githubBlobPath(pull, objectId), + signal, + }); + signal?.throwIfAborted(); + return raw.byteLength <= limit + ? { bytes: raw, objectId, provenance: { kind: 'github-api', project } } + : null; +}; + +/** Create the bounded GitHub API source for one current pull request. */ +export const createGitHubArtifactSource = ({ + maxBlobArtifactBytes: requestedMaxBlobArtifactBytes, + project, + pull, + transport, +}: { + maxBlobArtifactBytes?: number; + project: ReviewArtifactProject; + pull: GitHubPullRequestRef; + transport: GitHubTransport; +}): ReviewArtifactSource => { + const blobArtifactMaxBytes = normalizeBlobArtifactMaxBytes(requestedMaxBlobArtifactBytes); + const provenance = { kind: 'github-api' as const, project }; + return { + async readBlobs(objectIds, signal) { + if (!transport.requestBuffer) { + return new Map(); + } + const pending = [...new Set(objectIds)]; + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const objectId = pending[nextIndex++]!; + try { + const bytes = await transport.requestBuffer!({ + accept: 'application/vnd.github.raw+json', + maxBytes: blobArtifactMaxBytes, + path: githubBlobPath(pull, objectId), + signal, + }); + if (bytes.byteLength <= blobArtifactMaxBytes) { + blobs.set(objectId, { bytes, objectId, provenance }); + } + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return blobs; + }, + async readCommitArtifacts(commits, signal) { + const pending = [ + ...new Map( + commits.map((commit) => [createCommitArtifactRequestKey(commit), commit]), + ).values(), + ]; + const artifacts = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const commit = pending[nextIndex++]!; + try { + artifacts.set( + createCommitArtifactRequestKey(commit), + await readGitHubCommitArtifact({ commit, project, pull, signal, transport }), + ); + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return artifacts; + }, + async readFileBlobs(requests, signal) { + const pending = [ + ...new Map( + requests.map((request) => [createFileBlobArtifactRequestKey(request), request]), + ).values(), + ]; + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const request = pending[nextIndex++]!; + try { + const blob = await readGitHubFileBlobArtifact({ + ...request, + maxBytes: Math.min(request.maxBytes ?? blobArtifactMaxBytes, blobArtifactMaxBytes), + project, + pull, + signal, + transport, + }); + if (blob) { + blobs.set(createFileBlobArtifactRequestKey(request), blob); + } + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return blobs; + }, + async readStackAndRange({ headSha: head, requestedBaseSha: base }, signal) { + if (base === head) { + return { + range: validateRangeArtifact({ + baseSha: base, + coverage: 'complete', + files: [], + headSha: head, + provenance, + }), + stack: validateStackSnapshot({ + baseSha: base, + commits: [], + coverage: 'complete', + headSha: head, + provenance, + }), + }; + } + const path = githubComparePath(pull, base, head); + const first = await transport.request({ + maxBytes: maxArtifactResponseBytes, + path, + query: { page: 1, per_page: 100 }, + signal, + }); + if (!isRecord(first)) { + throw new Error('GitHub returned an invalid repository comparison.'); + } + const mergeBaseCommit = isRecord(first.merge_base_commit) ? first.merge_base_commit : {}; + const effectiveBaseSha = gitSha(mergeBaseCommit.sha); + if (!effectiveBaseSha) { + throw new Error('GitHub returned an invalid merge base for the repository comparison.'); + } + const totalCommits = asNumber(first.total_commits); + let rawCommits = [...asArray(first.commits)]; + let stackTruncated = totalCommits == null || totalCommits !== rawCommits.length; + if (totalCommits != null && totalCommits > rawCommits.length) { + const firstTailPage = + Math.floor(Math.max(0, totalCommits - currentCommitStackLimit) / 100) + 1; + const lastPage = Math.ceil(totalCommits / 100); + rawCommits = firstTailPage === 1 ? rawCommits : []; + for (let page = Math.max(2, firstTailPage); page <= lastPage; page += 1) { + signal.throwIfAborted(); + const tailPage = await transport.request({ + maxBytes: maxArtifactResponseBytes, + path, + query: { page, per_page: 100 }, + signal, + }); + if (!isRecord(tailPage)) { + throw new Error(`GitHub returned an invalid comparison page ${page}.`); + } + rawCommits.push(...asArray(tailPage.commits)); + } + } + if (rawCommits.length > currentCommitStackLimit) { + rawCommits = rawCommits.slice(-currentCommitStackLimit); + stackTruncated = true; + } + const commits = orderReviewCommitStack( + rawCommits + .map(normalizeGitHubArtifactCommit) + .filter((commit): commit is ReviewCommitSummary => commit != null), + ); + const rawFiles = asArray(first.files); + const range = createGitHubRangeArtifact({ + baseSha: effectiveBaseSha, + files: rawFiles, + headSha: head, + project, + truncated: rawFiles.length >= 300, + }); + const stack: StackSnapshot = { + baseSha: effectiveBaseSha, + commits, + coverage: stackTruncated || commits.length !== rawCommits.length ? 'truncated' : 'complete', + headSha: head, + provenance, + }; + return { range, stack: validateStackSnapshot(stack) }; + }, + }; +}; diff --git a/github/src/index.ts b/github/src/index.ts new file mode 100644 index 00000000..64a4a735 --- /dev/null +++ b/github/src/index.ts @@ -0,0 +1,2 @@ +export * from './current-review.ts'; +export * from './transport.ts'; diff --git a/github/src/transport.ts b/github/src/transport.ts new file mode 100644 index 00000000..b6e074ef --- /dev/null +++ b/github/src/transport.ts @@ -0,0 +1,48 @@ +/** + * Host-injected GitHub transport. + * + * The host authenticates and executes HTTP (gh api / fetch). This package owns + * endpoint construction, pagination policy, and response parsing. + */ +export type GitHubTransport = { + graphql?(request: { + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + query: string; + signal?: AbortSignal; + variables: Readonly>; + }): Promise; + request(request: { + accept?: string; + body?: unknown; + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + /** When true, hosts should follow pagination and return a combined array. */ + paginate?: boolean; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; + /** Optional binary response reader for images and other raw assets. */ + requestBuffer?(request: { + accept?: string; + /** Maximum raw response bytes the host may retain. */ + maxBytes?: number; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; + /** Optional raw text reader. */ + requestText?(request: { + accept?: string; + body?: unknown; + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + paginate?: boolean; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; +}; diff --git a/github/tsconfig.build.json b/github/tsconfig.build.json new file mode 100644 index 00000000..bddab4a7 --- /dev/null +++ b/github/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "incremental": false, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/github/vite.config.ts b/github/vite.config.ts new file mode 100644 index 00000000..e965e0af --- /dev/null +++ b/github/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { + copy: [], + deps: { + alwaysBundle: ['@nkzw/codiff-core'], + dts: { neverBundle: ['@nkzw/codiff-core'] }, + }, + dts: false, + }, +}); diff --git a/gitlab/__tests__/current-review.test.ts b/gitlab/__tests__/current-review.test.ts new file mode 100644 index 00000000..5374aacf --- /dev/null +++ b/gitlab/__tests__/current-review.test.ts @@ -0,0 +1,295 @@ +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + createReviewArtifactRun, + type ReviewArtifactProject, +} from '@nkzw/codiff-core'; +import type { GitSha } from '@nkzw/codiff-core/types'; +import { expect, test } from 'vite-plus/test'; +import { createFakeGitLabTransport } from '../../test/fake-provider-transports.ts'; +import { + createGitLabArtifactSource, + createGitLabRangeArtifact, + fetchGitLabCommitArtifacts, +} from '../src/current-review.ts'; + +const gitSha = (value: string) => value as GitSha; +const project: ReviewArtifactProject = { + host: 'gitlab.example.com', + project: 'group/project', + provider: 'gitlab', +}; + +test('normalizes GitLab patches into immutable Range Artifacts', () => { + const range = createGitLabRangeArtifact({ + baseSha: gitSha('a'.repeat(40)), + diffs: [ + { + diff: '@@ -1 +1,2 @@\n-old\n+new\n+second\n', + new_path: 'src/app.ts', + old_path: 'src/app.ts', + }, + ], + headSha: gitSha('b'.repeat(40)), + project, + }); + + expect(range).toMatchObject({ + coverage: 'complete', + files: [ + { + coverage: 'complete', + lineCount: { additions: 2, deletions: 1 }, + patch: + 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1,2 @@\n-old\n+new\n+second\n', + }, + ], + }); +}); + +test('one GitLab source populates stack, range, commit, and blob caches', async () => { + const baseSha = gitSha('a'.repeat(40)); + const headSha = gitSha('b'.repeat(40)); + const objectId = 'c'.repeat(40); + const diff = { + diff: '@@ -1 +1 @@\n-old\n+new\n', + new_path: 'src/app.ts', + old_path: 'src/app.ts', + }; + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/group%2Fproject/repository/compare', + query: { from: baseSha, straight: 'true', to: headSha }, + response: { + commits: [ + { + authored_date: '2026-01-01T00:00:00.000Z', + id: headSha, + parent_ids: [baseSha], + title: 'Update app', + }, + ], + diffs: [diff], + }, + }, + { + bytes: new Uint8Array([0, 1, 255]), + path: `/api/v4/projects/group%2Fproject/repository/blobs/${objectId}/raw`, + response: null, + }, + ]); + const run = createReviewArtifactRun( + createGitLabArtifactSource({ project, projectPath: 'group/project', transport }), + ); + + const firstRange = await run.readStackAndRange( + { headSha: headSha, requestedBaseSha: baseSha }, + run.signal, + ); + expect( + await run.readStackAndRange({ headSha: headSha, requestedBaseSha: baseSha }, run.signal), + ).toBe(firstRange); + const artifacts = await run.readCommitArtifacts( + [{ commitSha: headSha, parentSha: baseSha }], + run.signal, + ); + await run.readCommitArtifacts([{ commitSha: headSha, parentSha: baseSha }], run.signal); + const blobs = await run.readBlobs([objectId], run.signal); + await run.readBlobs([objectId], run.signal); + + expect(firstRange.stack.commits.map((entry) => entry.sha)).toEqual([headSha]); + expect(firstRange.range.files).toHaveLength(1); + expect( + artifacts.get(createCommitArtifactRequestKey({ commitSha: headSha, parentSha: baseSha })), + ).toMatchObject({ + parentSha: baseSha, + provenance: { kind: 'gitlab-api', project }, + }); + expect(blobs.get(objectId)?.bytes).toEqual(new Uint8Array([0, 1, 255])); + expect(run.diagnostics().sourceCalls).toEqual({ blobs: 1, commits: 1, stackAndRanges: 1 }); +}); + +test('resolves GitLab ref paths as bounded Blob Artifacts', async () => { + const ref = gitSha('a'.repeat(40)); + const objectId = gitSha('b'.repeat(40)); + const request = { maxBytes: 32, path: 'images/logo.png', ref }; + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/group%2Fproject/repository/files/images%2Flogo.png', + query: { ref }, + response: { + blob_id: objectId, + content: btoa(String.fromCharCode(0, 1, 255)), + encoding: 'base64', + }, + }, + ]); + const run = createReviewArtifactRun( + createGitLabArtifactSource({ project, projectPath: 'group/project', transport }), + ); + + const first = await run.readFileBlobs([request], run.signal); + const warm = await run.readFileBlobs([request], run.signal); + + expect(first.get(createFileBlobArtifactRequestKey(request))).toMatchObject({ + bytes: new Uint8Array([0, 1, 255]), + objectId, + provenance: { kind: 'gitlab-api', project }, + }); + expect(warm).toEqual(first); + expect(transport.calls).toHaveLength(1); +}); + +test('bounds GitLab Commit Artifact reads at eight concurrent requests', async () => { + const shas = Array.from({ length: 10 }, (_, index) => gitSha(index.toString().padStart(40, '0'))); + let active = 0; + let peak = 0; + const transport = createFakeGitLabTransport( + shas.map((sha, index) => ({ + path: `/api/v4/projects/group%2Fproject/repository/commits/${sha}/diff`, + response: async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return [ + { + diff: '@@ -1 +1 @@\n-old\n+new\n', + new_path: `src/${index}.ts`, + old_path: `src/${index}.ts`, + }, + ]; + }, + })), + ); + + const artifacts = await fetchGitLabCommitArtifacts({ + commits: shas.map((commitSha) => ({ commitSha, parentSha: null })), + project, + projectPath: 'group/project', + transport, + }); + + expect(artifacts).toHaveLength(10); + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThanOrEqual(8); +}); + +test('preserves one GitLab merge commit relative to two selected parents', async () => { + const firstParent = gitSha('a'.repeat(40)); + const secondParent = gitSha('b'.repeat(40)); + const mergeSha = gitSha('c'.repeat(40)); + const comparePath = '/api/v4/projects/group%2Fproject/repository/compare'; + const transport = createFakeGitLabTransport([ + { + path: comparePath, + query: { from: firstParent, straight: 'true', to: mergeSha }, + response: { + diffs: [ + { + diff: '@@ -1 +1 @@\n-old\n+first\n', + new_path: 'src/first.ts', + old_path: 'src/first.ts', + }, + ], + }, + }, + { + path: comparePath, + query: { from: secondParent, straight: 'true', to: mergeSha }, + response: { + diffs: [ + { + diff: '@@ -1 +1 @@\n-old\n+second\n', + new_path: 'src/second.ts', + old_path: 'src/second.ts', + }, + ], + }, + }, + ]); + const requests = [ + { commitSha: mergeSha, parentSha: firstParent }, + { commitSha: mergeSha, parentSha: secondParent }, + ]; + + const artifacts = await fetchGitLabCommitArtifacts({ + commits: requests, + project, + projectPath: 'group/project', + transport, + }); + + expect(artifacts).toHaveLength(2); + expect(artifacts.get(createCommitArtifactRequestKey(requests[0]!))?.files[0]?.path).toBe( + 'src/first.ts', + ); + expect(artifacts.get(createCommitArtifactRequestKey(requests[1]!))?.files[0]?.path).toBe( + 'src/second.ts', + ); + expect(transport.calls.map((call) => call.query?.from)).toEqual([firstParent, secondParent]); +}); + +test.each(['compare_timeout', 'overflow'] as const)( + 'does not mark GitLab %s comparison evidence complete', + async (flag) => { + const parentSha = gitSha('a'.repeat(40)); + const commitSha = gitSha('b'.repeat(40)); + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/group%2Fproject/repository/compare', + query: { from: parentSha, straight: 'true', to: commitSha }, + response: { + diffs: [ + { + diff: '@@ -1 +1 @@\n-old\n+new\n', + new_path: 'src/app.ts', + old_path: 'src/app.ts', + }, + ], + [flag]: true, + }, + }, + ]); + const request = { commitSha, parentSha }; + + const artifacts = await fetchGitLabCommitArtifacts({ + commits: [request], + project, + projectPath: 'group/project', + transport, + }); + + expect(artifacts.get(createCommitArtifactRequestKey(request))?.coverage).toBe('truncated'); + }, +); + +test('uses the GitLab commit-diff endpoint only for root commits', async () => { + const commitSha = gitSha('d'.repeat(40)); + const path = `/api/v4/projects/group%2Fproject/repository/commits/${commitSha}/diff`; + const transport = createFakeGitLabTransport([ + { + path, + response: [ + { + diff: '@@ -1 +1 @@\n-old\n+root\n', + new_path: 'src/root.ts', + old_path: 'src/root.ts', + }, + ], + }, + ]); + const request = { commitSha, parentSha: null }; + + const artifacts = await fetchGitLabCommitArtifacts({ + commits: [request], + project, + projectPath: 'group/project', + transport, + }); + + expect(artifacts.get(createCommitArtifactRequestKey(request))?.files[0]?.path).toBe( + 'src/root.ts', + ); + expect(transport.calls[0]?.path).toBe(path); +}); diff --git a/gitlab/__tests__/transport.test.ts b/gitlab/__tests__/transport.test.ts new file mode 100644 index 00000000..09547c13 --- /dev/null +++ b/gitlab/__tests__/transport.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from 'vite-plus/test'; +import { createFakeGitLabTransport } from '../../test/fake-provider-transports.ts'; + +test('routes host-injected requests by method, path, and normalized query', async () => { + const transport = createFakeGitLabTransport([ + { + method: 'POST', + path: '/api/v4/projects/example/merge_requests', + query: { page: 2, state: 'opened' }, + response: { id: 7 }, + }, + ]); + + await expect( + transport.request({ + method: 'POST', + path: '/api/v4/projects/example/merge_requests', + query: { page: 2, state: 'opened' }, + }), + ).resolves.toEqual({ id: 7 }); + expect(transport.calls).toEqual([ + { + method: 'POST', + path: '/api/v4/projects/example/merge_requests', + query: { page: 2, state: 'opened' }, + }, + ]); +}); + +test('collects paginated responses until GitLab returns a short page', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => index); + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/example/repository/commits', + query: { page: 1, per_page: 100, ref_name: 'main' }, + response: firstPage, + }, + { + path: '/api/v4/projects/example/repository/commits', + query: { page: 2, per_page: 100, ref_name: 'main' }, + response: [100], + }, + ]); + + await expect( + transport.requestPages?.({ + path: '/api/v4/projects/example/repository/commits', + query: { ref_name: 'main' }, + }), + ).resolves.toEqual([...firstPage, 100]); + expect(transport.calls).toHaveLength(2); +}); + +test('reads raw repository text without exposing host authentication', async () => { + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/example/repository/files/readme/raw', + query: { ref: 'main' }, + response: null, + text: '# Example', + }, + ]); + + await expect( + transport.requestText?.({ + path: '/api/v4/projects/example/repository/files/readme/raw', + query: { ref: 'main' }, + }), + ).resolves.toBe('# Example'); +}); + +test('reads immutable repository blob bytes without text coercion', async () => { + const bytes = new Uint8Array([0, 1, 2, 255]); + const transport = createFakeGitLabTransport([ + { + bytes, + path: '/api/v4/projects/example/repository/blobs/deadbeef/raw', + response: null, + }, + ]); + + await expect( + transport.requestBuffer?.({ + path: '/api/v4/projects/example/repository/blobs/deadbeef/raw', + }), + ).resolves.toEqual(bytes); +}); + +test('cancels in-flight host requests through the shared transport signal', async () => { + const controller = new AbortController(); + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/example/repository/commits/slow/diff', + response: () => new Promise((resolve) => setTimeout(() => resolve([]), 1000)), + }, + ]); + const request = transport.request({ + path: '/api/v4/projects/example/repository/commits/slow/diff', + signal: controller.signal, + }); + controller.abort(new DOMException('Superseded', 'AbortError')); + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); +}); diff --git a/gitlab/package.json b/gitlab/package.json new file mode 100644 index 00000000..629650e1 --- /dev/null +++ b/gitlab/package.json @@ -0,0 +1,36 @@ +{ + "name": "@nkzw/codiff-gitlab", + "version": "0.1.0", + "description": "Host-injected GitLab transport for Codiff.", + "license": "MIT", + "author": { + "name": "Christoph Nakazawa", + "email": "christoph.pojer@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/nkzw-tech/codiff.git", + "directory": "gitlab" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "@nkzw/codiff-source": "./src/index.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "rm -rf dist && vp pack -d dist --target=node24 src/index.ts && vp exec tsc -p tsconfig.build.json", + "typecheck": "vp exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "@nkzw/codiff-core": "workspace:*" + } +} diff --git a/gitlab/src/current-review.ts b/gitlab/src/current-review.ts new file mode 100644 index 00000000..a2cef858 --- /dev/null +++ b/gitlab/src/current-review.ts @@ -0,0 +1,548 @@ +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + orderReviewCommitStack, + validateCommitArtifact, + validateRangeArtifact, + validateStackSnapshot, + type ArtifactFile, + type BlobArtifact, + type CommitArtifact, + type CommitArtifactRequest, + type CommitArtifactRequestKey, + type FileBlobArtifactRequest, + type RangeArtifact, + type ReviewArtifactProject, + type ReviewArtifactSource, + type StackSnapshot, +} from '@nkzw/codiff-core'; +import type { GitSha, ReviewCommitSummary } from '@nkzw/codiff-core/types'; +import type { GitLabTransport } from './transport.ts'; + +const artifactReadConcurrency = 8; +const maxPages = 20; +const maxArtifactResponseBytes = 8 * 1024 * 1024; +const maxBlobArtifactBytes = 8 * 1024 * 1024; + +type JsonRecord = Record; +const isRecord = (value: unknown): value is JsonRecord => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const asRecord = (value: unknown): JsonRecord => (isRecord(value) ? value : {}); +const asArray = (value: unknown): Array => (Array.isArray(value) ? value : []); +const asString = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback); +const gitShaPattern = /^(?:[\da-f]{40}|[\da-f]{64})$/i; +const asGitSha = (value: unknown): GitSha | null => { + const candidate = asString(value).trim(); + return gitShaPattern.test(candidate) ? (candidate as GitSha) : null; +}; +const trimmedString = (value: unknown) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +const normalizeBlobArtifactMaxBytes = (value: number | undefined) => { + const maxBytes = value ?? maxBlobArtifactBytes; + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new RangeError('Blob Artifact byte limit must be a finite non-negative number.'); + } + return Math.floor(maxBytes); +}; + +export const validateProjectPath = (projectPath: string) => { + const normalized = projectPath.trim().replaceAll(/^\/+|\/+$/g, ''); + if ( + !normalized || + normalized.length > 500 || + !normalized.includes('/') || + normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error('Invalid GitLab project path.'); + } + return normalized; +}; + +const repositoryCompareEndpoint = (projectPath: string, from: string, to: string) => ({ + path: `/api/v4/projects/${encodeURIComponent(projectPath)}/repository/compare`, + query: { from, straight: 'true', to }, +}); + +const repositoryCommitDiffEndpoint = (projectPath: string, sha: string) => + `/api/v4/projects/${encodeURIComponent(projectPath)}/repository/commits/${encodeURIComponent(sha)}/diff`; + +const repositoryBlobRawEndpoint = (projectPath: string, objectId: string) => + `/api/v4/projects/${encodeURIComponent(projectPath)}/repository/blobs/${encodeURIComponent(objectId)}/raw`; + +const repositoryFileEndpoint = (projectPath: string, filePath: string) => + `/api/v4/projects/${encodeURIComponent(projectPath)}/repository/files/${encodeURIComponent(filePath)}`; + +const decodeBase64File = (content: string, maxBytes: number) => { + const normalized = content.replaceAll(/\s/g, ''); + if (normalized.length > Math.ceil(maxBytes / 3) * 4 + 4) { + return null; + } + try { + const decoded = atob(normalized); + if (decoded.length > maxBytes) { + return null; + } + const bytes = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index += 1) { + bytes[index] = decoded.charCodeAt(index); + } + return bytes; + } catch { + return null; + } +}; + +const readPages = async ( + transport: GitLabTransport, + path: string, + signal?: AbortSignal, +): Promise> => { + if (transport.requestPages) { + return transport.requestPages({ maxBytes: maxArtifactResponseBytes, path, signal }); + } + const values: Array = []; + let page = 1; + while (page <= maxPages) { + signal?.throwIfAborted(); + const result = await transport.request({ + maxBytes: maxArtifactResponseBytes, + path, + query: { page, per_page: 100 }, + signal, + }); + const pageValues = asArray(result); + values.push(...pageValues); + if (pageValues.length < 100) { + break; + } + page += 1; + } + if (page > maxPages) { + throw new Error('GitLab merge request data exceeded the pagination limit.'); + } + return values; +}; + +const createPatch = (diff: JsonRecord) => { + const oldPath = asString(diff.old_path); + const newPath = asString(diff.new_path); + const body = asString(diff.diff); + const oldHeader = diff.new_file === true ? '/dev/null' : `a/${oldPath}`; + const newHeader = diff.deleted_file === true ? '/dev/null' : `b/${newPath}`; + return `diff --git a/${oldPath} b/${newPath}\n--- ${oldHeader}\n+++ ${newHeader}\n${body}${ + body.endsWith('\n') ? '' : '\n' + }`; +}; + +const countPatchLines = (patch: string) => { + let additions = 0; + let deletions = 0; + for (const line of patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + deletions += 1; + } + } + return { additions, deletions }; +}; + +const normalizeMergeRequestCommit = ( + value: unknown, + projectPath: string, +): ReviewCommitSummary | null => { + const commit = asRecord(value); + const sha = asGitSha(commit.id ?? commit.sha); + if (!sha) { + return null; + } + const title = asString(commit.title, asString(commit.message).split('\n')[0] || sha.slice(0, 8)); + const parentShas = asArray(commit.parent_ids) + .map(asGitSha) + .filter((parent): parent is GitSha => parent != null); + const authorName = + trimmedString(commit.author_name) ?? trimmedString(asRecord(commit.author).name) ?? 'Unknown'; + const authoredAt = + asString(commit.authored_date) || + asString(commit.created_at) || + asString(commit.committed_date) || + new Date(0).toISOString(); + return { + authoredAt, + authorName, + parentShas, + sha, + shortSha: asString(commit.short_id, sha.slice(0, 8)), + subject: title, + webUrl: asString(commit.web_url) || `/${projectPath}/-/commit/${encodeURIComponent(sha)}`, + }; +}; + +const normalizeCommitArtifactFile = (value: unknown): ArtifactFile | null => { + const diff = asRecord(value); + const oldPath = asString(diff.old_path); + const path = asString(diff.new_path); + if (!oldPath || !path) { + return null; + } + const patch = asString(diff.diff); + const oldMode = asString(diff.a_mode) || undefined; + const newMode = asString(diff.b_mode) || undefined; + const oldObjectId = asString(diff.old_id) || undefined; + const newObjectId = asString(diff.new_id) || undefined; + const status = diff.new_file + ? 'added' + : diff.deleted_file + ? 'deleted' + : diff.renamed_file + ? 'renamed' + : 'modified'; + const coverage = + diff.too_large === true || diff.collapsed === true + ? 'truncated' + : patch.trim() || oldObjectId || newObjectId || (oldMode && newMode && oldMode !== newMode) + ? 'complete' + : 'opaque'; + return { + coverage, + ...(patch.trim() ? { lineCount: countPatchLines(patch) } : {}), + ...(newMode ? { newMode } : {}), + ...(newObjectId ? { newObjectId } : {}), + ...(oldMode ? { oldMode } : {}), + ...(oldObjectId ? { oldObjectId } : {}), + ...(oldPath !== path ? { oldPath } : {}), + ...(patch.trim() ? { patch: createPatch(diff) } : {}), + path, + status, + }; +}; + +const toGitLabCommitArtifact = ( + commit: CommitArtifactRequest, + project: ReviewArtifactProject, + values: ReadonlyArray, + truncated = false, +): CommitArtifact => { + const files = values + .map(normalizeCommitArtifactFile) + .filter((file): file is ArtifactFile => file != null); + const coverage = + truncated || + files.length !== values.length || + files.some((file) => file.coverage === 'truncated') + ? 'truncated' + : files.some((file) => file.coverage === 'opaque') + ? 'opaque' + : 'complete'; + return validateCommitArtifact({ + commitSha: commit.commitSha, + coverage, + files, + parentSha: commit.parentSha, + provenance: { kind: 'gitlab-api', project }, + }); +}; + +/** Read immutable current-review Commit Artifacts with one bounded scheduler. */ +export const fetchGitLabCommitArtifacts = async ({ + commits, + project, + projectPath: rawProjectPath, + signal, + transport, +}: { + commits: ReadonlyArray; + project: ReviewArtifactProject; + projectPath: string; + signal?: AbortSignal; + transport: GitLabTransport; +}): Promise> => { + const projectPath = validateProjectPath(rawProjectPath); + const pending = [ + ...new Map(commits.map((commit) => [createCommitArtifactRequestKey(commit), commit])).values(), + ]; + const artifacts = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal?.throwIfAborted(); + const commit = pending[nextIndex++]!; + try { + let values: ReadonlyArray; + let truncated = false; + if (commit.parentSha) { + const endpoint = repositoryCompareEndpoint( + projectPath, + commit.parentSha, + commit.commitSha, + ); + const comparison = asRecord( + await transport.request({ + maxBytes: maxArtifactResponseBytes, + path: endpoint.path, + query: endpoint.query, + signal, + }), + ); + values = asArray(comparison.diffs); + truncated = comparison.compare_timeout === true || comparison.overflow === true; + } else { + values = await readPages( + transport, + repositoryCommitDiffEndpoint(projectPath, commit.commitSha), + signal, + ); + } + artifacts.set( + createCommitArtifactRequestKey(commit), + toGitLabCommitArtifact(commit, project, values, truncated), + ); + } catch { + signal?.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return artifacts; +}; + +const artifactCoverage = (files: ReadonlyArray, expectedCount: number) => + files.length !== expectedCount || files.some((file) => file.coverage === 'truncated') + ? 'truncated' + : files.some((file) => file.coverage === 'opaque') + ? 'opaque' + : 'complete'; + +/** Normalize an acquired GitLab diff response into the shared Range Artifact contract. */ +export const createGitLabRangeArtifact = ({ + baseSha, + diffs, + headSha, + incompleteReason, + project, + truncated = false, +}: { + baseSha: GitSha; + diffs: ReadonlyArray; + headSha: GitSha; + incompleteReason?: string; + project: ReviewArtifactProject; + truncated?: boolean; +}): RangeArtifact => { + const files = diffs + .map(normalizeCommitArtifactFile) + .filter((file): file is ArtifactFile => file != null); + return validateRangeArtifact({ + baseSha, + coverage: + truncated || incompleteReason != null ? 'truncated' : artifactCoverage(files, diffs.length), + files, + headSha, + ...(incompleteReason != null ? { incompleteReason } : {}), + provenance: { kind: 'gitlab-api', project }, + }); +}; + +/** Resolve one immutable GitLab ref+path coordinate to a normalized Blob Artifact. */ +export const readGitLabFileBlobArtifact = async ({ + maxBytes, + path, + project, + projectPath: rawProjectPath, + ref, + signal, + transport, +}: FileBlobArtifactRequest & { + maxBytes: number; + project: ReviewArtifactProject; + projectPath: string; + signal?: AbortSignal; + transport: GitLabTransport; +}): Promise => { + const projectPath = validateProjectPath(rawProjectPath); + const limit = normalizeBlobArtifactMaxBytes(maxBytes); + signal?.throwIfAborted(); + const value = asRecord( + await transport.request({ + maxBytes: Math.ceil(limit / 3) * 4 + 64 * 1024, + path: repositoryFileEndpoint(projectPath, path), + query: { ref }, + signal, + }), + ); + signal?.throwIfAborted(); + const objectId = asGitSha(value.blob_id); + if (!objectId) { + return null; + } + const content = asString(value.content); + const bytes = + asString(value.encoding).toLowerCase() === 'base64' && content + ? decodeBase64File(content, limit) + : null; + if (bytes) { + return { bytes, objectId, provenance: { kind: 'gitlab-api', project } }; + } + if (!transport.requestBuffer) { + return null; + } + const raw = await transport.requestBuffer({ + maxBytes: limit, + path: repositoryBlobRawEndpoint(projectPath, objectId), + signal, + }); + signal?.throwIfAborted(); + return raw.byteLength <= limit + ? { bytes: raw, objectId, provenance: { kind: 'gitlab-api', project } } + : null; +}; + +/** Create the bounded GitLab API source for one current merge request. */ +export const createGitLabArtifactSource = ({ + maxBlobArtifactBytes: requestedMaxBlobArtifactBytes, + project, + projectPath: rawProjectPath, + transport, +}: { + maxBlobArtifactBytes?: number; + project: ReviewArtifactProject; + projectPath: string; + transport: GitLabTransport; +}): ReviewArtifactSource => { + const blobArtifactMaxBytes = normalizeBlobArtifactMaxBytes(requestedMaxBlobArtifactBytes); + const projectPath = validateProjectPath(rawProjectPath); + const provenance = { kind: 'gitlab-api' as const, project }; + return { + async readBlobs(objectIds, signal) { + if (!transport.requestBuffer) { + return new Map(); + } + const pending = [...new Set(objectIds)]; + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const objectId = pending[nextIndex++]!; + try { + const bytes = await transport.requestBuffer!({ + maxBytes: blobArtifactMaxBytes, + path: repositoryBlobRawEndpoint(projectPath, objectId), + signal, + }); + if (bytes.byteLength <= blobArtifactMaxBytes) { + blobs.set(objectId, { bytes, objectId, provenance }); + } + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return blobs; + }, + readCommitArtifacts: (commits: ReadonlyArray, signal) => + fetchGitLabCommitArtifacts({ + commits, + project, + projectPath, + signal, + transport, + }), + async readFileBlobs(requests, signal) { + const pending = [ + ...new Map( + requests.map((request) => [createFileBlobArtifactRequestKey(request), request]), + ).values(), + ]; + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const request = pending[nextIndex++]!; + try { + const blob = await readGitLabFileBlobArtifact({ + ...request, + maxBytes: Math.min(request.maxBytes ?? blobArtifactMaxBytes, blobArtifactMaxBytes), + project, + projectPath, + signal, + transport, + }); + if (blob) { + blobs.set(createFileBlobArtifactRequestKey(request), blob); + } + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return blobs; + }, + async readStackAndRange({ headSha: head, requestedBaseSha: base }, signal) { + if (base === head) { + return { + range: validateRangeArtifact({ + baseSha: base, + coverage: 'complete', + files: [], + headSha: head, + provenance, + }), + stack: validateStackSnapshot({ + baseSha: base, + commits: [], + coverage: 'complete', + headSha: head, + provenance, + }), + }; + } + const endpoint = repositoryCompareEndpoint(projectPath, base, head); + const value = asRecord( + await transport.request({ + maxBytes: maxArtifactResponseBytes, + path: endpoint.path, + query: endpoint.query, + signal, + }), + ); + const rawCommits = asArray(value.commits); + const commits = orderReviewCommitStack( + rawCommits + .map((entry) => normalizeMergeRequestCommit(entry, projectPath)) + .filter((commit): commit is ReviewCommitSummary => commit != null), + ); + const truncated = value.compare_timeout === true || value.overflow === true; + const range = createGitLabRangeArtifact({ + baseSha: base, + diffs: asArray(value.diffs), + headSha: head, + project, + truncated, + }); + const stack: StackSnapshot = { + baseSha: base, + commits, + coverage: truncated || commits.length !== rawCommits.length ? 'truncated' : 'complete', + headSha: head, + provenance, + }; + return { range, stack: validateStackSnapshot(stack) }; + }, + }; +}; diff --git a/gitlab/src/index.ts b/gitlab/src/index.ts new file mode 100644 index 00000000..64a4a735 --- /dev/null +++ b/gitlab/src/index.ts @@ -0,0 +1,2 @@ +export * from './current-review.ts'; +export * from './transport.ts'; diff --git a/gitlab/src/transport.ts b/gitlab/src/transport.ts new file mode 100644 index 00000000..c1f66474 --- /dev/null +++ b/gitlab/src/transport.ts @@ -0,0 +1,44 @@ +/** + * Host-injected GitLab transport. + * + * The host authenticates and executes HTTP. This package owns endpoint + * construction, pagination policy, and response parsing. + */ +export type GitLabTransport = { + request(request: { + body?: unknown; + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; + /** Optional raw byte reader for immutable repository blobs. */ + requestBuffer?(request: { + /** Maximum raw response bytes the host may retain. */ + maxBytes?: number; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; + /** + * Optional paginated reader. When omitted, {@link request} is called with + * `page` / `per_page` until a short page is returned. + */ + requestPages?(request: { + /** Maximum combined response bytes the host may retain. */ + maxBytes?: number; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise>; + /** Optional raw text reader for repository file blobs. */ + requestText?(request: { + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; +}; diff --git a/gitlab/tsconfig.build.json b/gitlab/tsconfig.build.json new file mode 100644 index 00000000..bddab4a7 --- /dev/null +++ b/gitlab/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "incremental": false, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/gitlab/vite.config.ts b/gitlab/vite.config.ts new file mode 100644 index 00000000..e965e0af --- /dev/null +++ b/gitlab/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { + copy: [], + deps: { + alwaysBundle: ['@nkzw/codiff-core'], + dts: { neverBundle: ['@nkzw/codiff-core'] }, + }, + dts: false, + }, +}); diff --git a/package.json b/package.json index 1b51c4df..4fa50845 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codiff", - "version": "1.12.1", + "version": "1.10.1", "private": true, "description": "A fast local diff viewer.", "license": "MIT", @@ -23,27 +23,30 @@ "core", "dist", "electron", + "github/dist", + "gitlab/dist", "opencode", "pi" ], "type": "module", "main": "./electron/main.cjs", "scripts": { - "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build", + "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-github' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build && node ./scripts/verify-package-runtime.mjs", + "build:runtime": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-github' build", "codiff": "node ./bin/codiff.js", "dev": "vp dev --host 127.0.0.1", "dev:app": "ELECTRON_RENDERER_URL=http://127.0.0.1:5173 node ./bin/codiff.js", "electron": "electron .", "eval:walkthrough": "node ./evals/run.mjs", "example:definition-navigation": "node ./examples/definition-navigation/run.mjs", - "forge:make": "electron-forge make", - "forge:package": "electron-forge package", - "link:global": "vp build && pnpm link --global", - "make": "vpr build && electron-forge make", - "make:ci": "vpr build && electron-forge make && electron-forge package --platform=linux --arch=x64 && electron-forge make --platform=win32", - "make:mac": "vpr build && electron-forge make --platform=darwin --arch=arm64", - "package:app": "vp build && electron-forge package", - "prepack": "vp build", + "forge:make": "pnpm run build:runtime && vpr build && electron-forge make", + "forge:package": "pnpm run build:runtime && vpr build && electron-forge package", + "link:global": "pnpm add --global .", + "make": "pnpm run build:runtime && vpr build && electron-forge make", + "make:ci": "pnpm run build:runtime && vpr build && electron-forge make && electron-forge package --platform=linux --arch=x64 && electron-forge make --platform=win32", + "make:mac": "pnpm run build:runtime && vpr build && electron-forge make --platform=darwin --arch=arm64", + "package:app": "pnpm run build:runtime && vpr build && electron-forge package", + "prepack": "pnpm run build:runtime && vpr build", "prepare": "vp config", "test": "vp test", "test:integration": "vp run build && vp test -c vitest.cloudflare.config.ts" @@ -55,23 +58,26 @@ "proper-lockfile": "^4.1.2" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.22.0", + "@cloudflare/vitest-pool-workers": "^0.18.8", "@electron-forge/cli": "^7.11.2", "@electron-forge/maker-deb": "^7.11.2", "@electron-forge/maker-rpm": "^7.11.2", "@electron-forge/maker-squirrel": "^7.11.2", "@electron-forge/maker-zip": "^7.11.2", "@nkzw/eslint-plugin": "^2.0.0", - "@nkzw/oxlint-config": "^2.0.1", + "@nkzw/oxlint-config": "^1.2.1", "@rolldown/plugin-babel": "^0.2.3", - "@types/node": "^26.4.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.5", - "@vitejs/plugin-react": "^6.1.1", + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", "babel-plugin-react-compiler": "^1.0.0", - "electron": "^44.1.1", + "electron": "^43.2.0", + "eslint-plugin-no-only-tests": "^3.4.0", + "eslint-plugin-perfectionist": "^5.10.0", + "eslint-plugin-react-hooks": "^7.1.1", "ghostty-web": "^0.4.0", - "jsdom": "^30.0.1", + "jsdom": "^29.1.1", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 969b924d..a09b773f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,18 @@ importers: specifier: ^1.4.2 version: 1.4.2(typescript@7.0.2) + github: + dependencies: + '@nkzw/codiff-core': + specifier: workspace:* + version: link:../core + + gitlab: + dependencies: + '@nkzw/codiff-core': + specifier: workspace:* + version: link:../core + service: dependencies: '@nkzw/codiff-core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0a9d69b3..5b8d206f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,7 @@ packages: - core + - gitlab + - github - service - web diff --git a/scripts/verify-package-runtime.mjs b/scripts/verify-package-runtime.mjs new file mode 100644 index 00000000..5645d3ee --- /dev/null +++ b/scripts/verify-package-runtime.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +import { Buffer } from 'node:buffer'; +import { spawn } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { builtinModules, createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; + +const root = process.cwd(); +const runtimeFiles = [ + 'electron/github-history-bridge.cjs', + 'electron/gitlab-history-bridge.cjs', + 'github/dist/index.mjs', + 'gitlab/dist/index.mjs', +]; +const builtin = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]); + +const run = (command, args) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: root, + env: { ...process.env, npm_config_ignore_scripts: 'true' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf8')); + } else { + reject(new Error(`${command} ${args.join(' ')} failed: ${Buffer.concat(stderr)}`)); + } + }); + }); + +const parsePackEntry = (value) => { + const entry = Array.isArray(value) ? value[0] : value; + return Array.isArray(entry?.files) ? new Set(entry.files.map((file) => file.path)) : null; +}; + +const parsePackList = (output) => { + try { + const files = parsePackEntry(JSON.parse(output)); + if (files) { + return files; + } + } catch { + // pnpm may prefix a JSON payload with informational output. + } + for (const line of output.trim().split('\n').reverse()) { + try { + const files = parsePackEntry(JSON.parse(line)); + if (files) { + return files; + } + } catch { + // Continue looking for a one-line JSON payload. + } + } + throw new Error('pnpm pack did not return a JSON package file list.'); +}; + +const nonBuiltinImports = async (path) => { + const source = await readFile(path, 'utf8'); + const specifiers = [ + ...source.matchAll( + /(?:\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?|\bimport\s*\()\s*["']([^"']+)["']/g, + ), + ...source.matchAll(/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g), + ].map((match) => match[1]); + return specifiers.filter( + (specifier) => + specifier && + !specifier.startsWith('.') && + !specifier.startsWith('/') && + !builtin.has(specifier), + ); +}; + +const packList = parsePackList( + await run('pnpm', ['--config.ignore-scripts=true', 'pack', '--dry-run', '--json']), +); +for (const path of runtimeFiles) { + if (!packList.has(path)) { + throw new Error(`Root package is missing required runtime artifact ${path}.`); + } +} +for (const path of ['github/dist/index.mjs', 'gitlab/dist/index.mjs']) { + const imports = await nonBuiltinImports(join(root, path)); + if (imports.length > 0) { + throw new Error(`${path} retains non-builtin runtime imports: ${imports.join(', ')}.`); + } +} + +const directory = await mkdtemp(join(tmpdir(), 'codiff-package-runtime-')); +try { + for (const path of runtimeFiles) { + const destination = join(directory, path); + await mkdir(dirname(destination), { recursive: true }); + await cp(join(root, path), destination, { recursive: true }); + } + const require = createRequire(join(directory, 'package.json')); + const [{ loadGitHubHistory }, { loadGitLabHistory }] = [ + require(join(directory, 'electron/github-history-bridge.cjs')), + require(join(directory, 'electron/gitlab-history-bridge.cjs')), + ]; + const [github, gitlab] = await Promise.all([loadGitHubHistory(), loadGitLabHistory()]); + if (typeof github.createGitHubArtifactSource !== 'function') { + throw new Error('GitHub runtime bridge did not load createGitHubArtifactSource.'); + } + if (typeof gitlab.createGitLabArtifactSource !== 'function') { + throw new Error('GitLab runtime bridge did not load createGitLabArtifactSource.'); + } +} finally { + await rm(directory, { force: true, recursive: true }); +} diff --git a/test/fake-provider-transports.ts b/test/fake-provider-transports.ts new file mode 100644 index 00000000..df4d3013 --- /dev/null +++ b/test/fake-provider-transports.ts @@ -0,0 +1,165 @@ +import type { GitHubTransport } from '../github/src/transport.ts'; +import type { GitLabTransport } from '../gitlab/src/transport.ts'; + +type Query = Readonly>; +type FakeTransportRequest = { method: string; path: string; query?: Query }; +type FakeTransportRoute = { + bytes?: Uint8Array | ((request: FakeTransportRequest) => Promise | Uint8Array); + method?: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; + path: string; + query?: Query; + response: unknown | ((request: FakeTransportRequest) => Promise | unknown); + text?: string | ((request: FakeTransportRequest) => Promise | string); +}; +type FakeTransportCall = { + maxBytes?: number; + method: string; + path: string; + query?: Record; +}; +type FakeTransport = GitHubTransport & + GitLabTransport & { + calls: Array; + }; + +const queryKey = (query?: Query) => + query + ? Object.entries(query) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${String(value)}`) + .join('&') + : ''; + +const withAbort = async (promise: Promise, signal?: AbortSignal): Promise => { + signal?.throwIfAborted(); + if (!signal) { + return promise; + } + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', abort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); +}; + +const createFakeTransport = ( + provider: 'GitHub' | 'GitLab', + routes: ReadonlyArray, +): FakeTransport => { + const calls: Array = []; + const match = (method: string, path: string, query?: Query) => + routes.find( + (route) => + route.path === path && + (route.method ?? 'GET') === method && + queryKey(route.query) === queryKey(query), + ) ?? + routes.find( + (route) => route.path === path && (route.method ?? 'GET') === method && route.query == null, + ); + const record = (request: { maxBytes?: number; method?: string; path: string; query?: Query }) => { + const method = request.method ?? 'GET'; + calls.push({ + ...(request.maxBytes == null ? {} : { maxBytes: request.maxBytes }), + method, + path: request.path, + ...(request.query ? { query: { ...request.query } } : {}), + }); + return method; + }; + const value = async ( + routeValue: Value | ((request: FakeTransportRequest) => Promise | Value), + request: FakeTransportRequest, + signal?: AbortSignal, + ) => + withAbort( + Promise.resolve( + typeof routeValue === 'function' + ? (routeValue as (request: FakeTransportRequest) => Promise | Value)(request) + : routeValue, + ), + signal, + ); + + return { + calls, + async request(request) { + request.signal?.throwIfAborted(); + const method = record(request); + const route = match(method, request.path, request.query); + if (!route) { + throw new Error(`No fake ${provider} route for ${method} ${request.path}`); + } + return value( + route.response as never, + { method, path: request.path, query: request.query }, + request.signal, + ); + }, + async requestBuffer(request) { + request.signal?.throwIfAborted(); + record(request); + const route = match('GET', request.path, request.query); + if (!route?.bytes) { + throw new Error(`No fake ${provider} byte route for GET ${request.path}`); + } + const bytes = await value( + route.bytes, + { method: 'GET', path: request.path, query: request.query }, + request.signal, + ); + if (request.maxBytes != null && bytes.byteLength > request.maxBytes) { + const error = new Error( + `${provider} response exceeded the ${request.maxBytes}-byte safety limit.`, + ); + error.name = 'ProviderOutputLimitError'; + throw error; + } + return bytes; + }, + async requestPages(request) { + const values: Array = []; + for (let page = 1; page < 50; page += 1) { + request.signal?.throwIfAborted(); + const query = { ...(request.query ?? {}), page, per_page: 100 }; + const route = + match('GET', request.path, query) ?? + (page === 1 ? match('GET', request.path, request.query) : undefined); + if (!route) { + break; + } + record({ ...request, query }); + const response = await value( + route.response as never, + { method: 'GET', path: request.path, query }, + request.signal, + ); + const pageValues = Array.isArray(response) ? response : []; + values.push(...pageValues); + if (pageValues.length < 100) { + break; + } + } + return values; + }, + async requestText(request) { + request.signal?.throwIfAborted(); + record(request); + const route = match('GET', request.path, request.query); + if (route?.text == null) { + throw new Error(`No fake ${provider} text route for GET ${request.path}`); + } + return value( + route.text, + { method: 'GET', path: request.path, query: request.query }, + request.signal, + ); + }, + }; +}; + +export const createFakeGitHubTransport = (routes: ReadonlyArray) => + createFakeTransport('GitHub', routes); + +export const createFakeGitLabTransport = (routes: ReadonlyArray) => + createFakeTransport('GitLab', routes); diff --git a/vite.config.ts b/vite.config.ts index 15a540e8..cdc68bca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,6 +26,8 @@ export default defineConfig({ 'pnpm-lock.yaml', 'core/__generated__/', 'core/node_modules/', + 'gitlab/node_modules/', + 'gitlab/dist/', 'service/node_modules/', 'service/dist/', 'web/.fate/', @@ -42,6 +44,8 @@ export default defineConfig({ 'dist/', 'electron/', 'core/node_modules/', + 'gitlab/node_modules/', + 'gitlab/dist/', 'service/node_modules/', 'service/dist/', 'web/.fate/', @@ -107,6 +111,8 @@ export default defineConfig({ test: { include: [ 'core/**/*.test.{ts,tsx}', + 'gitlab/**/*.test.ts', + 'github/**/*.test.ts', 'electron/**/*.test.ts', 'service/**/*.test.ts', 'web/**/*.test.{ts,tsx}', From a4fdb8803c94bfc1329b1f1f0cde63f0a3a288de Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Wed, 22 Jul 2026 19:08:46 -0500 Subject: [PATCH 05/17] Pass comment and note permissions into ReviewSurface Stop deriving host behavior from source type and let each host select local, provider, share, reply, session, navigation, and exact-content capabilities. Rename the common surface, forward one source-neutral content resolver through the code view, and render the changed-file filter only in Tree. --- core/App.css | 2 +- core/ReviewSurface.tsx | 1786 +++++++++++++++++ core/SharedWalkthroughApp.tsx | 1378 ------------- core/__tests__/ReviewCodeView-scroll.test.tsx | 11 +- .../ReviewSurface-capabilities.test.tsx | 983 +++++++++ ...ughApp.test.tsx => ReviewSurface.test.tsx} | 328 +-- .../app-review-comment-hooks.test.tsx | 2 +- core/app/components/Panels.tsx | 20 +- core/app/components/ReviewCodeView.tsx | 26 +- core/app/components/Sidebar.tsx | 209 +- .../merge-request/GeneralComments.tsx | 53 +- core/app/hooks/useAppKeyboardShortcuts.ts | 19 +- core/app/hooks/useAppReviewComments.ts | 6 +- core/app/hooks/useReviewState.ts | 66 +- core/index.ts | 5 + core/lib/source.ts | 4 +- core/react.ts | 21 +- core/tsconfig.build.json | 2 +- core/types/review-comments.ts | 21 + core/types/review-identity.ts | 25 + core/types/walkthrough.ts | 3 +- service/react.test.ts | 192 +- service/react.tsx | 63 +- 23 files changed, 3403 insertions(+), 1822 deletions(-) create mode 100644 core/ReviewSurface.tsx delete mode 100644 core/SharedWalkthroughApp.tsx create mode 100644 core/__tests__/ReviewSurface-capabilities.test.tsx rename core/__tests__/{SharedWalkthroughApp.test.tsx => ReviewSurface.test.tsx} (62%) diff --git a/core/App.css b/core/App.css index 412c2e4b..35d86725 100644 --- a/core/App.css +++ b/core/App.css @@ -728,7 +728,7 @@ html[data-codiff-platform='darwin'] .workspace-top-bar { position: relative; } -.share-shell .review-top-bar { +.share-shell:not(.merge-request-shell) .review-top-bar { padding-left: 14px; } diff --git a/core/ReviewSurface.tsx b/core/ReviewSurface.tsx new file mode 100644 index 00000000..d2da7750 --- /dev/null +++ b/core/ReviewSurface.tsx @@ -0,0 +1,1786 @@ +import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; +import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; +import { ClockCounterClockwiseIcon as ClockCounterClockwise } from '@phosphor-icons/react/ClockCounterClockwise'; +import { PathIcon as Path } from '@phosphor-icons/react/Path'; +import { TreeStructureIcon as TreeStructure } from '@phosphor-icons/react/TreeStructure'; +import type { FileDiffLoadedFiles } from '@pierre/diffs'; +import { Trash2 } from 'lucide-react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react'; +import { Button } from './app/components/Button.tsx'; +import { CommandBar } from './app/components/CommandBar.tsx'; +import { ReviewFileTree } from './app/components/FileTree.tsx'; +import { KeyboardShortcutsHelp } from './app/components/KeyboardShortcutsHelp.tsx'; +import { + MergeRequestCommentsView, + SidebarGeneralCommentList, +} from './app/components/merge-request/GeneralComments.tsx'; +import { + isTerminalPullRequestMergeState, + CopyCommentsButton, + DiffSearchPanel, + isPullRequestReviewActionDisabled, + PullRequestMergeControls, + PullRequestMergeStatusBadge, + PullRequestReviewButtons, +} from './app/components/Panels.tsx'; +import { + PullRequestSourceDescription, + ReviewCodeView, + type ReviewDiffBlock, +} from './app/components/ReviewCodeView.tsx'; +import type { ReviewModeItem } from './app/components/ReviewModeControl.tsx'; +import { ReviewTopBar } from './app/components/ReviewTopBar.tsx'; +import { DiffLineCountBadge, HistorySidebar } from './app/components/Sidebar.tsx'; +import type { + CommitHandler, + CommitMessageHandler, + CommitOutputSubscriber, +} from './app/components/walkthrough/CommitView.tsx'; +import { NarrativeSidebar } from './app/components/walkthrough/NarrativeSidebar.tsx'; +import { + NarrativeWalkthroughView, + type WalkthroughBlockScrollTarget, + type WalkthroughReviewTarget, +} from './app/components/walkthrough/NarrativeWalkthroughView.tsx'; +import { useNarrativeNavigation } from './app/components/walkthrough/useNarrativeNavigation.ts'; +import { WalkthroughDiffSurface } from './app/components/walkthrough/WalkthroughDiffSurface.tsx'; +import { WalkthroughProgress } from './app/components/walkthrough/WalkthroughProgress.tsx'; +import { useAppKeyboardShortcuts } from './app/hooks/useAppKeyboardShortcuts.ts'; +import { useDiffSearch } from './app/hooks/useDiffSearch.ts'; +import { + getCodeFontLineHeight, + normalizeCodeFontSizePreference, + useDocumentAppearance, +} from './app/hooks/useDocumentAppearance.ts'; +import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; +import { useReviewCommentDrafts } from './app/hooks/useReviewCommentDrafts.ts'; +import { useReviewFileState } from './app/hooks/useReviewState.ts'; +import { createDefaultConfig } from './config/defaults.ts'; +import { getShortcutLabel } from './config/keymap.ts'; +import type { CodiffDiffStyle, CodiffKeymap } from './config/types.ts'; +import { getAgentLabel } from './lib/app-constants.ts'; +import type { + CodeViewInstance, + ReviewComment, + ReviewScrollTarget, + WalkthroughError, +} from './lib/app-types.ts'; +import type { Command } from './lib/command-registry.ts'; +import { + getDiffLineCount, + getTotalDiffLineCount, + isMarkdownFilePath, + shouldPreloadSectionContentsForSearch, +} from './lib/diff.ts'; +import { abbreviateHomePath, sortFiles } from './lib/files.ts'; +import { isNativeInputTarget } from './lib/keyboard.ts'; +import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js'; +import { + buildReviewCommentsMarkdown, + getPendingPullRequestReviewComments, + getReviewCommentsFromState, + mergeReviewComments, + toSubmittedReviewComment, + toPullRequestReviewComment, +} from './lib/review-comments.ts'; +import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; +import { + SIDEBAR_COLLAPSE_THRESHOLD, + SIDEBAR_DEFAULT_WIDTH, + readSidebarWidth, + writeSidebarWidth, +} from './lib/sidebar-width.ts'; +import { + getEmptySourceDetail, + getEmptySourceTitle, + getSourceLabel, + getSourceKey, + supportsDiffSearchContentPreload, +} from './lib/source.ts'; +import type { + ChangedFile, + DiffImageContentRequest, + DiffImageContentResult, + DiffSection, + GitIdentity, + HistoryEntry, + NarrativeWalkthrough, + PullRequestMergeOptions, + PullRequestGeneralComment, + PullRequestGeneralCommentThread, + PullRequestExistingReviewComment, + PullRequestReviewEvent, + ProviderCommentSubmission, + ResolvedReviewSource, + ReviewCommenting, + ReviewSource, + RepositoryState, + ShareCommentSubmission, + SharedWalkthroughSnapshot, + SubmittedReviewComment, + WalkthroughCommitMessageResult, + WalkthroughCommitResult, +} from './types.ts'; + +export { ReadOnlyGeneralCommentCard } from './app/components/merge-request/GeneralComments.tsx'; +export type { ReviewCommenting } from './types.ts'; + +const emptyReviewComments: ReadonlyArray = []; +const emptyGeneralCommentThreads: ReadonlyArray = []; +const emptyPaths = new Set(); +const emptyWalkthroughNotes = new Map(); +const readSharedSidebarWidth = () => + typeof localStorage === 'undefined' ? SIDEBAR_DEFAULT_WIDTH : readSidebarWidth(); + +const writeSharedSidebarWidth = (width: number) => { + if (typeof localStorage !== 'undefined') { + writeSidebarWidth(width); + } +}; + +export type ReviewWalkthroughStatus = 'failed' | 'generating' | 'idle' | 'ready'; +export type ReviewMode = 'comments' | 'history' | 'tree' | 'walkthrough'; +export type ReviewSurfaceCommandBridge = { + copyPendingComments: () => string; + getPersistenceState: () => { mode: ReviewMode; selectedPath: string | null }; + openDiffSearch: () => void; +}; + +export type ControlledReviewValue = Readonly<{ + onChange: (value: Value) => void; + value: Value; +}>; + +export type ControlledReviewDrafts = Readonly<{ + onChange: Dispatch>>; + value: ReadonlyArray; +}>; + +type ReviewDraftCapabilities = { + canCreateInline?: boolean; + drafts?: ControlledReviewDrafts; + onAsk?: (comment: ReviewComment) => void; +}; + +export type LocalReviewNoteCapabilities = ReviewDraftCapabilities; + +export type CommentDestination = 'provider' | 'share'; +export type CommentAnchorPolicy = 'provider-target' | 'share-snapshot'; +export type ProviderReviewOutcome = 'approve' | 'comment' | 'request-changes'; + +export type SubmitProviderReviewRequest = { + comments: ReadonlyArray; + outcome: ProviderReviewOutcome; + summary?: string; +}; + +export type ProviderReviewSessionCapabilities = { + drafts: ControlledReviewDrafts; + submit: (request: SubmitProviderReviewRequest) => Promise; +}; + +type ReviewCommentSubmission = Destination extends 'share' + ? ShareCommentSubmission + : ProviderCommentSubmission; + +type CommonReviewCommentCapabilities = { + authoring: ReviewDraftCapabilities; + destination: Destination; + general?: { + onCreate?: (body: string) => Promise; + onDelete?: (commentId: string) => Promise; + onReply?: (threadId: string, body: string) => Promise; + onResolve?: (threadId: string, resolved: boolean) => Promise; + onUpdate?: (commentId: string, body: string) => Promise; + }; + inline: { + onDelete?: (commentId: string) => Promise; + onResolve?: (threadId: string, resolved: boolean) => Promise; + onSubmit?: (comment: ReviewCommentSubmission) => Promise; + onUpdate?: (commentId: string, body: string) => Promise; + }; + onSignIn?: () => Promise | void; +}; + +export type ShareReviewCommentCapabilities = CommonReviewCommentCapabilities<'share'>; + +export type ProviderReviewCommentCapabilities = CommonReviewCommentCapabilities<'provider'> & { + reviewSession?: ProviderReviewSessionCapabilities; +}; + +export type ReviewCommentCapabilities = + | ProviderReviewCommentCapabilities + | ShareReviewCommentCapabilities; + +export type ReviewContentCapabilities = { + forceExpandedPaths?: ReadonlySet; + itemVersionByKey?: Readonly>; + loadingSectionIds?: ReadonlySet; + onLoadImageContent?: (request: DiffImageContentRequest) => Promise; + onLoadSection?: (file: ChangedFile, section: DiffSection) => Promise | void; + onRefreshMarkdown?: (file: ChangedFile, section: DiffSection) => Promise; + resolveSectionContents?: ( + file: ChangedFile, + section: DiffSection, + ) => Promise; +}; + +export type ReviewDesktopCapabilities = { + beforeContent?: ReactNode; + collapsed?: ReadonlySet; + commands?: ReadonlyArray; + disableCodeViewWorkerPool?: boolean; + isWindowFullscreen?: boolean; + onActiveWalkthroughReviewTargetChange?: (target: WalkthroughReviewTarget | null) => void; + onCollapsedChange?: (collapsed: Set) => void; + onOpenFile?: (file: ChangedFile) => void; + onOpenSelectedFile?: () => void; + onViewedChange?: (viewed: Record) => void; + reloadDeltaPaths?: ReadonlySet; + viewed?: Readonly>; +}; + +export type ReviewHistoryModel = { + branchSource?: Extract | null; + currentSource: ResolvedReviewSource | ReviewSource; + entries: ReadonlyArray; + hasMore: boolean; + loading: boolean; + onLoadMore: () => void; + onSelectSource: (source: ReviewSource) => void; + pullRequestSource?: Extract | null; +}; + +export type ControlledReviewPreferences = { + diffLayout?: ControlledReviewValue; + outdatedVisibility?: ControlledReviewValue; + pendingCommentPrefix?: ControlledReviewValue; + selectedPath?: ControlledReviewValue; + wordWrap?: ControlledReviewValue; +}; + +export type ReviewSourceNavigation = { + onCancelAutoMerge?: () => Promise | void; + onClosePullRequest?: () => Promise | void; + onMergePullRequest?: ( + options: PullRequestMergeOptions & { autoMerge: boolean }, + ) => Promise | void; + onUpdateDescription?: (body: string) => Promise | void; + onUpdateTitle?: (title: string) => Promise | void; + onUploadDescriptionAsset?: (file: File) => Promise | string; +}; + +export type ReviewWalkthroughCapabilities = { + commit?: CommitHandler; + commitOutput?: CommitOutputSubscriber; + error?: Pick | null; + onGenerate?: () => Promise | void; + onShare?: () => Promise | void; + progress?: ReactNode; + status?: ReviewWalkthroughStatus; + updateCommitMessage?: CommitMessageHandler; +}; + +type ReviewAnnotationCapabilities = + | { + comments?: never; + localReviewNotes: LocalReviewNoteCapabilities; + } + | { + comments: ReviewCommentCapabilities; + localReviewNotes?: never; + } + | { + comments?: never; + localReviewNotes?: never; + }; + +export type ReviewSurfaceCapabilities = ReviewAnnotationCapabilities & { + content?: ReviewContentCapabilities; + desktop?: ReviewDesktopCapabilities; + history?: ReviewHistoryModel; + preferences?: ControlledReviewPreferences; + sourceNavigation?: ReviewSourceNavigation; + walkthrough?: ReviewWalkthroughCapabilities; +}; + +export const buildSharedReviewSnapshot = ({ + preferences, + state, + title, + walkthrough, +}: { + preferences: SharedWalkthroughSnapshot['preferences']; + state: RepositoryState; + title: string; + walkthrough: NarrativeWalkthrough; +}): SharedWalkthroughSnapshot => ({ + branch: state.branch, + codeQualityFindings: state.codeQualityFindings, + codiffVersion: 'desktop', + commitMetadata: state.commitMetadata, + exportedAt: new Date(state.generatedAt).toISOString(), + files: state.files, + kind: 'codiff-walkthrough-share', + preferences, + repository: { + generalComments: state.generalComments, + root: state.root, + source: state.source, + title, + }, + reviewComments: state.reviewComments, + version: 1, + walkthrough, +}); + +const getSnapshotReviewComments = ( + snapshot: SharedWalkthroughSnapshot, +): ReadonlyArray => { + if (!snapshot.reviewComments?.length) { + return emptyReviewComments; + } + + return getReviewCommentsFromState({ + branch: snapshot.branch, + files: snapshot.files, + generatedAt: Date.parse(snapshot.exportedAt) || Date.now(), + launchPath: snapshot.repository.root, + reviewComments: snapshot.reviewComments as ReadonlyArray, + root: snapshot.repository.root, + source: snapshot.repository.source, + } satisfies RepositoryState); +}; + +const noop = () => {}; + +const toProviderReviewOutcome = (event: PullRequestReviewEvent): ProviderReviewOutcome => { + switch (event) { + case 'APPROVE': + return 'approve'; + case 'COMMENT': + return 'comment'; + case 'REQUEST_CHANGES': + return 'request-changes'; + } +}; + +const disabledCommit = async (): Promise => ({ + reason: 'Shared walkthroughs are read-only.', + status: 'failed', +}); + +const disabledCommitMessage = async (): Promise => ({ + reason: 'Shared walkthroughs are read-only.', + status: 'unavailable', +}); + +type ReviewSurfaceBaseProps = { + capabilities?: ReviewSurfaceCapabilities; + externalUrl?: string; + gitIdentity?: GitIdentity | null; + keymap?: CodiffKeymap; + onCommandBridgeChange?: (bridge: ReviewSurfaceCommandBridge | null) => void; + onDeleteShare?: () => Promise | void; + providerLabel?: string; + repositoryUrl?: string; + settingsBar?: ReactNode; + signInLabel?: string; + snapshot: SharedWalkthroughSnapshot; + sourceDescriptionFooterAside?: ReactNode; + title?: string; +}; + +type ControlledReviewSurfaceProps = ReviewSurfaceBaseProps & { + activeMode: ControlledReviewValue; + initialMode?: never; +}; + +type UncontrolledReviewSurfaceProps = ReviewSurfaceBaseProps & { + activeMode?: never; + initialMode?: ReviewMode; +}; + +export type ReviewSurfaceProps = ControlledReviewSurfaceProps | UncontrolledReviewSurfaceProps; + +export function ReviewSurface({ + activeMode, + capabilities, + externalUrl, + gitIdentity = null, + initialMode, + keymap: keymapProp, + onCommandBridgeChange, + onDeleteShare, + providerLabel = 'provider', + repositoryUrl, + settingsBar, + signInLabel = 'Sign in to comment', + snapshot, + sourceDescriptionFooterAside, + title, +}: ReviewSurfaceProps) { + const content = capabilities?.content; + const desktop = capabilities?.desktop; + const history = capabilities?.history; + const localReviewNotes = capabilities?.localReviewNotes; + const comments = capabilities?.comments; + const controlledPreferences = capabilities?.preferences; + const sourceNavigation = capabilities?.sourceNavigation; + const walkthrough = capabilities?.walkthrough; + const reviewSession = comments?.destination === 'provider' ? comments.reviewSession : undefined; + const canComment = + localReviewNotes?.canCreateInline ?? + comments?.authoring.canCreateInline ?? + comments?.inline.onSubmit != null; + const reviewDrafts = + localReviewNotes ?? + (comments && (canComment || comments.authoring.drafts || reviewSession?.drafts) + ? comments.authoring + : undefined); + const controlledReviewDrafts = reviewSession?.drafts ?? reviewDrafts?.drafts; + const copyPendingCommentsLabel = localReviewNotes + ? 'Copy Review Notes' + : 'Copy Pending Review Comments'; + const pendingCommentPrefix = + controlledPreferences?.pendingCommentPrefix?.value ?? + (localReviewNotes + ? '# Address these Review Notes' + : comments + ? '# Address these Pending Review Comments' + : undefined); + const commenting = useMemo( + () => + comments + ? { + canComment, + onDeleteComment: comments.inline.onDelete, + onDeleteGeneralComment: comments.general?.onDelete, + onReplyGeneralComment: comments.general?.onReply, + onResolveDiscussion: comments.general?.onResolve ?? comments.inline.onResolve, + onSignIn: comments.onSignIn, + onSubmitComment: comments.inline.onSubmit, + onSubmitGeneralComment: comments.general?.onCreate, + onUpdateComment: comments.inline.onUpdate, + onUpdateGeneralComment: comments.general?.onUpdate, + } + : undefined, + [canComment, comments], + ); + const deleteShare = useCallback(async () => { + if ( + !onDeleteShare || + !window.confirm('Delete this shared walkthrough? This cannot be undone.') + ) { + return; + } + try { + await onDeleteShare(); + } catch (error: unknown) { + window.alert(error instanceof Error ? error.message : String(error)); + } + }, [onDeleteShare]); + const submitReviewComment = comments?.inline.onSubmit; + const submitGeneralDiscussion = comments?.general?.onCreate; + const updateReviewComment = comments?.inline.onUpdate; + const updateGeneralDiscussion = comments?.general?.onUpdate; + const resolveDiscussion = comments?.inline.onResolve; + const sharedWalkthrough = useMemo( + () => + walkthrough?.commit + ? snapshot.walkthrough + : { + ...snapshot.walkthrough, + commit: undefined, + }, + [snapshot.walkthrough, walkthrough?.commit], + ); + const navigation = useNarrativeNavigation( + sharedWalkthrough, + snapshot.files, + `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`, + ); + const defaultKeymap = useMemo(() => createDefaultConfig().keymap, []); + const keymap = keymapProp ?? defaultKeymap; + const [uncontrolledWordWrap, setUncontrolledWordWrap] = useState(snapshot.preferences.wordWrap); + const wordWrap = controlledPreferences?.wordWrap?.value ?? uncontrolledWordWrap; + const [fileSearchQuery, setFileSearchQuery] = useState(''); + const [uncontrolledSidebarMode, setUncontrolledSidebarMode] = useState( + () => initialMode ?? (desktop ? 'tree' : 'walkthrough'), + ); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const sidebarMode = activeMode?.value ?? uncontrolledSidebarMode; + const [treeScrollTarget, setTreeScrollTarget] = useState(null); + const { + bumpItemVersion, + collapsed, + expandedGenerated, + itemVersionByKey: uncontrolledItemVersionByKey, + selectedPath: uncontrolledSelectedPath, + setSelectedPath: setUncontrolledSelectedPath, + toggleCollapsed, + toggleViewed, + viewed, + } = useReviewFileState({ + collapsed: desktop?.collapsed, + initialSelectedPath: + controlledPreferences?.selectedPath?.value ?? snapshot.files[0]?.path ?? null, + onCollapsedChange: desktop?.onCollapsedChange, + onViewedChange: desktop?.onViewedChange, + viewed: desktop?.viewed, + }); + const itemVersionByKey = content?.itemVersionByKey ?? uncontrolledItemVersionByKey; + const selectedPath = controlledPreferences?.selectedPath?.value ?? uncontrolledSelectedPath; + const { resizeSidebar, sidebarWidth } = useResizableSidebar({ + collapseThreshold: SIDEBAR_COLLAPSE_THRESHOLD, + onCollapse: () => setSidebarCollapsed(true), + onWidthCommit: writeSharedSidebarWidth, + readWidth: readSharedSidebarWidth, + }); + const snapshotReviewComments = useMemo(() => getSnapshotReviewComments(snapshot), [snapshot]); + const showOutdated = controlledPreferences?.outdatedVisibility?.value ?? true; + const [editedReviewCommentBodies, setEditedReviewCommentBodies] = useState< + Readonly> + >({}); + const visibleSnapshotReviewComments = useMemo( + () => + snapshotReviewComments + .filter((comment) => showOutdated || !comment.isOutdated) + .map((comment) => ({ + ...comment, + ...(editedReviewCommentBodies[comment.id] != null && + editedReviewCommentBodies[comment.id] !== comment.body + ? { body: editedReviewCommentBodies[comment.id] } + : {}), + canDelete: comment.canDelete === true && comments?.inline.onDelete != null, + canEdit: comment.canEdit === true && comments?.inline.onUpdate != null, + canReplyThread: comment.canReplyThread !== false && comments?.inline.onSubmit != null, + canResolveThread: comment.canResolveThread === true && comments?.inline.onResolve != null, + })), + [comments, editedReviewCommentBodies, showOutdated, snapshotReviewComments], + ); + const [uncontrolledLocalReviewComments, setUncontrolledLocalReviewComments] = + useState>(emptyReviewComments); + const uncontrolledLocalReviewCommentsRef = useRef(uncontrolledLocalReviewComments); + const localReviewComments = controlledReviewDrafts?.value ?? uncontrolledLocalReviewComments; + const setLocalReviewComments = useCallback< + Dispatch>> + >( + (update) => { + if (controlledReviewDrafts) { + controlledReviewDrafts.onChange(update); + return; + } + const nextComments = + typeof update === 'function' ? update(uncontrolledLocalReviewCommentsRef.current) : update; + uncontrolledLocalReviewCommentsRef.current = nextComments; + setUncontrolledLocalReviewComments(nextComments); + }, + [controlledReviewDrafts], + ); + const reviewComments = useMemo( + () => mergeReviewComments(visibleSnapshotReviewComments, localReviewComments), + [localReviewComments, visibleSnapshotReviewComments], + ); + const { + activeReviewCommentDraftRef, + activeReviewCommentDraftState, + clearCommentFocus, + createComment, + deleteComment: deleteLocalComment, + focusCommentId, + focusCommentRequest, + reviewCommentsRef, + updateActiveReviewCommentDraft, + updateComment, + } = useReviewCommentDrafts({ + canCreateComment: canComment, + comments: reviewComments, + onCommentFileChange: bumpItemVersion, + setComments: setLocalReviewComments, + }); + const generalCommentThreads = snapshot.repository.generalComments ?? emptyGeneralCommentThreads; + const generalComments = useMemo( + () => + (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).flatMap( + (thread) => thread.comments, + ), + [snapshot.repository.generalComments], + ); + const generalCommentCount = generalComments.length; + const showCommentsTab = + comments != null || generalCommentCount > 0 || visibleSnapshotReviewComments.length > 0; + const [generalCommentDraft, setGeneralCommentDraft] = useState(''); + const [generalCommentEditDraft, setGeneralCommentEditDraft] = useState(''); + const [editingGeneralCommentId, setEditingGeneralCommentId] = useState(null); + const [generalCommentEditError, setGeneralCommentEditError] = useState(null); + const [generalCommentEditSubmitting, setGeneralCommentEditSubmitting] = useState(false); + const [generalCommentError, setGeneralCommentError] = useState(null); + const [focusedGeneralCommentId, setFocusedGeneralCommentId] = useState(null); + const [generalCommentScrollRequest, setGeneralCommentScrollRequest] = useState(0); + const [generalCommentSubmitting, setGeneralCommentSubmitting] = useState(false); + const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = + useState(null); + const [pullRequestCloseSubmitting, setPullRequestCloseSubmitting] = useState(false); + const [pullRequestMergeSubmitting, setPullRequestMergeSubmitting] = useState(false); + const [walkthroughRequestPending, setWalkthroughRequestPending] = useState(false); + const walkthroughRequestPendingRef = useRef(false); + const [walkthroughRequestId, setWalkthroughRequestId] = useState(0); + const walkthroughRef = useRef(walkthrough); + + const orderedFiles = useMemo(() => sortFiles(snapshot.files), [snapshot.files]); + const { + activeMatch: activeDiffSearchMatch, + activeMatchIndex: activeDiffSearchMatchIndex, + closeSearch: closeDiffSearch, + fileFilteredFiles, + focusRequest: diffSearchFocusRequest, + matches: diffSearchMatches, + matchPathSet: diffSearchMatchPathSet, + moveMatch: moveDiffSearchMatch, + openSearch: openDiffSearch, + query: diffSearchQuery, + updateQuery: updateDiffSearchQuery, + visible: diffSearchVisible, + visibleFiles, + } = useDiffSearch({ + files: orderedFiles, + fileSearchQuery, + showWhitespace: snapshot.preferences.showWhitespace, + }); + useEffect(() => { + if ( + !content?.onLoadSection || + !supportsDiffSearchContentPreload(snapshot.repository.source) || + !diffSearchQuery.trim() + ) { + return; + } + + const requests = fileFilteredFiles.flatMap((file) => + file.sections + .filter(shouldPreloadSectionContentsForSearch) + .map((section) => ({ file, section })), + ); + if (requests.length === 0) { + return; + } + + let canceled = false; + let cursor = 0; + const loadNext = async () => { + while (!canceled) { + const request = requests[cursor]; + cursor += 1; + if (!request) { + return; + } + await content.onLoadSection!(request.file, request.section); + } + }; + void Promise.all(Array.from({ length: Math.min(3, requests.length) }, () => loadNext())); + return () => { + canceled = true; + }; + }, [content, diffSearchQuery, fileFilteredFiles, snapshot.repository.source]); + const forceExpandedPaths = useMemo( + () => new Set([...diffSearchMatchPathSet, ...(content?.forceExpandedPaths ?? emptyPaths)]), + [content?.forceExpandedPaths, diffSearchMatchPathSet], + ); + const totalLineCount = useMemo( + () => + getTotalDiffLineCount( + visibleFiles.map((file) => getDiffLineCount(file, snapshot.preferences.showWhitespace)), + ), + [snapshot.preferences.showWhitespace, visibleFiles], + ); + const showTotalLineCount = + sidebarMode !== 'comments' && sidebarMode !== 'history' && totalLineCount.countable; + const visibleSelectedPath = + selectedPath && visibleFiles.some((file) => file.path === selectedPath) + ? selectedPath + : (visibleFiles[0]?.path ?? null); + const initialMarkdownPreviewSectionIds = useMemo(() => { + const nonGeneratedFiles = snapshot.files.filter((file) => !isGeneratedWalkthroughFile(file)); + if ( + nonGeneratedFiles.length === 0 || + !nonGeneratedFiles.every((file) => isMarkdownFilePath(file.path)) + ) { + return emptyPaths; + } + + return new Set( + snapshot.files + .filter((file) => isMarkdownFilePath(file.path)) + .flatMap((file) => file.sections.map((section) => section.id)), + ); + }, [snapshot.files]); + + useDocumentAppearance({ + codeFontFamily: snapshot.preferences.codeFontFamily, + codeFontSize: snapshot.preferences.codeFontSize, + theme: snapshot.preferences.theme, + }); + + const changeSidebarMode = useCallback( + (mode: ReviewMode) => { + if (activeMode) { + activeMode.onChange(mode); + } else { + setUncontrolledSidebarMode(mode); + } + }, + [activeMode], + ); + + const activateGeneralComment = useCallback( + (commentId: string) => { + changeSidebarMode('comments'); + setFocusedGeneralCommentId(commentId); + setGeneralCommentScrollRequest((current) => current + 1); + }, + [changeSidebarMode], + ); + const navigateGeneralComment = useCallback( + (direction: 1 | -1) => { + if (generalComments.length === 0) { + return; + } + + const currentIndex = focusedGeneralCommentId + ? generalComments.findIndex((comment) => comment.id === focusedGeneralCommentId) + : -1; + const nextIndex = + currentIndex === -1 + ? direction > 0 + ? 0 + : generalComments.length - 1 + : Math.min(generalComments.length - 1, Math.max(0, currentIndex + direction)); + const nextComment = generalComments[nextIndex]; + + if (nextComment) { + activateGeneralComment(nextComment.id); + } + }, + [activateGeneralComment, focusedGeneralCommentId, generalComments], + ); + const updateExistingReviewComment = useCallback( + async (commentId: string, body: string) => { + if (!updateReviewComment) { + return; + } + const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + await updateReviewComment(commentId, body); + setEditedReviewCommentBodies((current) => ({ ...current, [commentId]: body })); + if (comment) { + bumpItemVersion(comment.filePath); + } + }, + [bumpItemVersion, reviewCommentsRef, updateReviewComment], + ); + const deleteComment = useCallback( + (commentId: string) => { + const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + if (comment?.isReadOnly && comment.canDelete && comments?.inline.onDelete) { + updateActiveReviewCommentDraft(null); + setLocalReviewComments((current) => + current.filter((candidate) => candidate.id !== commentId), + ); + void comments.inline.onDelete(commentId).catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }); + return; + } + deleteLocalComment(commentId); + }, + [ + comments, + deleteLocalComment, + reviewCommentsRef, + setLocalReviewComments, + updateActiveReviewCommentDraft, + ], + ); + const submitComment = useCallback( + (commentId: string) => { + const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + if ( + !submitReviewComment || + !comment || + comment.isReadOnly || + !comment.body.trim() || + comment.remoteSubmit?.status === 'submitting' + ) { + return; + } + + updateActiveReviewCommentDraft(null); + setLocalReviewComments((current) => + current.map((candidate) => + candidate.id === commentId + ? { ...candidate, remoteSubmit: { status: 'submitting' } } + : candidate, + ), + ); + const submission = toPullRequestReviewComment(comment, { + includeSectionId: comments?.destination === 'share', + }); + void submitReviewComment(submission) + .then((submittedComment) => { + clearCommentFocus(commentId); + setLocalReviewComments((current) => + current.flatMap((candidate) => { + if (candidate.id !== commentId) { + return [candidate]; + } + return [toSubmittedReviewComment(submittedComment, candidate)]; + }), + ); + bumpItemVersion(comment.filePath); + }) + .catch((error: unknown) => { + setLocalReviewComments((current) => + current.map((candidate) => + candidate.id === commentId + ? { + ...candidate, + remoteSubmit: { + error: error instanceof Error ? error.message : String(error), + status: 'error', + }, + } + : candidate, + ), + ); + bumpItemVersion(comment.filePath); + }); + }, + [ + bumpItemVersion, + clearCommentFocus, + comments?.destination, + reviewCommentsRef, + setLocalReviewComments, + submitReviewComment, + updateActiveReviewCommentDraft, + ], + ); + const submitReview = useCallback( + (event: PullRequestReviewEvent, body?: string): Promise | void => { + const source = snapshot.repository.source; + if ( + !reviewSession || + pullRequestReviewSubmitting || + (source.type === 'pull-request' && + isPullRequestReviewActionDisabled(source.reviewStatus, event)) + ) { + return; + } + + const pendingComments = getPendingPullRequestReviewComments( + reviewCommentsRef.current, + activeReviewCommentDraftRef.current, + ); + if (event === 'COMMENT' && pendingComments.length === 0 && !body?.trim()) { + return; + } + const pendingIds = new Set(pendingComments.map((comment) => comment.id)); + setPullRequestReviewSubmitting(event); + const formattedComments = pendingComments.map((comment) => + toPullRequestReviewComment(comment), + ); + const submission = reviewSession.submit({ + comments: formattedComments, + outcome: toProviderReviewOutcome(event), + ...(body?.trim() ? { summary: body } : {}), + }); + return submission + .then(() => { + updateActiveReviewCommentDraft(null); + setLocalReviewComments((current) => + current.filter((comment) => !pendingIds.has(comment.id)), + ); + }) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + throw error; + }) + .finally(() => setPullRequestReviewSubmitting(null)); + }, + [ + reviewSession, + pullRequestReviewSubmitting, + snapshot.repository.source, + activeReviewCommentDraftRef, + reviewCommentsRef, + setLocalReviewComments, + updateActiveReviewCommentDraft, + ], + ); + const closePullRequest = useCallback(() => { + const source = snapshot.repository.source; + if ( + !sourceNavigation?.onClosePullRequest || + pullRequestCloseSubmitting || + source.type !== 'pull-request' || + source.reviewStatus?.close?.disabled === true || + !source.reviewStatus?.close + ) { + return; + } + + setPullRequestCloseSubmitting(true); + void Promise.resolve(sourceNavigation.onClosePullRequest()) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setPullRequestCloseSubmitting(false)); + }, [pullRequestCloseSubmitting, snapshot.repository.source, sourceNavigation]); + const mergePullRequest = useCallback( + (options: PullRequestMergeOptions & { autoMerge: boolean }) => { + if (!sourceNavigation?.onMergePullRequest || pullRequestMergeSubmitting) { + return; + } + + setPullRequestMergeSubmitting(true); + void Promise.resolve(sourceNavigation.onMergePullRequest(options)) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setPullRequestMergeSubmitting(false)); + }, + [pullRequestMergeSubmitting, sourceNavigation], + ); + const cancelAutoMerge = useCallback(() => { + if (!sourceNavigation?.onCancelAutoMerge || pullRequestMergeSubmitting) { + return; + } + + setPullRequestMergeSubmitting(true); + void Promise.resolve(sourceNavigation.onCancelAutoMerge()) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setPullRequestMergeSubmitting(false)); + }, [pullRequestMergeSubmitting, sourceNavigation]); + useEffect(() => { + walkthroughRef.current = walkthrough; + }, [walkthrough]); + + useEffect(() => { + if (!walkthroughRequestPending || walkthroughRequestId === 0) { + return; + } + + let cancelled = false; + void Promise.resolve(walkthroughRef.current?.onGenerate?.()) + .catch(() => {}) + .finally(() => { + if (cancelled) { + return; + } + walkthroughRequestPendingRef.current = false; + setWalkthroughRequestPending(false); + }); + return () => { + cancelled = true; + }; + }, [walkthroughRequestId, walkthroughRequestPending]); + + const startWalkthroughGeneration = useCallback(() => { + if ( + !walkthrough?.onGenerate || + walkthrough.status === 'generating' || + walkthroughRequestPendingRef.current + ) { + return; + } + + walkthroughRequestPendingRef.current = true; + setWalkthroughRequestPending(true); + setWalkthroughRequestId((current) => current + 1); + }, [walkthrough]); + useEffect(() => { + if (sidebarMode === 'walkthrough' && walkthrough?.status === 'idle') { + startWalkthroughGeneration(); + } + }, [sidebarMode, startWalkthroughGeneration, walkthrough?.status]); + useEffect(() => { + if (sidebarMode !== 'comments' || generalComments.length === 0) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + isNativeInputTarget(event.target) + ) { + return; + } + + const key = event.key.toLowerCase(); + if (key !== 'j' && key !== 'k') { + return; + } + + event.preventDefault(); + navigateGeneralComment(key === 'j' ? 1 : -1); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [generalComments.length, navigateGeneralComment, sidebarMode]); + const submitGeneralComment = useCallback(() => { + const body = generalCommentDraft.trim(); + if (!submitGeneralDiscussion || !body || generalCommentSubmitting) { + return; + } + + setGeneralCommentError(null); + setGeneralCommentSubmitting(true); + void Promise.resolve(submitGeneralDiscussion(body)) + .then(() => setGeneralCommentDraft('')) + .catch((error: unknown) => { + setGeneralCommentError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setGeneralCommentSubmitting(false)); + }, [generalCommentDraft, generalCommentSubmitting, submitGeneralDiscussion]); + const startEditGeneralComment = useCallback((comment: PullRequestGeneralComment) => { + if (!comment.canEdit) { + return; + } + + setEditingGeneralCommentId(comment.id); + setGeneralCommentEditDraft(comment.body); + setGeneralCommentEditError(null); + }, []); + const cancelEditGeneralComment = useCallback(() => { + if (generalCommentEditSubmitting) { + return; + } + + setEditingGeneralCommentId(null); + setGeneralCommentEditDraft(''); + setGeneralCommentEditError(null); + }, [generalCommentEditSubmitting]); + const saveGeneralCommentEdit = useCallback(() => { + const commentId = editingGeneralCommentId; + const body = generalCommentEditDraft.trim(); + if (!updateGeneralDiscussion || !commentId || !body || generalCommentEditSubmitting) { + return; + } + + setGeneralCommentEditError(null); + setGeneralCommentEditSubmitting(true); + void Promise.resolve(updateGeneralDiscussion(commentId, body)) + .then(() => { + setEditingGeneralCommentId(null); + setGeneralCommentEditDraft(''); + }) + .catch((error: unknown) => { + setGeneralCommentEditError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setGeneralCommentEditSubmitting(false)); + }, [ + editingGeneralCommentId, + generalCommentEditDraft, + generalCommentEditSubmitting, + updateGeneralDiscussion, + ]); + const selectPath = useCallback( + (path: string | null) => { + setUncontrolledSelectedPath(path); + controlledPreferences?.selectedPath?.onChange(path); + }, + [controlledPreferences?.selectedPath, setUncontrolledSelectedPath], + ); + const activateTreePath = useCallback( + (path: string) => { + selectPath(path); + setTreeScrollTarget((current) => ({ + behavior: 'smooth', + path, + request: (current?.request ?? 0) + 1, + })); + }, + [selectPath], + ); + const updateSelectedPathFromScroll = useCallback( + (viewer: CodeViewInstance) => { + const nextPath = getSelectedPathFromScroll( + viewer, + visibleFiles, + snapshot.preferences.showWhitespace, + ); + + if (nextPath && selectedPath !== nextPath) { + selectPath(nextPath); + } + }, + [selectPath, selectedPath, snapshot.preferences.showWhitespace, visibleFiles], + ); + + const [hunkNavigation, setHunkNavigation] = useState<{ + direction: 1 | -1; + request: number; + } | null>(null); + const navigateHunks = useCallback((direction: 1 | -1) => { + setHunkNavigation((current) => ({ + direction, + request: (current?.request ?? 0) + 1, + })); + }, []); + const focusFileFilter = useCallback(() => { + setSidebarCollapsed(false); + changeSidebarMode('tree'); + requestAnimationFrame(() => { + const input = document.querySelector('.review-surface .sidebar-search'); + input?.focus(); + input?.select(); + }); + }, [changeSidebarMode]); + const toggleWordWrap = useCallback(() => { + const nextWordWrap = !wordWrap; + setUncontrolledWordWrap(nextWordWrap); + controlledPreferences?.wordWrap?.onChange(nextWordWrap); + }, [controlledPreferences?.wordWrap, wordWrap]); + const { closeCommandBar, commandBarVisible, shortcutsHelpVisible } = useAppKeyboardShortcuts({ + keymap, + navigateHunks, + onFocusFileFilter: focusFileFilter, + onOpenDiffSearch: openDiffSearch, + onOpenSelectedFile: desktop?.onOpenSelectedFile, + onToggleSidebar: () => setSidebarCollapsed((current) => !current), + onToggleWordWrap: toggleWordWrap, + shouldDeferHunkNavigation: () => sidebarMode === 'walkthrough', + sidebarCollapsed, + }); + const commandBarCommands = useMemo( + () => + [ + { + execute: focusFileFilter, + id: 'file-filter', + keymapAction: 'fileFilter', + title: 'Focus File Filter', + }, + { + execute: openDiffSearch, + id: 'diff-search', + keymapAction: 'diffSearch', + title: 'Find in Diffs', + }, + { execute: () => changeSidebarMode('tree'), id: 'sidebar-tree', title: 'Show File Tree' }, + { + execute: () => changeSidebarMode('walkthrough'), + id: 'sidebar-walkthrough', + title: 'Show Walkthrough', + }, + ...(showCommentsTab + ? [ + { + execute: () => changeSidebarMode('comments'), + id: 'sidebar-comments', + title: 'Show Comments', + }, + ] + : []), + ...(history + ? [ + { + execute: () => changeSidebarMode('history'), + id: 'sidebar-history', + title: 'Show History', + }, + ] + : []), + { + execute: () => setSidebarCollapsed((current) => !current), + id: 'toggle-sidebar', + keymapAction: 'toggleSidebar', + title: 'Toggle Sidebar', + }, + { + execute: toggleWordWrap, + id: 'toggle-word-wrap', + keymapAction: 'toggleWordWrap', + title: 'Toggle Word Wrap', + }, + ...(desktop?.commands ?? []), + ] satisfies ReadonlyArray, + [ + changeSidebarMode, + desktop?.commands, + focusFileFilter, + history, + openDiffSearch, + showCommentsTab, + toggleWordWrap, + ], + ); + const commandBridge = useMemo( + () => ({ + copyPendingComments: () => + buildReviewCommentsMarkdown( + snapshot.files, + localReviewComments, + snapshot.preferences.showWhitespace, + pendingCommentPrefix, + ), + getPersistenceState: () => ({ mode: sidebarMode, selectedPath: visibleSelectedPath }), + openDiffSearch, + }), + [ + localReviewComments, + openDiffSearch, + pendingCommentPrefix, + sidebarMode, + snapshot.files, + snapshot.preferences.showWhitespace, + visibleSelectedPath, + ], + ); + useEffect(() => { + onCommandBridgeChange?.(commandBridge); + return () => onCommandBridgeChange?.(null); + }, [commandBridge, onCommandBridgeChange]); + + const diffLineHeight = getCodeFontLineHeight( + normalizeCodeFontSizePreference(snapshot.preferences.codeFontSize), + ); + const commonReviewProps = { + activeSearchMatch: activeDiffSearchMatch, + agentId: snapshot.walkthrough.agent, + agentLabel: getAgentLabel(snapshot.walkthrough.agent), + codeQualityFindings: snapshot.codeQualityFindings, + collapsed, + comments: reviewComments, + commitMetadata: snapshot.commitMetadata ?? null, + diffLineHeight, + diffStyle: controlledPreferences?.diffLayout?.value ?? snapshot.preferences.diffStyle, + disableWorkerPool: desktop?.disableCodeViewWorkerPool ?? true, + expandedGenerated, + focusCommentId, + focusCommentRequest, + gitIdentity, + hunkNavigation, + initialMarkdownPreviewSectionIds, + isReadOnly: !canComment, + itemVersionByKey, + keymap, + loadingSectionIds: content?.loadingSectionIds ?? new Set(), + onAskCodex: reviewDrafts?.onAsk, + onCommentDraftChange: updateActiveReviewCommentDraft, + onCreateComment: createComment, + onDeleteComment: deleteComment, + onLoadImageContent: content?.onLoadImageContent, + onLoadSection: content?.onLoadSection, + resolveSectionContents: content?.resolveSectionContents, + onOpenFile: desktop?.onOpenFile, + onRefreshMarkdown: content?.onRefreshMarkdown, + onResolveThread: resolveDiscussion ?? noop, + onSaveCommentEdit: updateExistingReviewComment, + onSelectPathFromScroll: noop, + onSubmitComment: submitComment, + onToggleCollapsed: toggleCollapsed, + onToggleViewed: toggleViewed, + onUpdateComment: updateComment, + onUpdateSourceDescription: sourceNavigation?.onUpdateDescription, + onUpdateSourceTitle: sourceNavigation?.onUpdateTitle, + onUploadSourceDescriptionAsset: sourceNavigation?.onUploadDescriptionAsset, + searchQuery: diffSearchQuery, + showWhitespace: snapshot.preferences.showWhitespace, + source: snapshot.repository.source, + supportsReviewCommentActions: submitReviewComment != null, + theme: snapshot.preferences.theme, + viewed, + wordWrap, + }; + const source = snapshot.repository.source; + const emptySourceDetail = getEmptySourceDetail(source, snapshot.repository.root); + const hasDiffSearchQuery = diffSearchQuery.trim().length > 0; + const sourceMergeState = source.type === 'pull-request' ? source.mergeState : undefined; + const isTerminalMergeState = sourceMergeState + ? isTerminalPullRequestMergeState(sourceMergeState) + : false; + const sourceMergeStatusBadge = + sourceMergeState && isTerminalMergeState ? ( + + ) : null; + const sourceDescriptionActions = + (reviewSession || sourceNavigation?.onClosePullRequest) && source.type === 'pull-request' ? ( + 0 + } + onClosePullRequest={sourceNavigation?.onClosePullRequest ? closePullRequest : undefined} + onSubmitReview={reviewSession ? submitReview : undefined} + reviewStatus={source.reviewStatus} + showCommentReview={source.provider === 'github' || source.host === 'github.com'} + > + {sourceMergeStatusBadge} + + ) : sourceMergeStatusBadge ? ( +
+ {sourceMergeStatusBadge} +
+ ) : undefined; + const sourceDescriptionFooterMain = + (sourceNavigation?.onMergePullRequest || sourceNavigation?.onCancelAutoMerge) && + sourceMergeState && + !isTerminalMergeState ? ( + + ) : undefined; + const sourceDescriptionFooter = + sourceDescriptionFooterMain && sourceDescriptionFooterAside ? ( +
+
{sourceDescriptionFooterMain}
+
{sourceDescriptionFooterAside}
+
+ ) : ( + (sourceDescriptionFooterMain ?? sourceDescriptionFooterAside) + ); + const sourceDescription = + source.type === 'pull-request' ? ( + + ) : null; + + const renderWalkthroughDiffBlocks = ( + blocks: ReadonlyArray, + blockScrollTarget: WalkthroughBlockScrollTarget | null, + onActiveBlockChange: (blockId: string) => void, + ) => { + return ( + + ); + }; + + const sourceLabel = + snapshot.repository.source.type === 'working-tree' + ? null + : getSourceLabel(snapshot.repository.source); + const rootLabel = repositoryUrl + ? snapshot.repository.root + : abbreviateHomePath(snapshot.repository.root); + const sourceExternalUrl = + snapshot.repository.source.type === 'pull-request' + ? (externalUrl ?? snapshot.repository.source.url) + : null; + const repositoryLinkUrl = repositoryUrl ?? sourceExternalUrl; + const walkthroughStatus = + walkthroughRequestPending && walkthrough?.status !== 'ready' + ? 'generating' + : walkthrough?.status; + const [walkthroughProgressRevision, setWalkthroughProgressRevision] = useState(0); + const previousWalkthroughStatusRef = useRef(walkthroughStatus); + useEffect(() => { + if ( + walkthroughStatus === 'generating' && + previousWalkthroughStatusRef.current !== 'generating' + ) { + setWalkthroughProgressRevision((current) => current + 1); + } + previousWalkthroughStatusRef.current = walkthroughStatus; + }, [walkthroughStatus]); + const walkthroughReady = !walkthrough || walkthroughStatus === 'ready'; + const walkthroughFailed = walkthroughStatus === 'failed'; + const walkthroughStatusTitle = walkthroughFailed + ? 'Walkthrough unavailable' + : 'Generating walkthroughโ€ฆ'; + const walkthroughStatusDescription = walkthroughFailed + ? (walkthrough?.error?.reason ?? 'Fix the generation issue, then try again.') + : null; + const shellTheme = + snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; + const requestWalkthrough = () => { + startWalkthroughGeneration(); + }; + const reviewModes = [ + { + icon: , + label: 'Walkthrough', + value: 'walkthrough', + }, + { + icon: , + label: 'Tree', + value: 'tree', + }, + ...(history + ? [ + { + icon: , + label: 'History', + value: 'history' as const, + }, + ] + : []), + ...(showCommentsTab + ? [ + { + ariaLabel: generalCommentCount > 0 ? `Comments (${generalCommentCount})` : 'Comments', + icon: , + indicator: + generalCommentCount > 0 ? ( + + {generalCommentCount} + + ) : undefined, + label: 'Comments', + title: + generalCommentCount > 0 + ? `${generalCommentCount} ${generalCommentCount === 1 ? 'comment' : 'comments'}` + : 'Comments', + value: 'comments' as const, + }, + ] + : []), + ] satisfies ReadonlyArray>; + const topBarActions = + onDeleteShare || settingsBar ? ( + <> + {onDeleteShare ? ( + + ) : null} + {settingsBar ?
{settingsBar}
: null} + + ) : undefined; + + return ( + <> + +
+ + {snapshot.branch ? ( + + {snapshot.branch} + + ) : null} + {sourceLabel ? ( + sourceExternalUrl ? ( + + {sourceLabel} + + + ) : ( + {sourceLabel} + ) + ) : null} + + } + mode={sidebarMode} + modes={reviewModes} + onModeChange={changeSidebarMode} + onToggleSidebar={() => setSidebarCollapsed((current) => !current)} + repository={ + repositoryLinkUrl ? ( + + {rootLabel} + + ) : ( + {rootLabel} + ) + } + repositoryTooltip={snapshot.repository.root} + sidebarCollapsed={sidebarCollapsed} + toggleTitle={`${sidebarCollapsed ? 'Expand' : 'Collapse'} sidebar (${getShortcutLabel( + keymap, + 'toggleSidebar', + )})`} + /> + {desktop?.beforeContent} + {reviewDrafts ? ( +
+ +
+ ) : null} + moveDiffSearchMatch(1)} + onPrevious={() => moveDiffSearchMatch(-1)} + query={diffSearchQuery} + visible={diffSearchVisible} + /> + +
+
+ {sidebarMode === 'comments' ? ( + + ) : sidebarMode === 'tree' || sidebarMode === 'history' ? ( + snapshot.files.length === 0 ? ( +
+
+ {getEmptySourceTitle(source)} + {emptySourceDetail.kind === 'code' ? ( + + {emptySourceDetail.text} + + ) : ( + {emptySourceDetail.text} + )} +
+
+ ) : visibleFiles.length === 0 ? ( +
+
+ + {hasDiffSearchQuery ? 'No matches in diffs' : 'No matching files'} + + + {diffSearchQuery || + fileSearchQuery || + (snapshot.preferences.showWhitespace + ? snapshot.repository.root + : 'Whitespace-only changes hidden')} + +
+
+ ) : ( + + ) + ) : walkthroughReady ? ( + + ) : walkthroughFailed ? ( +
+
+ {walkthroughStatusTitle} +

{walkthroughStatusDescription}

+
+ +
+
+
+ ) : ( +
+ {walkthrough?.progress ?? ( + + )} +
+ )} +
+
+ + + ); +} diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx deleted file mode 100644 index 2b7f763c..00000000 --- a/core/SharedWalkthroughApp.tsx +++ /dev/null @@ -1,1378 +0,0 @@ -import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; -import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; -import { PathIcon as Path } from '@phosphor-icons/react/Path'; -import { TreeStructureIcon as TreeStructure } from '@phosphor-icons/react/TreeStructure'; -import { Trash2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Button } from './app/components/Button.tsx'; -import { ReviewFileTree } from './app/components/FileTree.tsx'; -import { - MergeRequestCommentsView, - SidebarGeneralCommentList, - type ReviewCommenting, -} from './app/components/merge-request/GeneralComments.tsx'; -import { - isTerminalPullRequestMergeState, - isPullRequestReviewActionDisabled, - PullRequestMergeControls, - PullRequestMergeStatusBadge, - PullRequestReviewButtons, -} from './app/components/Panels.tsx'; -import { - PullRequestSourceDescription, - ReviewCodeView, - type ReviewDiffBlock, -} from './app/components/ReviewCodeView.tsx'; -import type { ReviewModeItem } from './app/components/ReviewModeControl.tsx'; -import { ReviewTopBar } from './app/components/ReviewTopBar.tsx'; -import { DiffLineCountBadge } from './app/components/Sidebar.tsx'; -import { NarrativeSidebar } from './app/components/walkthrough/NarrativeSidebar.tsx'; -import { - NarrativeWalkthroughView, - type WalkthroughBlockScrollTarget, -} from './app/components/walkthrough/NarrativeWalkthroughView.tsx'; -import { useNarrativeNavigation } from './app/components/walkthrough/useNarrativeNavigation.ts'; -import { WalkthroughDiffSurface } from './app/components/walkthrough/WalkthroughDiffSurface.tsx'; -import { WalkthroughProgress } from './app/components/walkthrough/WalkthroughProgress.tsx'; -import { - getCodeFontLineHeight, - normalizeCodeFontSizePreference, - useDocumentAppearance, -} from './app/hooks/useDocumentAppearance.ts'; -import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; -import { useReviewCommentDrafts } from './app/hooks/useReviewCommentDrafts.ts'; -import { useReviewFileState } from './app/hooks/useReviewState.ts'; -import { createDefaultConfig } from './config/defaults.ts'; -import { matchesShortcut } from './config/keymap.ts'; -import { getAgentLabel } from './lib/app-constants.ts'; -import type { CodeViewInstance, ReviewComment, ReviewScrollTarget } from './lib/app-types.ts'; -import { - fileHasVisibleDiff, - getDiffLineCount, - getTotalDiffLineCount, - isMarkdownFilePath, -} from './lib/diff.ts'; -import { abbreviateHomePath, fuzzyMatches, sortFiles } from './lib/files.ts'; -import { isNativeInputTarget } from './lib/keyboard.ts'; -import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js'; -import { - getPendingPullRequestReviewComments, - getReviewCommentsFromState, - mergeReviewComments, - toSubmittedReviewComment, - toPullRequestReviewComment, -} from './lib/review-comments.ts'; -import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; -import { - SIDEBAR_COLLAPSE_THRESHOLD, - SIDEBAR_DEFAULT_WIDTH, - readSidebarWidth, - writeSidebarWidth, -} from './lib/sidebar-width.ts'; -import { getSourceLabel, getSourceKey } from './lib/source.ts'; -import type { - GitIdentity, - PullRequestMergeOptions, - PullRequestGeneralComment, - PullRequestGeneralCommentThread, - PullRequestExistingReviewComment, - PullRequestReviewComment, - PullRequestReviewEvent, - RepositoryState, - SharedWalkthroughSnapshot, - WalkthroughCommitMessageResult, - WalkthroughCommitResult, -} from './types.ts'; - -export { - ReadOnlyGeneralCommentCard, - type ReviewCommenting, -} from './app/components/merge-request/GeneralComments.tsx'; - -const emptyReviewComments: ReadonlyArray = []; -const emptyGeneralCommentThreads: ReadonlyArray = []; -const emptyPaths = new Set(); -const emptyWalkthroughNotes = new Map(); -const reviewSurfacePreferencesKey = 'codiff:web-review-surface-preferences:v1'; -const getLocationHashTarget = () => { - const hash = window.location.hash.slice(1); - if (!hash) { - return null; - } - try { - return decodeURIComponent(hash); - } catch { - return hash; - } -}; -const commentMatchesHashTarget = ( - comment: { id: string; threadId?: string; url?: string }, - target: string, -) => - comment.id === target || - comment.threadId === target || - comment.url?.slice(comment.url.lastIndexOf('#') + 1) === target; -const readSharedSidebarWidth = () => - typeof localStorage === 'undefined' ? SIDEBAR_DEFAULT_WIDTH : readSidebarWidth(); - -const writeSharedSidebarWidth = (width: number) => { - if (typeof localStorage !== 'undefined') { - writeSidebarWidth(width); - } -}; - -const readStoredSidebarCollapsed = (): boolean | null => { - if (typeof localStorage === 'undefined') { - return null; - } - try { - const stored = JSON.parse(localStorage.getItem(reviewSurfacePreferencesKey) ?? '{}') as unknown; - if ( - stored && - typeof stored === 'object' && - 'sidebarCollapsed' in stored && - typeof stored.sidebarCollapsed === 'boolean' - ) { - return stored.sidebarCollapsed; - } - } catch { - // Ignore unavailable or invalid browser storage and use the viewport default. - } - return null; -}; - -const writeStoredSidebarCollapsed = (sidebarCollapsed: boolean) => { - if (typeof localStorage === 'undefined') { - return; - } - try { - localStorage.setItem(reviewSurfacePreferencesKey, JSON.stringify({ sidebarCollapsed })); - } catch { - // Ignore unavailable or full browser storage; the preference remains in memory. - } -}; - -export type ReviewWalkthroughStatus = 'failed' | 'generating' | 'idle' | 'ready'; -export type ReviewMode = 'comments' | 'tree' | 'walkthrough'; - -const getSnapshotReviewComments = ( - snapshot: SharedWalkthroughSnapshot, -): ReadonlyArray => { - if (!snapshot.reviewComments?.length) { - return emptyReviewComments; - } - - return getReviewCommentsFromState({ - branch: snapshot.branch, - files: snapshot.files, - generatedAt: Date.parse(snapshot.exportedAt) || Date.now(), - launchPath: snapshot.repository.root, - reviewComments: snapshot.reviewComments as ReadonlyArray, - root: snapshot.repository.root, - source: snapshot.repository.source, - } satisfies RepositoryState); -}; - -const noop = () => {}; - -const disabledCommit = async (): Promise => ({ - reason: 'Shared walkthroughs are read-only.', - status: 'failed', -}); - -const disabledCommitMessage = async (): Promise => ({ - reason: 'Shared walkthroughs are read-only.', - status: 'unavailable', -}); - -export type ReviewSurfaceProps = { - commenting?: ReviewCommenting; - externalUrl?: string; - gitIdentity?: GitIdentity | null; - initialMode?: ReviewMode; - interactive?: { - onCancelAutoMerge?: () => Promise | void; - onClosePullRequest?: () => Promise | void; - onGenerateWalkthrough: () => Promise | void; - onHome: () => void; - onMarkPullRequestReady?: () => Promise | void; - onMergePullRequest?: ( - options: PullRequestMergeOptions & { autoMerge: boolean }, - ) => Promise | void; - onResolveDiscussion?: (discussionId: string, resolved: boolean) => Promise; - onSubmitComment: ( - comment: PullRequestReviewComment, - ) => Promise; - onSubmitGeneralComment: (body: string) => Promise; - onSubmitReview: ( - event: PullRequestReviewEvent, - comments: ReadonlyArray, - body?: string, - ) => Promise; - onUpdateComment: (commentId: string, body: string) => Promise; - onUpdateDescription?: (body: string) => Promise | void; - onUpdateGeneralComment: (commentId: string, body: string) => Promise; - onUpdateTitle?: (title: string) => Promise | void; - onUploadDescriptionAsset?: (file: File) => Promise | string; - walkthroughError?: string | null; - walkthroughStatus: ReviewWalkthroughStatus; - }; - onDeleteShare?: () => Promise | void; - onModeChange?: (mode: ReviewMode) => void; - providerLabel?: string; - repositoryUrl?: string; - settingsBar?: ReactNode; - sidebarPosition?: 'left' | 'right'; - signInLabel?: string; - snapshot: SharedWalkthroughSnapshot; - sourceDescriptionFooterAside?: ReactNode; - title?: string; -}; - -const mobileSidebarMediaQuery = '(max-width: 720px)'; - -const shouldCollapseSidebarInitially = () => - readStoredSidebarCollapsed() ?? - (typeof window !== 'undefined' && window.matchMedia(mobileSidebarMediaQuery).matches); - -export function ReviewSurface({ - commenting, - externalUrl, - gitIdentity = null, - initialMode, - interactive, - onDeleteShare, - onModeChange, - providerLabel = 'provider', - repositoryUrl, - settingsBar, - sidebarPosition = 'left', - signInLabel = 'Sign in to comment', - snapshot, - sourceDescriptionFooterAside, - title, -}: ReviewSurfaceProps) { - const canComment = commenting?.canComment ?? Boolean(interactive); - const deleteShare = useCallback(async () => { - if ( - !onDeleteShare || - !window.confirm('Delete this shared walkthrough? This cannot be undone.') - ) { - return; - } - try { - await onDeleteShare(); - } catch (error: unknown) { - window.alert(error instanceof Error ? error.message : String(error)); - } - }, [onDeleteShare]); - const submitReviewComment = commenting?.onSubmitComment ?? interactive?.onSubmitComment; - const submitGeneralDiscussion = - commenting?.onSubmitGeneralComment ?? interactive?.onSubmitGeneralComment; - const updateReviewComment = commenting?.onUpdateComment ?? interactive?.onUpdateComment; - const updateGeneralDiscussion = - commenting?.onUpdateGeneralComment ?? interactive?.onUpdateGeneralComment; - const resolveDiscussion = commenting?.onResolveDiscussion ?? interactive?.onResolveDiscussion; - const sharedWalkthrough = useMemo( - () => ({ - ...snapshot.walkthrough, - commit: undefined, - }), - [snapshot.walkthrough], - ); - const navigation = useNarrativeNavigation( - sharedWalkthrough, - snapshot.files, - `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`, - ); - const keymap = useMemo(() => createDefaultConfig().keymap, []); - const [fileSearchQuery, setFileSearchQuery] = useState(''); - const [uncontrolledSidebarMode, setUncontrolledSidebarMode] = useState( - () => initialMode ?? (interactive ? 'tree' : 'walkthrough'), - ); - const [sidebarCollapsed, setSidebarCollapsed] = useState(null); - const sidebarCollapsedRef = useRef(null); - const sidebarInteractedRef = useRef(false); - const updateSidebarCollapsed = useCallback((value: boolean, persist: boolean) => { - sidebarCollapsedRef.current = value; - setSidebarCollapsed(value); - if (persist) { - writeStoredSidebarCollapsed(value); - } - }, []); - const toggleSidebar = useCallback(() => { - sidebarInteractedRef.current = true; - updateSidebarCollapsed( - !(sidebarCollapsedRef.current ?? shouldCollapseSidebarInitially()), - true, - ); - }, [updateSidebarCollapsed]); - useEffect(() => { - const timeout = window.setTimeout(() => { - if (!sidebarInteractedRef.current) { - updateSidebarCollapsed(shouldCollapseSidebarInitially(), false); - } - }, 0); - return () => window.clearTimeout(timeout); - }, [updateSidebarCollapsed]); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.repeat || !matchesShortcut(event, keymap, 'toggleSidebar')) { - return; - } - event.preventDefault(); - toggleSidebar(); - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [keymap, toggleSidebar]); - const isSidebarModeControlled = Boolean(initialMode && onModeChange); - const sidebarMode = - isSidebarModeControlled && initialMode ? initialMode : uncontrolledSidebarMode; - const [treeScrollTarget, setTreeScrollTarget] = useState(null); - const { - bumpItemVersion, - collapsed, - expandedGenerated, - itemVersionByKey, - selectedPath, - setSelectedPath, - toggleCollapsed, - toggleViewed, - viewed, - } = useReviewFileState({ - initialSelectedPath: snapshot.files[0]?.path ?? null, - }); - const { resizeSidebar, sidebarWidth } = useResizableSidebar({ - collapseThreshold: SIDEBAR_COLLAPSE_THRESHOLD, - onCollapse: () => { - sidebarInteractedRef.current = true; - updateSidebarCollapsed(true, true); - }, - onWidthCommit: writeSharedSidebarWidth, - position: sidebarPosition, - readWidth: readSharedSidebarWidth, - }); - const snapshotReviewComments = useMemo(() => getSnapshotReviewComments(snapshot), [snapshot]); - const [editedReviewCommentBodies, setEditedReviewCommentBodies] = useState< - Readonly> - >({}); - const visibleSnapshotReviewComments = useMemo( - () => - snapshotReviewComments.map((comment) => - editedReviewCommentBodies[comment.id] != null && - editedReviewCommentBodies[comment.id] !== comment.body - ? { ...comment, body: editedReviewCommentBodies[comment.id] } - : comment, - ), - [editedReviewCommentBodies, snapshotReviewComments], - ); - const [localReviewComments, setLocalReviewComments] = - useState>(emptyReviewComments); - const reviewComments = useMemo( - () => mergeReviewComments(visibleSnapshotReviewComments, localReviewComments), - [localReviewComments, visibleSnapshotReviewComments], - ); - const { - activeReviewCommentDraftRef, - activeReviewCommentDraftState, - clearCommentFocus, - createComment, - deleteComment: deleteLocalComment, - focusComment, - focusCommentId, - focusCommentRequest, - reviewCommentsRef, - updateActiveReviewCommentDraft, - updateComment, - } = useReviewCommentDrafts({ - canCreateComment: canComment, - comments: reviewComments, - onCommentFileChange: bumpItemVersion, - setComments: setLocalReviewComments, - }); - const generalCommentThreads = snapshot.repository.generalComments ?? emptyGeneralCommentThreads; - const generalComments = useMemo( - () => - (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).flatMap( - (thread) => thread.comments, - ), - [snapshot.repository.generalComments], - ); - const generalCommentCount = generalComments.length; - const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); - const [generalCommentDraft, setGeneralCommentDraft] = useState(''); - const [generalCommentEditDraft, setGeneralCommentEditDraft] = useState(''); - const [editingGeneralCommentId, setEditingGeneralCommentId] = useState(null); - const [generalCommentEditError, setGeneralCommentEditError] = useState(null); - const [generalCommentEditSubmitting, setGeneralCommentEditSubmitting] = useState(false); - const [generalCommentError, setGeneralCommentError] = useState(null); - const [focusedGeneralCommentId, setFocusedGeneralCommentId] = useState(null); - const [generalCommentScrollRequest, setGeneralCommentScrollRequest] = useState(0); - const [generalCommentSubmitting, setGeneralCommentSubmitting] = useState(false); - const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = - useState(null); - const [pullRequestCloseSubmitting, setPullRequestCloseSubmitting] = useState(false); - const [pullRequestReadySubmitting, setPullRequestReadySubmitting] = useState(false); - const [pullRequestMergeSubmitting, setPullRequestMergeSubmitting] = useState(false); - const [walkthroughRequestPending, setWalkthroughRequestPending] = useState(false); - const walkthroughRequestPendingRef = useRef(false); - const [walkthroughRequestId, setWalkthroughRequestId] = useState(0); - const interactiveRef = useRef(interactive); - const handledHashTargetRef = useRef(null); - - const visibleFiles = useMemo( - () => - sortFiles(snapshot.files).filter( - (file) => - fuzzyMatches(file.path, fileSearchQuery) && - fileHasVisibleDiff(file, snapshot.preferences.showWhitespace), - ), - [fileSearchQuery, snapshot.files, snapshot.preferences.showWhitespace], - ); - const totalLineCount = useMemo( - () => - getTotalDiffLineCount( - visibleFiles.map((file) => getDiffLineCount(file, snapshot.preferences.showWhitespace)), - ), - [snapshot.preferences.showWhitespace, visibleFiles], - ); - const showTotalLineCount = sidebarMode !== 'comments' && totalLineCount.countable; - const visibleSelectedPath = - selectedPath && visibleFiles.some((file) => file.path === selectedPath) - ? selectedPath - : (visibleFiles[0]?.path ?? null); - const initialMarkdownPreviewSectionIds = useMemo(() => { - const nonGeneratedFiles = snapshot.files.filter((file) => !isGeneratedWalkthroughFile(file)); - if ( - nonGeneratedFiles.length === 0 || - !nonGeneratedFiles.every((file) => isMarkdownFilePath(file.path)) - ) { - return emptyPaths; - } - - return new Set( - snapshot.files - .filter((file) => isMarkdownFilePath(file.path)) - .flatMap((file) => file.sections.map((section) => section.id)), - ); - }, [snapshot.files]); - - useDocumentAppearance({ - codeFontFamily: snapshot.preferences.codeFontFamily, - codeFontSize: snapshot.preferences.codeFontSize, - theme: snapshot.preferences.theme, - }); - - const changeSidebarMode = useCallback( - (mode: ReviewMode) => { - setUncontrolledSidebarMode(mode); - onModeChange?.(mode); - }, - [onModeChange], - ); - - const activateGeneralComment = useCallback( - (commentId: string) => { - changeSidebarMode('comments'); - setFocusedGeneralCommentId(commentId); - setGeneralCommentScrollRequest((current) => current + 1); - }, - [changeSidebarMode], - ); - const navigateGeneralComment = useCallback( - (direction: 1 | -1) => { - if (generalComments.length === 0) { - return; - } - - const currentIndex = focusedGeneralCommentId - ? generalComments.findIndex((comment) => comment.id === focusedGeneralCommentId) - : -1; - const nextIndex = - currentIndex === -1 - ? direction > 0 - ? 0 - : generalComments.length - 1 - : Math.min(generalComments.length - 1, Math.max(0, currentIndex + direction)); - const nextComment = generalComments[nextIndex]; - - if (nextComment) { - activateGeneralComment(nextComment.id); - } - }, - [activateGeneralComment, focusedGeneralCommentId, generalComments], - ); - const updateExistingReviewComment = useCallback( - async (commentId: string, body: string) => { - if (!updateReviewComment) { - return; - } - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - await updateReviewComment(commentId, body); - setEditedReviewCommentBodies((current) => ({ ...current, [commentId]: body })); - if (comment) { - bumpItemVersion(comment.filePath); - } - }, - [bumpItemVersion, reviewCommentsRef, updateReviewComment], - ); - const deleteComment = useCallback( - (commentId: string) => { - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if (comment?.isReadOnly && comment.canDelete && commenting?.onDeleteComment) { - updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => - current.filter((candidate) => candidate.id !== commentId), - ); - void commenting.onDeleteComment(commentId).catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }); - return; - } - deleteLocalComment(commentId); - }, - [commenting, deleteLocalComment, reviewCommentsRef, updateActiveReviewCommentDraft], - ); - const submitComment = useCallback( - (commentId: string) => { - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if ( - !submitReviewComment || - !comment || - comment.isReadOnly || - !comment.body.trim() || - comment.remoteSubmit?.status === 'submitting' - ) { - return; - } - - updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => - current.map((candidate) => - candidate.id === commentId - ? { ...candidate, remoteSubmit: { status: 'submitting' } } - : candidate, - ), - ); - void submitReviewComment( - toPullRequestReviewComment(comment, { includeSectionId: commenting != null }), - ) - .then((submittedComment) => { - clearCommentFocus(commentId); - setLocalReviewComments((current) => - current.flatMap((candidate) => { - if (candidate.id !== commentId) { - return [candidate]; - } - return [toSubmittedReviewComment(submittedComment, candidate)]; - }), - ); - bumpItemVersion(comment.filePath); - }) - .catch((error: unknown) => { - setLocalReviewComments((current) => - current.map((candidate) => - candidate.id === commentId - ? { - ...candidate, - remoteSubmit: { - error: error instanceof Error ? error.message : String(error), - status: 'error', - }, - } - : candidate, - ), - ); - bumpItemVersion(comment.filePath); - }); - }, - [ - bumpItemVersion, - clearCommentFocus, - commenting, - reviewCommentsRef, - submitReviewComment, - updateActiveReviewCommentDraft, - ], - ); - const submitReview = useCallback( - (event: PullRequestReviewEvent, body?: string) => { - const source = snapshot.repository.source; - if ( - !interactive || - pullRequestReviewSubmitting || - (source.type === 'pull-request' && - isPullRequestReviewActionDisabled(source.reviewStatus, event)) - ) { - return; - } - - const pendingComments = getPendingPullRequestReviewComments( - reviewCommentsRef.current, - activeReviewCommentDraftRef.current, - ); - if (event === 'COMMENT' && pendingComments.length === 0 && !body?.trim()) { - return; - } - const pendingIds = new Set(pendingComments.map((comment) => comment.id)); - setPullRequestReviewSubmitting(event); - const formattedComments = pendingComments.map((comment) => - toPullRequestReviewComment(comment), - ); - const submission = body - ? interactive.onSubmitReview(event, formattedComments, body) - : interactive.onSubmitReview(event, formattedComments); - return submission - .then(() => { - updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => - current.filter((comment) => !pendingIds.has(comment.id)), - ); - }) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - throw error; - }) - .finally(() => setPullRequestReviewSubmitting(null)); - }, - [ - interactive, - pullRequestReviewSubmitting, - snapshot.repository.source, - activeReviewCommentDraftRef, - reviewCommentsRef, - updateActiveReviewCommentDraft, - ], - ); - const closePullRequest = useCallback(() => { - const source = snapshot.repository.source; - if ( - !interactive?.onClosePullRequest || - pullRequestCloseSubmitting || - source.type !== 'pull-request' || - source.reviewStatus?.close?.disabled === true || - !source.reviewStatus?.close - ) { - return; - } - - setPullRequestCloseSubmitting(true); - void Promise.resolve(interactive.onClosePullRequest()) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setPullRequestCloseSubmitting(false)); - }, [interactive, pullRequestCloseSubmitting, snapshot.repository.source]); - const markPullRequestReady = useCallback(() => { - const source = snapshot.repository.source; - if ( - !interactive?.onMarkPullRequestReady || - pullRequestReadySubmitting || - source.type !== 'pull-request' || - source.reviewStatus?.markReady?.disabled === true || - !source.reviewStatus?.markReady - ) { - return; - } - - setPullRequestReadySubmitting(true); - return Promise.resolve(interactive.onMarkPullRequestReady()) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setPullRequestReadySubmitting(false)); - }, [interactive, pullRequestReadySubmitting, snapshot.repository.source]); - const mergePullRequest = useCallback( - (options: PullRequestMergeOptions & { autoMerge: boolean }) => { - if (!interactive?.onMergePullRequest || pullRequestMergeSubmitting) { - return; - } - - setPullRequestMergeSubmitting(true); - void Promise.resolve(interactive.onMergePullRequest(options)) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setPullRequestMergeSubmitting(false)); - }, - [interactive, pullRequestMergeSubmitting], - ); - const cancelAutoMerge = useCallback(() => { - if (!interactive?.onCancelAutoMerge || pullRequestMergeSubmitting) { - return; - } - - setPullRequestMergeSubmitting(true); - void Promise.resolve(interactive.onCancelAutoMerge()) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setPullRequestMergeSubmitting(false)); - }, [interactive, pullRequestMergeSubmitting]); - useEffect(() => { - interactiveRef.current = interactive; - }, [interactive]); - - useEffect(() => { - if (!walkthroughRequestPending || walkthroughRequestId === 0) { - return; - } - - let cancelled = false; - void Promise.resolve(interactiveRef.current?.onGenerateWalkthrough()) - .catch(() => {}) - .finally(() => { - if (cancelled) { - return; - } - walkthroughRequestPendingRef.current = false; - setWalkthroughRequestPending(false); - }); - return () => { - cancelled = true; - }; - }, [walkthroughRequestId, walkthroughRequestPending]); - - const startWalkthroughGeneration = useCallback(() => { - if ( - !interactive || - interactive.walkthroughStatus === 'generating' || - walkthroughRequestPendingRef.current - ) { - return; - } - - walkthroughRequestPendingRef.current = true; - setWalkthroughRequestPending(true); - setWalkthroughRequestId((current) => current + 1); - }, [interactive]); - useEffect(() => { - if (sidebarMode === 'walkthrough' && interactive?.walkthroughStatus === 'idle') { - startWalkthroughGeneration(); - } - }, [interactive?.walkthroughStatus, sidebarMode, startWalkthroughGeneration]); - useEffect(() => { - if (sidebarMode !== 'comments' || generalComments.length === 0) { - return; - } - - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - isNativeInputTarget(event.target) - ) { - return; - } - - const key = event.key.toLowerCase(); - if (key !== 'j' && key !== 'k') { - return; - } - - event.preventDefault(); - navigateGeneralComment(key === 'j' ? 1 : -1); - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [generalComments.length, navigateGeneralComment, sidebarMode]); - const submitGeneralComment = useCallback(() => { - const body = generalCommentDraft.trim(); - if (!submitGeneralDiscussion || !body || generalCommentSubmitting) { - return; - } - - setGeneralCommentError(null); - setGeneralCommentSubmitting(true); - void Promise.resolve(submitGeneralDiscussion(body)) - .then(() => setGeneralCommentDraft('')) - .catch((error: unknown) => { - setGeneralCommentError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setGeneralCommentSubmitting(false)); - }, [generalCommentDraft, generalCommentSubmitting, submitGeneralDiscussion]); - const startEditGeneralComment = useCallback((comment: PullRequestGeneralComment) => { - if (!comment.canEdit) { - return; - } - - setEditingGeneralCommentId(comment.id); - setGeneralCommentEditDraft(comment.body); - setGeneralCommentEditError(null); - }, []); - const cancelEditGeneralComment = useCallback(() => { - if (generalCommentEditSubmitting) { - return; - } - - setEditingGeneralCommentId(null); - setGeneralCommentEditDraft(''); - setGeneralCommentEditError(null); - }, [generalCommentEditSubmitting]); - const saveGeneralCommentEdit = useCallback(() => { - const commentId = editingGeneralCommentId; - const body = generalCommentEditDraft.trim(); - if (!updateGeneralDiscussion || !commentId || !body || generalCommentEditSubmitting) { - return; - } - - setGeneralCommentEditError(null); - setGeneralCommentEditSubmitting(true); - void Promise.resolve(updateGeneralDiscussion(commentId, body)) - .then(() => { - setEditingGeneralCommentId(null); - setGeneralCommentEditDraft(''); - }) - .catch((error: unknown) => { - setGeneralCommentEditError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setGeneralCommentEditSubmitting(false)); - }, [ - editingGeneralCommentId, - generalCommentEditDraft, - generalCommentEditSubmitting, - updateGeneralDiscussion, - ]); - const activateTreePath = useCallback( - (path: string) => { - setSelectedPath(path); - setTreeScrollTarget((current) => ({ - behavior: 'smooth', - path, - request: (current?.request ?? 0) + 1, - })); - }, - [setSelectedPath], - ); - const activateReviewComment = useCallback( - (comment: ReviewComment) => { - changeSidebarMode('tree'); - setSelectedPath(comment.filePath); - focusComment(comment.id); - setTreeScrollTarget((current) => ({ - behavior: 'smooth', - commentId: comment.id, - path: comment.filePath, - request: (current?.request ?? 0) + 1, - })); - }, - [changeSidebarMode, focusComment, setSelectedPath], - ); - const activateHashTarget = useCallback(() => { - const target = getLocationHashTarget(); - if (!target || handledHashTargetRef.current === target) { - return; - } - - const reviewComment = reviewComments.find((comment) => - commentMatchesHashTarget(comment, target), - ); - if (reviewComment) { - handledHashTargetRef.current = target; - activateReviewComment(reviewComment); - return; - } - - for (const thread of generalCommentThreads) { - const generalComment = - thread.comments.find((comment) => commentMatchesHashTarget(comment, target)) ?? - (thread.id === target ? thread.comments[0] : undefined); - if (generalComment) { - handledHashTargetRef.current = target; - activateGeneralComment(generalComment.id); - return; - } - } - }, [activateGeneralComment, activateReviewComment, generalCommentThreads, reviewComments]); - useEffect(() => { - const timeout = window.setTimeout(activateHashTarget, 0); - return () => window.clearTimeout(timeout); - }, [activateHashTarget]); - useEffect(() => { - const handleHashChange = () => { - handledHashTargetRef.current = null; - activateHashTarget(); - }; - window.addEventListener('hashchange', handleHashChange); - return () => window.removeEventListener('hashchange', handleHashChange); - }, [activateHashTarget]); - const updateSelectedPathFromScroll = useCallback( - (viewer: CodeViewInstance) => { - const nextPath = getSelectedPathFromScroll( - viewer, - visibleFiles, - snapshot.preferences.showWhitespace, - ); - - if (nextPath) { - setSelectedPath((current) => (current === nextPath ? current : nextPath)); - } - }, - [setSelectedPath, snapshot.preferences.showWhitespace, visibleFiles], - ); - - const diffLineHeight = getCodeFontLineHeight( - normalizeCodeFontSizePreference(snapshot.preferences.codeFontSize), - ); - const commonReviewProps = { - activeSearchMatch: null, - agentId: snapshot.walkthrough.agent, - agentLabel: getAgentLabel(snapshot.walkthrough.agent), - codeQualityFindings: snapshot.codeQualityFindings, - collapsed, - comments: reviewComments, - commitMetadata: null, - diffLineHeight, - diffStyle: snapshot.preferences.diffStyle, - disableWorkerPool: true, - expandedGenerated, - focusCommentId, - focusCommentRequest, - gitIdentity, - hunkNavigation: null, - initialMarkdownPreviewSectionIds, - isReadOnly: !canComment, - itemVersionByKey, - keymap, - loadingSectionIds: new Set(), - onCommentDraftChange: updateActiveReviewCommentDraft, - onCreateComment: createComment, - onDeleteComment: deleteComment, - onLoadSection: noop, - onResolveThread: resolveDiscussion ?? noop, - onSaveCommentEdit: updateExistingReviewComment, - onSelectPathFromScroll: noop, - onSubmitComment: submitComment, - onToggleCollapsed: toggleCollapsed, - onToggleViewed: toggleViewed, - onUpdateComment: updateComment, - onUpdateSourceDescription: interactive?.onUpdateDescription, - onUpdateSourceTitle: interactive?.onUpdateTitle, - onUploadSourceDescriptionAsset: interactive?.onUploadDescriptionAsset, - searchQuery: '', - showWhitespace: snapshot.preferences.showWhitespace, - source: snapshot.repository.source, - supportsReviewCommentActions: submitReviewComment != null, - theme: snapshot.preferences.theme, - viewed, - wordWrap: snapshot.preferences.wordWrap, - }; - const source = snapshot.repository.source; - const sourceMergeState = source.type === 'pull-request' ? source.mergeState : undefined; - const isTerminalMergeState = sourceMergeState - ? isTerminalPullRequestMergeState(sourceMergeState) - : false; - const sourceMergeStatusBadge = - sourceMergeState && isTerminalMergeState ? ( - - ) : null; - const sourceDescriptionActions = - interactive && source.type === 'pull-request' ? ( - 0 - } - onClosePullRequest={closePullRequest} - onMarkPullRequestReady={markPullRequestReady} - onSubmitReview={submitReview} - reviewStatus={source.reviewStatus} - showCommentReview={source.provider === 'github' || source.host === 'github.com'} - > - {sourceMergeStatusBadge} - - ) : sourceMergeStatusBadge ? ( -
- {sourceMergeStatusBadge} -
- ) : undefined; - const sourceDescriptionFooterMain = - interactive && sourceMergeState && !isTerminalMergeState ? ( - - ) : undefined; - const sourceDescriptionFooter = - sourceDescriptionFooterMain && sourceDescriptionFooterAside ? ( -
-
{sourceDescriptionFooterMain}
-
{sourceDescriptionFooterAside}
-
- ) : ( - (sourceDescriptionFooterMain ?? sourceDescriptionFooterAside) - ); - const sourceDescription = - source.type === 'pull-request' ? ( - - ) : null; - - const renderWalkthroughDiffBlocks = ( - blocks: ReadonlyArray, - blockScrollTarget: WalkthroughBlockScrollTarget | null, - onActiveBlockChange: (blockId: string) => void, - ) => { - return ( - - ); - }; - - const sourceLabel = - snapshot.repository.source.type === 'working-tree' - ? null - : getSourceLabel(snapshot.repository.source); - const rootLabel = repositoryUrl - ? snapshot.repository.root - : abbreviateHomePath(snapshot.repository.root); - const sourceExternalUrl = - snapshot.repository.source.type === 'pull-request' - ? (externalUrl ?? snapshot.repository.source.url) - : null; - const repositoryLinkUrl = repositoryUrl ?? sourceExternalUrl; - const walkthroughStatus = - walkthroughRequestPending && interactive?.walkthroughStatus !== 'ready' - ? 'generating' - : interactive?.walkthroughStatus; - const [walkthroughProgressRevision, setWalkthroughProgressRevision] = useState(0); - const previousWalkthroughStatusRef = useRef(walkthroughStatus); - useEffect(() => { - if ( - walkthroughStatus === 'generating' && - previousWalkthroughStatusRef.current !== 'generating' - ) { - setWalkthroughProgressRevision((current) => current + 1); - } - previousWalkthroughStatusRef.current = walkthroughStatus; - }, [walkthroughStatus]); - const walkthroughReady = !interactive || walkthroughStatus === 'ready'; - const walkthroughFailed = walkthroughStatus === 'failed'; - const walkthroughStatusTitle = walkthroughFailed - ? 'Walkthrough unavailable' - : 'Generating walkthroughโ€ฆ'; - const walkthroughStatusDescription = walkthroughFailed - ? (interactive?.walkthroughError ?? 'Fix the generation issue, then try again.') - : null; - const shellTheme = - snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; - const requestWalkthrough = () => { - startWalkthroughGeneration(); - }; - const reviewModes = [ - { - icon: , - label: 'Walkthrough', - value: 'walkthrough', - }, - { - icon: , - label: 'Tree', - value: 'tree', - }, - ...(showCommentsTab - ? [ - { - ariaLabel: generalCommentCount > 0 ? `Comments (${generalCommentCount})` : 'Comments', - icon: , - indicator: - generalCommentCount > 0 ? ( - - {generalCommentCount} - - ) : undefined, - label: 'Comments', - title: - generalCommentCount > 0 - ? `${generalCommentCount} ${generalCommentCount === 1 ? 'comment' : 'comments'}` - : 'Comments', - value: 'comments' as const, - }, - ] - : []), - ] satisfies ReadonlyArray>; - const topBarActions = - onDeleteShare || settingsBar ? ( - <> - {onDeleteShare ? ( - - ) : null} - {settingsBar ?
{settingsBar}
: null} - - ) : undefined; - - return ( -
- - {snapshot.branch ? ( - - {snapshot.branch} - - ) : null} - {sourceLabel ? ( - sourceExternalUrl ? ( - - {sourceLabel} - - - ) : ( - {sourceLabel} - ) - ) : null} - - } - leading={ - interactive ? ( - - ) : undefined - } - mode={sidebarMode} - modes={reviewModes} - onModeChange={changeSidebarMode} - onToggleSidebar={toggleSidebar} - repository={ - repositoryLinkUrl ? ( - - {rootLabel} - - ) : ( - {rootLabel} - ) - } - repositoryTooltip={snapshot.repository.root} - sidebarCollapsed={sidebarCollapsed ?? false} - sidebarPosition={sidebarPosition} - toggleTitle={`${sidebarCollapsed ? 'Expand' : 'Collapse'} sidebar`} - /> - -
-
- {sidebarMode === 'comments' ? ( - - ) : sidebarMode === 'tree' ? ( - visibleFiles.length === 0 ? ( -
-
- No matching files - {fileSearchQuery} -
-
- ) : ( - - ) - ) : walkthroughReady ? ( - - ) : walkthroughFailed ? ( -
-
- {walkthroughStatusTitle} -

{walkthroughStatusDescription}

-
- -
-
-
- ) : ( -
- -
- )} -
-
- ); -} diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index 23ecb3b5..1173a818 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -1115,7 +1115,7 @@ test('read-only walkthroughs can opt into the viewed control', async () => { }); const viewedButton = container.querySelector('.codiff-viewed-button'); expect(viewedButton).not.toBeNull(); - expect(container.querySelector('.codiff-button')).toBeNull(); + expect(container.querySelector('[title="Open file in editor"]')).not.toBeNull(); await act(async () => { viewedButton?.click(); }); @@ -1471,7 +1471,10 @@ test('review comment typing stays local until a comment action commits it', asyn askButton.click(); }); expect(onUpdateComment).toHaveBeenCalledWith('comment-1', 'Please check this.'); - expect(onAskCodex).toHaveBeenCalledWith('comment-1'); + expect(onAskCodex).toHaveBeenCalledWith({ + ...comment, + body: 'Please check this.', + }); }); const renderLocalReviewComment = async ({ @@ -1753,7 +1756,9 @@ test('local review comments ask the agent with Mod+Alt+Enter', async () => { }; await setInputValue(textarea, 'Explain this change.'); await pressCommentShortcut(textarea, true); - expect(onAskCodex).toHaveBeenCalledWith('comment-1'); + expect(onAskCodex).toHaveBeenCalledWith( + expect.objectContaining({ body: 'Explain this change.', id: 'comment-1' }), + ); expect(onUpdateComment).toHaveBeenCalledWith('comment-1', 'Explain this change.'); expect(document.activeElement).toBe(textarea); }); diff --git a/core/__tests__/ReviewSurface-capabilities.test.tsx b/core/__tests__/ReviewSurface-capabilities.test.tsx new file mode 100644 index 00000000..5ee56e93 --- /dev/null +++ b/core/__tests__/ReviewSurface-capabilities.test.tsx @@ -0,0 +1,983 @@ +/** + * @vitest-environment jsdom + */ + +import { act, useState, type Dispatch, type SetStateAction } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, expectTypeOf, test, vi } from 'vite-plus/test'; +import { createDefaultConfig } from '../config/defaults.ts'; +import { getShortcutLabel } from '../config/keymap.ts'; +import type { ReviewComment } from '../lib/app-types.ts'; +import { + buildSharedReviewSnapshot, + ReviewSurface, + type ProviderReviewCommentCapabilities, + type ReviewSurfaceCommandBridge, + type ReviewSurfaceProps, + type ShareReviewCommentCapabilities, +} from '../ReviewSurface.tsx'; +import type { + CommitMetadata, + HistoryEntry, + NarrativeWalkthrough, + RepositoryState, + SharedWalkthroughSnapshot, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { waitFor } from './helpers/react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; + +const snapshot = { + branch: 'main', + codiffVersion: 'test', + exportedAt: '2026-08-04T00:00:00.000Z', + files: [createChangedFile('src/app.ts')], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, + }, + repository: { root: '/repo', source: { type: 'working-tree' } }, + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Review capability boundaries.', + generatedAt: '2026-08-04T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'main', root: '/repo' }, + source: { type: 'working-tree' }, + support: [], + title: 'Capability review', + version: 4, + }, +} satisfies SharedWalkthroughSnapshot; + +const createShareComments = ( + overrides: Partial = {}, +): ShareReviewCommentCapabilities => ({ + authoring: {}, + destination: 'share', + inline: {}, + ...overrides, +}); + +const createProviderComments = ( + overrides: Partial = {}, +): ProviderReviewCommentCapabilities => ({ + authoring: {}, + destination: 'provider', + inline: {}, + ...overrides, +}); + +const providerSnapshot = { + ...snapshot, + repository: { + root: '/repo', + source: { + number: 7, + owner: 'cloudflare', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/cloudflare/codiff/pull/7', + }, + }, + walkthrough: { + ...snapshot.walkthrough, + source: { + number: 7, + owner: 'cloudflare', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/cloudflare/codiff/pull/7', + }, + }, +} satisfies SharedWalkthroughSnapshot; + +type WithOptionalSnapshot = Props extends unknown + ? Omit & { snapshot?: SharedWalkthroughSnapshot } + : never; +type OptionalSnapshotProps = WithOptionalSnapshot; + +const renderSurface = async (props: OptionalSnapshotProps = {}) => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const render = async (nextProps: OptionalSnapshotProps) => { + const { snapshot: nextSnapshot = snapshot, ...rest } = nextProps; + await act(async () => root.render()); + }; + await render(props); + return { + container, + render, + async [Symbol.asyncDispose]() { + await act(async () => root.unmount()); + container.remove(); + }, + }; +}; + +const findButton = (container: HTMLElement, label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ); + +const findModeButton = (container: HTMLElement, label: string) => + Array.from(container.querySelectorAll('button[role="tab"]')).find((button) => + (button.getAttribute('aria-label') ?? button.textContent?.trim())?.startsWith(label), + ); + +const openCommandBar = async () => { + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 'p' })); + }); +}; + +test('omits modes and commands whose capabilities are absent', async () => { + await using view = await renderSurface({ + initialMode: 'tree', + keymap: { ...createDefaultConfig().keymap, commandBar: 'Ctrl+p' }, + }); + + const modes = Array.from(view.container.querySelectorAll('[role="tab"]')).map((tab) => + tab.textContent?.trim(), + ); + expect(modes).toEqual(['Walkthrough', 'Tree']); + + await openCommandBar(); + expect(view.container.querySelector('.command-bar')?.textContent).not.toContain('Host action'); +}); + +test('keeps a controlled mode unchanged until its parent supplies a new value', async () => { + const onModeChange = vi.fn(); + const history = { + currentSource: { type: 'working-tree' } as const, + entries: [], + hasMore: false, + loading: false, + onLoadMore: vi.fn(), + onSelectSource: vi.fn(), + }; + await using view = await renderSurface({ + activeMode: { onChange: onModeChange, value: 'tree' }, + capabilities: { history }, + }); + + await act(async () => findButton(view.container, 'History')?.click()); + expect(onModeChange).toHaveBeenCalledWith('history'); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('true'); + expect(findButton(view.container, 'History')?.getAttribute('aria-selected')).toBe('false'); + + await view.render({ + activeMode: { onChange: onModeChange, value: 'history' }, + capabilities: { history }, + }); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('false'); + expect(findButton(view.container, 'History')?.getAttribute('aria-selected')).toBe('true'); +}); + +test('uses initialMode only for a surface that owns its later mode changes', async () => { + const history = { + currentSource: { type: 'working-tree' } as const, + entries: [], + hasMore: false, + loading: false, + onLoadMore: vi.fn(), + onSelectSource: vi.fn(), + }; + await using view = await renderSurface({ capabilities: { history }, initialMode: 'history' }); + + expect(findButton(view.container, 'History')?.getAttribute('aria-selected')).toBe('true'); + await act(async () => findButton(view.container, 'Tree')?.click()); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('true'); +}); + +test('rejects simultaneous controlled and uncontrolled mode inputs at the type boundary', () => { + type ConflictingModeProps = { + activeMode: { onChange: (mode: 'tree') => void; value: 'tree' }; + initialMode: 'history'; + snapshot: SharedWalkthroughSnapshot; + }; + + expectTypeOf().not.toMatchTypeOf(); + + type ConflictingAnnotationProps = { + capabilities: { + comments: ShareReviewCommentCapabilities; + localReviewNotes: { canCreateInline: true }; + }; + snapshot: SharedWalkthroughSnapshot; + }; + expectTypeOf().not.toMatchTypeOf(); +}); + +test('exposes Comments through persisted-comment capabilities independent of source', async () => { + await using local = await renderSurface({ + capabilities: { localReviewNotes: { canCreateInline: true } }, + initialMode: 'tree', + }); + expect(findModeButton(local.container, 'Comments')).toBeUndefined(); + + await using share = await renderSurface({ + capabilities: { comments: createShareComments() }, + initialMode: 'tree', + }); + expect(findModeButton(share.container, 'Comments')).not.toBeUndefined(); + + await using provider = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'tree', + snapshot: providerSnapshot, + }); + expect(findModeButton(provider.container, 'Comments')).not.toBeUndefined(); + + await using readOnly = await renderSurface({ + initialMode: 'tree', + snapshot: { + ...snapshot, + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'Existing read-only comment.', + filePath: snapshot.files[0]!.path, + id: 'existing-read-only', + lineNumber: 1, + side: 'additions', + }, + ], + }, + }); + expect(findModeButton(readOnly.container, 'Comments')).not.toBeUndefined(); +}); + +test('composes implicit inline reply permission with the host reply capability', async () => { + const file = snapshot.files[0]!; + const comment = { + author: { login: 'reviewer' }, + body: 'Reply to this existing thread.', + filePath: file.path, + id: 'existing-comment', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions' as const, + threadId: 'existing-thread', + }; + const snapshotWithThread = { + ...snapshot, + reviewComments: [comment], + } satisfies SharedWalkthroughSnapshot; + const onSubmit = vi.fn(async () => { + throw new Error('Not used by this test.'); + }); + const capabilities = { + comments: createShareComments({ + authoring: { canCreateInline: true }, + inline: { onSubmit }, + }), + }; + await using view = await renderSurface({ + capabilities, + initialMode: 'tree', + snapshot: snapshotWithThread, + }); + + await waitFor(() => expect(findButton(view.container, 'Reply')).not.toBeUndefined()); + + await view.render({ + capabilities, + initialMode: 'tree', + snapshot: { + ...snapshotWithThread, + reviewComments: [{ ...comment, canReplyThread: false }], + }, + }); + await waitFor(() => expect(findButton(view.container, 'Reply')).toBeUndefined()); + + await view.render({ + capabilities: { + comments: createShareComments({ + authoring: { canCreateInline: true }, + inline: {}, + }), + }, + initialMode: 'tree', + snapshot: snapshotWithThread, + }); + await waitFor(() => expect(findButton(view.container, 'Reply')).toBeUndefined()); +}); + +test('copies local notes with the local label and Markdown heading', async () => { + const file = snapshot.files[0]!; + const draft = { + body: 'Keep this local note.', + filePath: file.path, + id: 'local-note', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + } satisfies ReviewComment; + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { + localReviewNotes: { + drafts: { onChange: vi.fn(), value: [draft] }, + }, + }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + }); + + const copyButton = view.container.querySelector( + 'button[aria-label="Copy Review Notes"]', + ); + expect(copyButton).not.toBeNull(); + const markdown = bridge.current?.copyPendingComments(); + expect(markdown).toContain('# Address these Review Notes'); + await act(async () => copyButton?.click()); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(markdown)); +}); + +test('copies provider drafts with the provider label and Markdown heading', async () => { + const file = providerSnapshot.files[0]!; + const draft = { + body: 'Submit this provider draft.', + filePath: file.path, + id: 'provider-draft', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + } satisfies ReviewComment; + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + authoring: { drafts: { onChange: vi.fn(), value: [draft] } }, + }), + }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: providerSnapshot, + }); + + const copyButton = view.container.querySelector( + 'button[aria-label="Copy Pending Review Comments"]', + ); + expect(copyButton).not.toBeNull(); + const markdown = bridge.current?.copyPendingComments(); + expect(markdown).toContain('# Address these Pending Review Comments'); + await act(async () => copyButton?.click()); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(markdown)); +}); + +test('shows the configured sidebar shortcut in the collapse tooltip', async () => { + const keymap = { ...createDefaultConfig().keymap, toggleSidebar: 'Alt+b' }; + await using view = await renderSurface({ initialMode: 'tree', keymap }); + const toggle = view.container.querySelector('.sidebar-toggle-button'); + expect(toggle?.title).toBe(`Collapse sidebar (${getShortcutLabel(keymap, 'toggleSidebar')})`); + await act(async () => toggle?.click()); + expect(toggle?.title).toBe(`Expand sidebar (${getShortcutLabel(keymap, 'toggleSidebar')})`); +}); + +test('shows the file filter only in Tree and keeps History unfiltered', async () => { + const entries = [ + { + author: 'Ada Lovelace', + committedAt: Date.now(), + parentShas: [], + sha: 'a'.repeat(40) as HistoryEntry['sha'], + subject: 'Fix parser', + }, + { + author: 'Grace Hopper', + committedAt: Date.now(), + parentShas: [], + sha: 'b'.repeat(40) as HistoryEntry['sha'], + subject: 'Update docs', + }, + ] satisfies ReadonlyArray; + const onLoadMore = vi.fn(); + await using view = await renderSurface({ + capabilities: { + history: { + currentSource: { type: 'working-tree' }, + entries, + hasMore: true, + loading: false, + onLoadMore, + onSelectSource: vi.fn(), + }, + }, + initialMode: 'tree', + }); + expect(view.container.querySelector('.sidebar-search')?.placeholder).toBe( + 'Filter files', + ); + await act(async () => findButton(view.container, 'Walkthrough')?.click()); + expect(view.container.querySelector('.sidebar-search')).toBeNull(); + await act(async () => findButton(view.container, 'History')?.click()); + expect(view.container.querySelector('.sidebar-search')).toBeNull(); + const historySubjects = () => + Array.from(view.container.querySelectorAll('.history-entry-subject')).map( + (element) => element.textContent, + ); + expect(historySubjects()).toEqual(['Uncommitted changes', 'Fix parser', 'Update docs']); + + await act(async () => { + view.container.querySelector('.history-list')?.dispatchEvent(new Event('scroll')); + }); + expect(onLoadMore).toHaveBeenCalledOnce(); +}); + +test('preserves structured walkthrough failure metadata', async () => { + await using view = await renderSurface({ + capabilities: { + walkthrough: { + error: { code: 'PI_NOT_FOUND', reason: 'Pi CLI was not found.' }, + status: 'failed', + }, + }, + initialMode: 'walkthrough', + }); + expect(view.container.textContent).toContain('Pi CLI was not found.'); +}); + +test('composes host commands while keeping controlled preferences authoritative', async () => { + const onModeChange = vi.fn(); + const onWordWrapChange = vi.fn(); + const hostAction = vi.fn(); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + activeMode: { onChange: onModeChange, value: 'tree' }, + capabilities: { + comments: createProviderComments({ onSignIn: () => {} }), + desktop: { + commands: [{ execute: hostAction, id: 'host-action', title: 'Host action' }], + }, + history: { + currentSource: { type: 'working-tree' }, + entries: [], + hasMore: false, + loading: false, + onLoadMore: () => {}, + onSelectSource: () => {}, + }, + preferences: { + wordWrap: { onChange: onWordWrapChange, value: true }, + }, + }, + keymap: { + ...createDefaultConfig().keymap, + commandBar: 'Ctrl+p', + toggleWordWrap: 'Ctrl+Alt+w', + }, + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: providerSnapshot, + }); + + expect( + Array.from(view.container.querySelectorAll('[role="tab"]')).map((tab) => + tab.textContent?.trim(), + ), + ).toEqual(['Walkthrough', 'Tree', 'History', 'Comments']); + + await act(async () => findButton(view.container, 'Comments')?.click()); + expect(onModeChange).toHaveBeenCalledWith('comments'); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('true'); + expect(bridge.current?.getPersistenceState().mode).toBe('tree'); + + await openCommandBar(); + await act(async () => findButton(view.container, 'Host action')?.click()); + expect(hostAction).toHaveBeenCalledTimes(1); + + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { altKey: true, bubbles: true, ctrlKey: true, key: 'w' }), + ); + }); + expect(onWordWrapChange).toHaveBeenCalledWith(false); +}); + +test('forwards controlled draft updates atomically across an asynchronous submission', async () => { + const file = snapshot.files[0]!; + const firstDraft: ReviewComment = { + body: 'First draft', + filePath: file.path, + id: 'draft-1', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + }; + const secondDraft: ReviewComment = { + ...firstDraft, + body: 'Second draft', + id: 'draft-2', + }; + let completeSubmission!: ( + comment: import('../types.ts').PullRequestExistingReviewComment, + ) => void; + const submission = new Promise( + (resolve) => { + completeSubmission = resolve; + }, + ); + let setDrafts!: Dispatch>>; + let latestDrafts: ReadonlyArray = []; + + function ControlledSurface() { + const [drafts, updateDrafts] = useState>([firstDraft]); + setDrafts = updateDrafts; + latestDrafts = drafts; + return ( + submission }, + }), + }} + initialMode="tree" + snapshot={providerSnapshot} + /> + ); + } + + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render()); + const commentButton = findButton(container, 'Comment'); + expect(commentButton).not.toBeUndefined(); + await act(async () => commentButton?.click()); + await act(async () => setDrafts((current) => [...current, secondDraft])); + await act(async () => + completeSubmission({ + author: { login: 'ada', name: 'Ada' }, + body: firstDraft.body, + filePath: firstDraft.filePath, + id: 'submitted-1', + lineNumber: firstDraft.lineNumber, + sectionId: firstDraft.sectionId, + side: firstDraft.side, + }), + ); + await waitFor(() => expect(latestDrafts.map(({ id }) => id)).toEqual(['submitted-1', 'draft-2'])); + await act(async () => root.unmount()); + container.remove(); +}); + +test('preloads deferred files before applying diff-content search results', async () => { + const deferredFile = { + ...createChangedFile('src/lazy.ts', { kind: 'pull-request', patch: '' }), + sections: [ + { + binary: false, + id: 'src/lazy.ts:pull-request', + kind: 'pull-request', + loadState: 'deferred', + patch: '', + summary: { canLoad: true, reason: 'Load exact contents.' }, + }, + ], + } satisfies SharedWalkthroughSnapshot['files'][number]; + const lazySnapshot = { + ...snapshot, + files: [deferredFile], + repository: { + root: '/repo', + source: { + number: 7, + owner: 'cloudflare', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/cloudflare/codiff/pull/7', + }, + }, + } satisfies SharedWalkthroughSnapshot; + const onLoadSection = vi.fn(); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { content: { onLoadSection } }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: lazySnapshot, + }); + + await act(async () => bridge.current?.openDiffSearch()); + const input = view.container.querySelector('.diff-search-input'); + expect(input).not.toBeNull(); + const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + await act(async () => { + setInputValue?.call(input, 'needle'); + input?.dispatchEvent(new Event('input', { bubbles: true })); + }); + await waitFor(() => + expect(onLoadSection).toHaveBeenCalledWith(deferredFile, deferredFile.sections[0]), + ); + expect(view.container.querySelector('.empty-panel')?.textContent).toContain( + 'No matches in diffs', + ); + + const loadedFile = { + ...deferredFile, + sections: [ + { + ...deferredFile.sections[0], + loadState: 'ready', + newFile: { contents: 'const needle = true;\n', name: deferredFile.path }, + oldFile: { contents: 'const value = false;\n', name: deferredFile.path }, + patch: '@@ -1 +1 @@\n-const value = false;\n+const needle = true;\n', + }, + ], + } satisfies SharedWalkthroughSnapshot['files'][number]; + await view.render({ + capabilities: { content: { onLoadSection } }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: { ...lazySnapshot, files: [loadedFile] }, + }); + await waitFor(() => expect(view.container.querySelector('.empty-panel')).toBeNull()); + expect(view.container.textContent).toContain('src/lazy.ts'); +}); + +test('bounds deferred diff-search preloads to three concurrent section loads', async () => { + const files = Array.from({ length: 100 }, (_, index) => { + const path = `src/lazy-${index}.ts`; + return { + ...createChangedFile(path, { kind: 'pull-request', patch: '' }), + sections: [ + { + binary: false, + id: `${path}:pull-request`, + kind: 'pull-request' as const, + loadState: 'deferred' as const, + patch: '', + summary: { canLoad: true, reason: 'Load exact contents.' }, + }, + ], + }; + }) satisfies ReadonlyArray; + const lazySnapshot = { + ...snapshot, + files, + repository: { + root: '/repo', + source: { + number: 7, + owner: 'cloudflare', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/cloudflare/codiff/pull/7', + }, + }, + } satisfies SharedWalkthroughSnapshot; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const onLoadSection = vi.fn(() => gate); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { content: { onLoadSection } }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: lazySnapshot, + }); + + await act(async () => bridge.current?.openDiffSearch()); + const input = view.container.querySelector('.diff-search-input'); + const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + await act(async () => { + setInputValue?.call(input, 'needle'); + input?.dispatchEvent(new Event('input', { bubbles: true })); + }); + await waitFor(() => expect(onLoadSection).toHaveBeenCalledTimes(3)); + expect(onLoadSection).toHaveBeenCalledTimes(3); + + await act(async () => release()); + await waitFor(() => expect(onLoadSection).toHaveBeenCalledTimes(100)); +}); + +test('tracks controlled collapsed and viewed state across same-source rerenders', async () => { + const file = snapshot.files[0]!; + const capabilities = { + desktop: { + collapsed: new Set(), + onCollapsedChange: vi.fn(), + onViewedChange: vi.fn(), + viewed: {}, + }, + } satisfies NonNullable; + await using view = await renderSurface({ capabilities, initialMode: 'tree' }); + expect( + view.container.querySelector('[aria-label="Collapse file"]')?.getAttribute('aria-expanded'), + ).toBe('true'); + + await view.render({ + capabilities: { + desktop: { + ...capabilities.desktop, + collapsed: new Set([file.path]), + viewed: { [file.path]: file.fingerprint }, + }, + }, + initialMode: 'tree', + }); + expect( + view.container.querySelector('[aria-label="Expand file"]')?.getAttribute('aria-expanded'), + ).toBe('false'); + const tree = view.container.querySelector('file-tree-container'); + const viewedStyle = tree?.shadowRoot?.querySelector( + 'style[data-codiff-viewed-rows]', + ); + expect(viewedStyle?.textContent).toContain(file.path); +}); + +test('distinguishes web, windowed desktop, and fullscreen desktop shell spacing', async () => { + await using web = await renderSurface(); + expect(web.container.querySelector('.app-shell')?.className).toContain('share-shell'); + expect(web.container.querySelector('.app-shell')?.className).not.toContain('merge-request-shell'); + + await using desktop = await renderSurface({ capabilities: { desktop: {} } }); + expect(desktop.container.querySelector('.app-shell')?.className).toContain('merge-request-shell'); + expect(desktop.container.querySelector('.app-shell')?.className).not.toContain( + 'window-fullscreen', + ); + + await using fullscreen = await renderSurface({ + capabilities: { desktop: { isWindowFullscreen: true } }, + }); + expect(fullscreen.container.querySelector('.app-shell')?.className).toContain( + 'window-fullscreen', + ); +}); + +test('preserves generated commit text when desktop commit capability is enabled', async () => { + const file = snapshot.files[0]!; + const seededSnapshot = { + ...snapshot, + walkthrough: { + ...snapshot.walkthrough, + chapters: [ + { + blurb: 'Review the implementation.', + icon: 'gear', + id: 'implementation', + stops: [ + { + added: 1, + deleted: 1, + hunkIds: [`${file.sections[0]!.id}:h1`], + hunks: [ + { + added: 1, + anchor: { + display: file.path, + sectionId: file.sections[0]!.id, + side: 'both', + }, + deleted: 1, + id: `${file.sections[0]!.id}:h1`, + path: file.path, + status: file.status, + }, + ], + id: 'implementation-path', + importance: 'critical', + prose: 'Review this file.', + title: 'Implementation path', + }, + ], + title: 'Implementation', + }, + ], + commit: { body: 'Keep the generated body.', title: 'Use the generated subject' }, + }, + } satisfies SharedWalkthroughSnapshot; + await using view = await renderSurface({ + capabilities: { + walkthrough: { + commit: async () => ({ sha: 'a'.repeat(40) as CommitMetadata['sha'], status: 'committed' }), + status: 'ready', + updateCommitMessage: async (request) => ({ + body: request.body, + status: 'ready', + subject: request.subject, + }), + }, + }, + snapshot: seededSnapshot, + }); + const commitAction = view.container.querySelector('.wt-toc-commit-action'); + expect(commitAction).not.toBeNull(); + await act(async () => commitAction?.click()); + await waitFor(() => { + expect(view.container.querySelector('.wt-commit-subject-field')?.value).toBe( + 'Use the generated subject', + ); + }); + expect(view.container.querySelector('.wt-commit-msg-input')?.value).toBe( + 'Keep the generated body.', + ); +}); + +test('copies commit metadata into snapshots and renders the commit card', async () => { + const sha = 'a'.repeat(40) as CommitMetadata['sha']; + const person = { + date: '2026-08-04T00:00:00.000Z', + email: 'ada@example.com', + name: 'Ada Lovelace', + }; + const commitMetadata = { + author: person, + body: 'Explain the immutable review target.', + committer: person, + files: [], + parentShas: [], + refs: ['main'], + sha, + shortSha: sha.slice(0, 7), + signature: { status: 'unsigned' }, + stats: { + additions: 1, + binaryFiles: 0, + deletions: 1, + files: 1, + renamedFiles: 0, + }, + subject: 'Preserve commit context', + trailers: [], + } satisfies CommitMetadata; + const state = { + branch: 'main', + commitMetadata, + files: snapshot.files, + generatedAt: Date.parse(snapshot.exportedAt), + launchPath: '/repo', + root: '/repo', + source: { sha, type: 'commit' }, + } satisfies RepositoryState; + const commitSnapshot = buildSharedReviewSnapshot({ + preferences: snapshot.preferences, + state, + title: 'Commit review', + walkthrough: { ...snapshot.walkthrough, source: state.source }, + }); + expect(commitSnapshot.commitMetadata).toEqual(commitMetadata); + + await using view = await renderSurface({ initialMode: 'tree', snapshot: commitSnapshot }); + await waitFor(() => expect(view.container.textContent).toContain('Preserve commit context')); + expect(view.container.textContent).toContain('Explain the immutable review target.'); + expect(view.container.textContent).toContain('Ada Lovelace'); +}); + +test('forwards the active walkthrough review target to the desktop host', async () => { + const file = snapshot.files[0]!; + const walkthrough = { + ...snapshot.walkthrough, + chapters: [ + { + blurb: 'Review the implementation.', + icon: 'gear', + id: 'implementation', + stops: [ + { + added: 1, + deleted: 1, + hunkIds: [`${file.sections[0]!.id}:h1`], + hunks: [ + { + added: 1, + anchor: { + display: file.path, + sectionId: file.sections[0]!.id, + side: 'both', + }, + deleted: 1, + id: `${file.sections[0]!.id}:h1`, + path: file.path, + status: file.status, + }, + ], + id: 'implementation-path', + importance: 'critical', + prose: 'Review this file.', + title: 'Implementation path', + }, + ], + title: 'Implementation', + }, + ], + } satisfies NarrativeWalkthrough; + const onTargetChange = vi.fn(); + await using view = await renderSurface({ + capabilities: { + desktop: { onActiveWalkthroughReviewTargetChange: onTargetChange }, + }, + initialMode: 'walkthrough', + snapshot: { ...snapshot, walkthrough }, + }); + await act(async () => findButton(view.container, 'Implementation path')?.click()); + await waitFor(() => + expect(onTargetChange).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ path: file.path }), + reviewIdentity: expect.objectContaining({ key: expect.any(String) }), + }), + ), + ); +}); + +test('distinguishes an empty source from filtered files', async () => { + await using view = await renderSurface({ + initialMode: 'tree', + snapshot: { ...snapshot, files: [] }, + }); + expect(view.container.querySelector('.empty-panel')?.textContent).toContain('No local changes'); + expect(view.container.querySelector('.empty-panel')?.textContent).toContain('/repo'); +}); diff --git a/core/__tests__/SharedWalkthroughApp.test.tsx b/core/__tests__/ReviewSurface.test.tsx similarity index 62% rename from core/__tests__/SharedWalkthroughApp.test.tsx rename to core/__tests__/ReviewSurface.test.tsx index 23a3c2c3..09189ff9 100644 --- a/core/__tests__/SharedWalkthroughApp.test.tsx +++ b/core/__tests__/ReviewSurface.test.tsx @@ -6,10 +6,10 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { expect, test, vi } from 'vite-plus/test'; import { ReviewTopBar } from '../app/components/ReviewTopBar.tsx'; -import { ReviewSurface, type ReviewCommenting } from '../react.ts'; +import { ReviewSurface, type ReviewCommenting } from '../ReviewSurface.tsx'; import type { NarrativeWalkthrough, SharedWalkthroughSnapshot } from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; -import { renderReact, waitFor } from './helpers/react.tsx'; +import { waitFor } from './helpers/react.tsx'; const reactActEnvironment = globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserver; @@ -65,258 +65,70 @@ const commenting = { onUpdateGeneralComment: async () => {}, } satisfies ReviewCommenting; -const sharedWalkthroughSource = { type: 'working-tree' } as const; -const sharedWalkthroughSnapshot = { - branch: 'main', - codiffVersion: '1.4.1', - exportedAt: '2026-06-19T00:00:00.000Z', - files: [createChangedFile('src/app.ts')], - kind: 'codiff-walkthrough-share', - preferences: { - codeFontFamily: 'Fira Code', - codeFontSize: 13, - diffStyle: 'split', - showWhitespace: false, - theme: 'system', - wordWrap: false, - }, - repository: { root: '/Users/ada/dev/codiff-web', source: sharedWalkthroughSource }, - version: 1, - walkthrough: { - agent: 'codex', - chapters: [], - focus: 'Focus on the implementation.', - generatedAt: '2026-06-19T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/Users/ada/dev/codiff-web' }, - source: sharedWalkthroughSource, - support: [], - title: 'Shared walkthrough', - version: 4, - }, -} satisfies SharedWalkthroughSnapshot; +test('review top bar renders its leading control at the far left', async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); -test.each([ - { iconTransform: null, position: 'left' }, - { iconTransform: 'scale(-1, 1)', position: 'right' }, -] as const)('review top bar places its sidebar control on the $position', async (testCase) => { - await using view = await renderReact( - - Codiff - - } - mode="tree" - modes={[{ icon: null, label: 'Tree', value: 'tree' }]} - onModeChange={() => {}} - onToggleSidebar={() => {}} - repository="cloudflare/voidzero/codiff-web" - sidebarCollapsed={false} - sidebarPosition={testCase.position} - toggleTitle="Collapse sidebar" - />, - ); + await act(async () => { + root.render( + Codiff} + mode="tree" + modes={[{ icon: null, label: 'Tree', value: 'tree' }]} + onModeChange={() => {}} + onToggleSidebar={() => {}} + repository="cloudflare/voidzero/codiff-web" + sidebarCollapsed={false} + toggleTitle="Collapse sidebar" + />, + ); + }); - const leftRegion = view.container.querySelector('.review-top-bar-left'); + const leftRegion = container.querySelector('.review-top-bar-left'); expect(leftRegion?.firstElementChild?.className).toBe('codiff-logo'); expect(leftRegion?.nextElementSibling?.classList.contains('review-mode-control')).toBe(true); expect(leftRegion?.nextElementSibling?.nextElementSibling?.className).toBe( 'review-top-bar-right', ); - const toggle = view.container.querySelector( - `.review-top-bar-${testCase.position} > .sidebar-toggle-button`, - ); - expect(toggle).not.toBeNull(); - expect(toggle?.querySelector('svg')?.getAttribute('transform')).toBe(testCase.iconTransform); -}); - -test.each([ - { columns: '292px 0 minmax(0, 1fr)', position: 'left' }, - { columns: 'minmax(0, 1fr) 0 292px', position: 'right' }, -] as const)('$position review surface layout', async (testCase) => { - window.localStorage.clear(); - await using view = await renderReact( - , - ); - const shell = view.container.querySelector('.app-shell'); - expect(shell?.dataset.sidebarPosition).toBe(testCase.position); - await waitFor(() => { - expect(shell?.style.gridTemplateColumns).toBe(testCase.columns); - }); -}); - -test('review surface starts with the sidebar collapsed on mobile viewports', async () => { - window.localStorage.clear(); - const matchMedia = vi.spyOn(window, 'matchMedia').mockImplementation( - (query) => - ({ - addEventListener() {}, - addListener() {}, - dispatchEvent: () => false, - matches: query === '(max-width: 720px)', - media: query, - onchange: null, - removeEventListener() {}, - removeListener() {}, - }) as MediaQueryList, - ); - - try { - await using view = await renderReact(); - - await waitFor(() => { - expect( - view.container.querySelector('.app-shell')?.classList.contains('sidebar-collapsed'), - ).toBe(true); - expect( - view.container - .querySelector('.sidebar-toggle-button') - ?.getAttribute('aria-label'), - ).toBe('Expand sidebar'); - }); - - await act(async () => { - view.container.querySelector('.sidebar-toggle-button')?.click(); - }); - expect( - view.container.querySelector('.app-shell')?.classList.contains('sidebar-collapsed'), - ).toBe(false); - expect( - JSON.parse(window.localStorage.getItem('codiff:web-review-surface-preferences:v1') ?? '{}'), - ).toEqual({ sidebarCollapsed: false }); - - await using persistedView = await renderReact( - , - ); - await waitFor(() => { - expect( - persistedView.container - .querySelector('.app-shell') - ?.classList.contains('sidebar-collapsed'), - ).toBe(false); - }); - } finally { - window.localStorage.clear(); - matchMedia.mockRestore(); - } + await act(async () => root.unmount()); + container.remove(); }); -test('a resolved general discussion stays collapsed unless its hash targets the thread', async () => { - let finishResolve: (() => void) | null = null; - const onResolveDiscussion = vi.fn( - () => - new Promise((resolve) => { - finishResolve = resolve; - }), - ); - const resolvedSnapshot = { - ...sharedWalkthroughSnapshot, - repository: { - ...sharedWalkthroughSnapshot.repository, - generalComments: [ - { - canReply: true, - canResolve: true, - comments: [ - { - author: { login: 'reviewer' }, - body: 'This conversation is resolved.', - id: 'gitlab:200', - url: 'https://gitlab.example.com/group/project/-/merge_requests/1#note_200', - }, - ], - id: 'general-discussion', - isResolved: true, - }, - ], +test('share viewer shows the complete repository path when there is no repository link', async () => { + const file = createChangedFile('src/app.ts'); + const source = { type: 'working-tree' } as const; + const snapshot = { + branch: 'main', + codiffVersion: '1.4.1', + exportedAt: '2026-06-19T00:00:00.000Z', + files: [file], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, + }, + repository: { root: '/Users/ada/dev/codiff-web', source }, + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Focus on the implementation.', + generatedAt: '2026-06-19T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'main', root: '/Users/ada/dev/codiff-web' }, + source, + support: [], + title: 'Shared walkthrough', + version: 4, }, } satisfies SharedWalkthroughSnapshot; - window.history.replaceState(null, '', '/review'); - await using collapsedView = await renderReact( - , - ); - expect( - collapsedView.container - .querySelector('.resolved-thread-toggle') - ?.getAttribute('aria-expanded'), - ).toBe('false'); - expect(collapsedView.container.querySelector('.resolved-thread-content')).toBeNull(); - - window.history.replaceState(null, '', '/review#general-discussion'); - window.dispatchEvent(new HashChangeEvent('hashchange')); - await waitFor(() => { - expect( - collapsedView.container - .querySelector('.resolved-thread-toggle') - ?.getAttribute('aria-expanded'), - ).toBe('true'); - expect(collapsedView.container.textContent).toContain('This conversation is resolved.'); - }); - - const reopen = [...collapsedView.container.querySelectorAll('button')].find( - (button) => button.textContent === 'Reopen', - ); - await act(async () => reopen?.click()); - expect(onResolveDiscussion).toHaveBeenCalledWith('general-discussion', false); - expect(collapsedView.container.querySelector('.resolved-thread-toggle')).toBeNull(); - expect( - [...collapsedView.container.querySelectorAll('button')].some( - (button) => button.textContent === 'Reply', - ), - ).toBe(false); - - await act(async () => finishResolve?.()); - expect( - [...collapsedView.container.querySelectorAll('button')].some( - (button) => button.textContent === 'Reply', - ), - ).toBe(true); - window.history.replaceState(null, '', '/'); -}); - -test('a resolved inline discussion expands when its note hash is targeted', async () => { - const resolvedSnapshot = { - ...sharedWalkthroughSnapshot, - reviewComments: [ - { - author: { login: 'reviewer' }, - body: 'This inline conversation is resolved.', - canResolveThread: true, - filePath: 'src/app.ts', - id: 'gitlab:99', - isThreadResolved: true, - lineNumber: 1, - side: 'additions', - threadId: 'line-discussion', - url: 'https://gitlab.example.com/group/project/-/merge_requests/1#note_99', - }, - ], - } satisfies SharedWalkthroughSnapshot; - - window.history.replaceState(null, '', '/review#note_99'); - await using view = await renderReact( - , - ); - - await waitFor(() => { - expect( - view.container - .querySelector('.resolved-thread-toggle') - ?.getAttribute('aria-expanded'), - ).toBe('true'); - expect(view.container.textContent).toContain('This inline conversation is resolved.'); - }); - window.history.replaceState(null, '', '/'); -}); - -test('share viewer shows the complete repository path when there is no repository link', async () => { const container = document.createElement('div'); document.body.append(container); let root: Root | null = null; @@ -330,9 +142,7 @@ test('share viewer shows the complete repository path when there is no repositor }; await act(async () => { root = createRoot(container); - root.render( - , - ); + root.render(); }); await waitFor(() => { @@ -437,7 +247,26 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy root = createRoot(container); root.render( { expect(container.querySelector('.walkthrough-list')).not.toBeNull(); }); - const searchInput = container.querySelector('.sidebar-search'); - expect(searchInput).not.toBeNull(); + expect(container.querySelector('.sidebar-search')).toBeNull(); const deleteShare = container.querySelector( 'button[aria-label="Delete shared walkthrough"]', ); @@ -463,8 +291,6 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy confirmDelete.mockReturnValue(true); await act(async () => deleteShare?.click()); await waitFor(() => expect(onDeleteShare).toHaveBeenCalledOnce()); - expect(searchInput?.placeholder).toBe('Filter files'); - const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; const tablist = container.querySelector('[role="tablist"]'); expect(tablist?.classList.contains('review-mode-control')).toBe(true); const topBar = tablist?.closest('.review-top-bar'); @@ -510,6 +336,9 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy ).toBe(true); expect(tabs[0]?.getAttribute('aria-selected')).toBe('false'); expect(tabs[1]?.getAttribute('aria-selected')).toBe('true'); + const searchInput = container.querySelector('.sidebar-search'); + expect(searchInput?.placeholder).toBe('Filter files'); + const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; const sidebarToggle = container.querySelector( '.review-top-bar .sidebar-toggle-button', ); @@ -561,6 +390,7 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy await waitFor(() => { expect(container.querySelector('.walkthrough-list')).not.toBeNull(); expect(container.querySelector('.file-tree-shell')).toBeNull(); + expect(container.querySelector('.sidebar-search')).toBeNull(); }); }); diff --git a/core/__tests__/app-review-comment-hooks.test.tsx b/core/__tests__/app-review-comment-hooks.test.tsx index dcca9447..84623106 100644 --- a/core/__tests__/app-review-comment-hooks.test.tsx +++ b/core/__tests__/app-review-comment-hooks.test.tsx @@ -98,7 +98,7 @@ test('app review comments request and store assistant replies', async () => { getState().setReviewComments([comment]); }); await act(async () => { - getState().askCodex(comment.id); + getState().askCodex(comment); }); expect(askReviewAssistant).toHaveBeenCalledWith({ comment: { diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index a187da4e..d797c464 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -472,7 +472,7 @@ function PullRequestReviewAction({ hasPendingComments?: boolean; icon: ReactNode; label: string; - onSubmitReview: (event: PullRequestReviewEvent, body?: string) => Promise | void; + onSubmitReview?: (event: PullRequestReviewEvent, body?: string) => Promise | void; title: string; }) { const [body, setBody] = useState(''); @@ -676,14 +676,18 @@ export function PullRequestReviewButtons({ const approveBlocked = isPullRequestReviewActionDisabled(reviewStatus, 'APPROVE'); const commentBlocked = isPullRequestReviewActionDisabled(reviewStatus, 'COMMENT'); const requestChangesBlocked = isPullRequestReviewActionDisabled(reviewStatus, 'REQUEST_CHANGES'); + const canSubmitReview = onSubmitReview != null; const closeStatus = reviewStatus?.close; const closeVisible = onClosePullRequest && closeStatus && closeStatus.disabled !== true; const markReadyStatus = reviewStatus?.markReady; const markReadyVisible = onMarkPullRequestReady && markReadyStatus && markReadyStatus.disabled !== true; - const commentVisible = showCommentReview && !commentBlocked; + const commentVisible = canSubmitReview && showCommentReview && !commentBlocked; const hasReviewActions = - commentVisible || !approveBlocked || !requestChangesBlocked || markReadyVisible || closeVisible; + commentVisible || + (canSubmitReview && (!approveBlocked || !requestChangesBlocked)) || + markReadyVisible || + closeVisible; if (!hasReviewActions && !children) { return null; } @@ -708,11 +712,11 @@ export function PullRequestReviewButtons({ /> } label="Comment" - onSubmitReview={onSubmitReview} + onSubmitReview={onSubmitReview!} title={getPullRequestReviewActionTitle(reviewStatus, 'COMMENT', 'Submit review comments')} /> ) : null} - {!approveBlocked ? ( + {canSubmitReview && !approveBlocked ? ( } label="Approve" - onSubmitReview={onSubmitReview} + onSubmitReview={onSubmitReview!} title={getPullRequestReviewActionTitle(reviewStatus, 'APPROVE', 'Approve review')} /> ) : null} - {!requestChangesBlocked ? ( + {canSubmitReview && !requestChangesBlocked ? ( } label="Request Changes" - onSubmitReview={onSubmitReview} + onSubmitReview={onSubmitReview!} title={getPullRequestReviewActionTitle( reviewStatus, 'REQUEST_CHANGES', diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index e65b5647..d9408b74 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -220,7 +220,7 @@ function CodeViewHeader({ isSectionLoading: boolean; meta: CodeViewItemMetadata; onCreateFileComment: () => void; - onLoadSection: (file: ChangedFile, section: DiffSection) => void; + onLoadSection?: (file: ChangedFile, section: DiffSection) => void; onOpenFile?: (file: ChangedFile) => void; onToggleCollapsed: (file: ChangedFile, isCollapsed: boolean, reviewKey: string) => void; onToggleMarkdownPreview: (file: ChangedFile, section: DiffSection) => void; @@ -241,7 +241,7 @@ function CodeViewHeader({ walkthroughNote, } = meta; const canOpenFile = file.status !== 'deleted'; - const canLoadSection = shouldLoadDiffSectionContents(section); + const canLoadSection = onLoadSection != null && shouldLoadDiffSectionContents(section); return (
) : null} - {canLoadSection && !readOnly ? ( + {canLoadSection ? ( ) : null} - {!readOnly && onOpenFile ? ( + {onOpenFile ? ( ) : null} - {comment.canDelete ? ( + {canDelete ? ( + ) : null}
) : null} + {desktop?.sidebarFooter}
- {sidebarMode === 'comments' ? ( + {desktop?.commit?.open ? ( + + ) : sidebarMode === 'comments' ? ( ({ installed: true, path: '/skill' })), + installTerminalHelper = vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + repositoryPathProvided = false, +}: { + installAgentSkill?: ReturnType; + installTerminalHelper?: ReturnType; + repositoryPathProvided?: boolean; +} = {}) => { + const api = { + getAgentSkillStatus: vi.fn(async () => ({ installed: false, path: '/skill' })), + getConfig: vi.fn(async () => createDefaultConfig()), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => null), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided, walkthrough: false })), + getRepositoryState: vi.fn(async () => { + throw new Error('fatal: not a git repository'); + }), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: false, + path: '/usr/local/bin/codiff', + })), + installAgentSkill, + installTerminalHelper, + onConfigChanged: vi.fn(() => () => {}), + }; + window.codiff = api as unknown as Window['codiff']; + return api; +}; + +test('a non-repository launch renders first-run guidance and install actions', async () => { + const api = installWindowApi(); + await using view = await renderReact(); + + await waitFor(() => expect(view.container.textContent).toContain('Open a Git repository')); + expect(view.container.textContent).toContain('Install Terminal Helper'); + expect(view.container.textContent).toContain('Install Codex Skill'); + + await act(async () => + Array.from(view.container.querySelectorAll('button')) + .find((button) => button.textContent === 'Install Codex Skill') + ?.click(), + ); + await waitFor(() => expect(api.installAgentSkill).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(view.container.textContent).not.toContain('Install Codex Skill')); +}); + +test('an explicit invalid repository path renders the repository error instead of first-run', async () => { + installWindowApi({ repositoryPathProvided: true }); + await using view = await renderReact(); + + await waitFor(() => expect(view.container.textContent).toContain('No Git repository found')); + expect(view.container.textContent).not.toContain('Install Terminal Helper'); +}); + +test('terminal-helper install success exits first-run and failure restores the action', async () => { + const successfulInstall = vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })); + installWindowApi({ installTerminalHelper: successfulInstall }); + await using success = await renderReact(); + await waitFor(() => expect(success.container.textContent).toContain('Install Terminal Helper')); + await act(async () => + Array.from(success.container.querySelectorAll('button')) + .find((button) => button.textContent === 'Install Terminal Helper') + ?.click(), + ); + await waitFor(() => expect(successfulInstall).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(success.container.textContent).toContain('No Git repository found')); + + const failedInstall = vi.fn(async () => { + throw new Error('Install failed.'); + }); + installWindowApi({ installTerminalHelper: failedInstall }); + await using failure = await renderReact(); + await waitFor(() => expect(failure.container.textContent).toContain('Install Terminal Helper')); + await act(async () => + Array.from(failure.container.querySelectorAll('button')) + .find((button) => button.textContent === 'Install Terminal Helper') + ?.click(), + ); + await waitFor(() => expect(failedInstall).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(failure.container.textContent).toContain('Install Terminal Helper')); +}); diff --git a/core/__tests__/App-plan.test.tsx b/core/__tests__/App-plan.test.tsx new file mode 100644 index 00000000..8ae858eb --- /dev/null +++ b/core/__tests__/App-plan.test.tsx @@ -0,0 +1,486 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; +import App from '../App.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; +import type { GitSha, PlanCommentThread, PlanReview, RepositoryState } from '../types.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; + Worker?: typeof Worker; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; +class StubWorker extends EventTarget { + constructor(_scriptURL: string | URL, _options?: WorkerOptions) { + super(); + } + onerror = null; + onmessage = null; + postMessage() {} + terminate() {} +} +reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; + +const createMemoryStorage = (): Storage => { + const values = new Map(); + return { + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +}; + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createMemoryStorage(), +}); +Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: createMemoryStorage(), +}); + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); +}); + +const repositoryState = { + branch: 'main', + files: [], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' }, +} satisfies RepositoryState; + +const createCodiffMock = (overrides: Partial = {}): Window['codiff'] => ({ + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + askReviewAssistant: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + cancelDiffContentRequest: vi.fn(), + completePlan: vi.fn(async () => {}), + createWalkthroughCommit: vi.fn(async () => ({ + sha: '0'.repeat(40) as GitSha, + status: 'committed' as const, + })), + decreaseCodeFontSize: vi.fn(async () => {}), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + findDefinitions: vi.fn(async () => ({ + candidates: [], + identifier: '', + status: 'ready' as const, + })), + getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/skill' })), + getConfig: vi.fn(async () => createDefaultConfig()), + getDiffImageContent: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' as const })), + getDiffSectionContent: vi.fn(async () => { + throw new Error('Unexpected diff section load.'); + }), + getDiffSectionsContent: vi.fn(async () => ({ sections: [] })), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => ({ email: 'reviewer@example.com', name: 'Reviewer' })), + getKeyboardLayout: vi.fn(async () => null), + getLaunchOptions: vi.fn(async () => ({ + planFile: '/tmp/plan.md', + planResultFile: '/tmp/result.json', + repositoryPathProvided: true, + walkthrough: false, + })), + getMarkdownDocument: vi.fn(async () => ({ + content: '# Execute this plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getNarrativeWalkthrough: vi.fn(async () => ({ + reason: 'Not used.', + status: 'unavailable' as const, + })), + getPlanReview: vi.fn(async () => null), + getPreferences: vi.fn(async () => createDefaultConfig().settings), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState: vi.fn(async () => repositoryState), + getReviewComments: vi.fn(async () => []), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + increaseCodeFontSize: vi.fn(async () => {}), + installAgentSkill: vi.fn(async () => ({ installed: true, path: '/skill' })), + installTerminalHelper: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + isWindowFullScreen: vi.fn(async () => false), + markPlanReady: vi.fn(async () => {}), + onConfigChanged: vi.fn(() => () => {}), + onCopyPendingCommentsRequest: vi.fn(() => () => {}), + onFindInDiffs: vi.fn(() => () => {}), + onKeyboardLayoutChanged: vi.fn(() => () => {}), + onMarkdownDocumentChanged: vi.fn(() => () => {}), + onOpenReviewSource: vi.fn(() => () => {}), + onPlanCloseRequested: vi.fn(() => () => {}), + onRefreshRequest: vi.fn(() => () => {}), + onRepositoryChanged: vi.fn(() => () => {}), + onUpdateStatusChanged: vi.fn(() => () => {}), + onWalkthroughCommitOutput: vi.fn(() => () => {}), + onWalkthroughProgress: vi.fn(() => () => {}), + onWindowFullScreenChanged: vi.fn(() => () => {}), + openConfigFile: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + openReleasePage: vi.fn(async () => {}), + openRepositoryFolder: vi.fn(async () => {}), + resetCodeFontSize: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value) => value), + saveMarkdownDocument: vi.fn(async (request) => ({ + document: { + content: request.content, + id: `${request.kind}:${request.path}`, + kind: request.kind, + path: request.path, + version: 'next-version', + }, + status: 'saved' as const, + })), + savePlanReview: vi.fn(async (review) => review), + setDiffStyle: vi.fn(async () => {}), + setShowOutdated: vi.fn(async () => {}), + setWordWrap: vi.fn(async () => {}), + sharePlan: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/p/test', + })), + shareWalkthrough: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/w/test', + })), + showInFolder: vi.fn(async () => {}), + submitPullRequestComment: vi.fn(async () => { + throw new Error('Unexpected provider comment submission.'); + }), + submitPullRequestReview: vi.fn(async () => {}), + updateWalkthroughCommitMessage: vi.fn(async () => ({ + reason: 'Not used.', + status: 'unavailable' as const, + })), + ...overrides, +}); + +const author = { + email: 'reviewer@example.com', + id: 'reviewer@example.com', + name: 'Reviewer', +}; + +const createThread = ({ + body, + id, + path = [0], + text = 'Execute this plan', +}: { + body: string; + id: string; + path?: Array; + text?: string; +}): PlanCommentThread => ({ + anchor: { + block: { + fingerprint: `${id}-fingerprint`, + path, + text, + type: 'heading', + }, + kind: 'block', + version: 1, + }, + createdAt: '2026-06-24T00:00:00.000Z', + createdBy: author, + id, + messages: [ + { + author, + body, + createdAt: '2026-06-24T00:00:00.000Z', + id: `${id}-message`, + updatedAt: '2026-06-24T00:00:00.000Z', + }, + ], + status: 'open', + updatedAt: '2026-06-24T00:00:00.000Z', +}); + +const createReview = (threads: ReadonlyArray): PlanReview => ({ + document: { + id: 'plan:/tmp/plan.md', + path: '/tmp/plan.md', + version: 'plan-version', + }, + threads, + version: 1, +}); + +test('plan mode opens the Markdown editor without loading repository state', async () => { + const getRepositoryState = vi.fn(async () => repositoryState); + const completePlan = vi.fn(async (_review: PlanReview, _status: 'closed' | 'done') => {}); + const markPlanReady = vi.fn(async () => {}); + const sharePlan = vi.fn(async (_review: PlanReview) => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/p/shared-plan', + })); + const storedReview = createReview([ + createThread({ body: 'Keep the rollout steps explicit.', id: 'thread-1' }), + createThread({ body: ' ', id: 'empty-thread' }), + ]); + window.codiff = createCodiffMock({ + completePlan, + getFeatureFlags: vi.fn(async () => ({ planSharing: true, walkthroughSharing: false })), + getMarkdownDocument: vi.fn(async () => ({ + content: '# Execute this plan\n\n- First\n- Second\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getPlanReview: vi.fn(async () => storedReview), + getRepositoryState, + markPlanReady, + sharePlan, + }); + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('.plan-shell')).not.toBeNull()); + expect(getRepositoryState).not.toHaveBeenCalled(); + expect(markPlanReady).toHaveBeenCalledTimes(1); + expect(view.container.querySelector('.plan-title')?.textContent).toContain('plan.md'); + await waitFor(() => + expect(view.container.querySelector('.plan-comment-thread')?.textContent).toContain( + 'Keep the rollout steps explicit.', + ), + ); + + await act(async () => + view.container.querySelector('.plan-share-button')?.click(), + ); + await waitFor(() => expect(sharePlan).toHaveBeenCalledTimes(1)); + expect(sharePlan.mock.calls[0]?.[0].threads).toHaveLength(1); + + await act(async () => + view.container.querySelector('.plan-done-button')?.click(), + ); + await waitFor(() => expect(completePlan).toHaveBeenCalledTimes(1)); + expect(completePlan).toHaveBeenCalledWith( + expect.objectContaining({ + document: { + id: 'plan:/tmp/plan.md', + path: '/tmp/plan.md', + version: 'plan-version', + }, + threads: [expect.objectContaining({ id: 'thread-1' })], + version: 1, + }), + 'done', + ); +}); + +test('plan mode resolves stored comments whose anchors were already removed', async () => { + const savePlanReview = vi.fn(async (review: PlanReview) => review); + window.codiff = createCodiffMock({ + getMarkdownDocument: vi.fn(async () => ({ + content: '# Current plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getPlanReview: vi.fn(async () => + createReview([ + createThread({ + body: 'Keep this comment as history.', + id: 'detached-thread', + path: [99], + text: 'Removed heading', + }), + ]), + ), + savePlanReview, + }); + + await using view = await renderReact(); + await waitFor(() => + expect(savePlanReview).toHaveBeenCalledWith( + expect.objectContaining({ + threads: [ + expect.objectContaining({ + id: 'detached-thread', + resolution: expect.objectContaining({ reason: 'anchor-removed' }), + status: 'resolved', + }), + ], + }), + ), + ); + const resolvedSection = + view.container.querySelector('.plan-resolved-comments'); + expect(resolvedSection?.querySelector('summary')?.textContent).toBe('Resolved comments (1)'); + expect(view.container.querySelector('.plan-comment-thread.resolved')?.textContent).toContain( + 'Resolved after target removal', + ); +}); + +test('plan mode keeps comments open when their anchors are removed during the current review', async () => { + let publishMarkdownChange: Parameters[0] | null = + null; + const savePlanReview = vi.fn(async (review: PlanReview) => review); + window.codiff = createCodiffMock({ + getMarkdownDocument: vi.fn(async () => ({ + content: '# Current plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getPlanReview: vi.fn(async () => + createReview([ + createThread({ + body: 'The agent still needs to process this.', + id: 'live-thread', + text: 'Current plan', + }), + ]), + ), + onMarkdownDocumentChanged: vi.fn((callback) => { + publishMarkdownChange = callback; + return () => { + publishMarkdownChange = null; + }; + }), + savePlanReview, + }); + + await using view = await renderReact(); + await waitFor(() => { + expect( + view.container.querySelector('[data-mdx-annotation-block~="live-thread"]'), + ).not.toBeNull(); + expect(publishMarkdownChange).not.toBeNull(); + }); + savePlanReview.mockClear(); + await act(async () => + publishMarkdownChange?.({ + deleted: false, + document: { + content: '# Replacement plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan', + path: '/tmp/plan.md', + version: 'next-plan-version', + }, + id: 'plan:/tmp/plan.md', + }), + ); + await waitFor(() => + expect(view.container.querySelector('[data-mdx-annotation-block~="live-thread"]')).toBeNull(), + ); + expect(view.container.querySelector('.plan-resolved-comments')).toBeNull(); + expect( + view.container.querySelector('.plan-comment-position .plan-comment-thread'), + ).not.toBeNull(); + expect( + savePlanReview.mock.calls.some( + ([review]) => + review.threads.find((thread) => thread.id === 'live-thread')?.status === 'resolved', + ), + ).toBe(false); +}); + +test('closing plan mode flushes and returns a closed handoff', async () => { + const completePlan = vi.fn(async (_review: PlanReview, _status: 'closed' | 'done') => {}); + let blockPlanReviewSave = false; + let resolvePlanReviewSave: (() => void) | null = null; + const savePlanReview = vi.fn((review: PlanReview) => { + if (!blockPlanReviewSave) { + return Promise.resolve(review); + } + return new Promise((resolveSave) => { + resolvePlanReviewSave = () => resolveSave(review); + }); + }); + let requestClose: (() => void) | null = null; + window.codiff = createCodiffMock({ + completePlan, + getPlanReview: vi.fn(async () => + createReview([createThread({ body: 'Keep this requirement.', id: 'thread-1' })]), + ), + onPlanCloseRequested: vi.fn((callback) => { + requestClose = callback; + return () => { + requestClose = null; + }; + }), + savePlanReview, + }); + + await using view = await renderReact(); + await waitFor(() => { + expect(view.container.querySelector('.plan-shell')).not.toBeNull(); + expect(requestClose).not.toBeNull(); + expect(view.container.querySelector('.plan-comment-thread')).not.toBeNull(); + }); + await act(async () => { + await new Promise((resolveWait) => setTimeout(resolveWait, 75)); + }); + savePlanReview.mockClear(); + blockPlanReviewSave = true; + await act(async () => requestClose?.()); + await waitFor(() => expect(savePlanReview).toHaveBeenCalledTimes(1)); + expect(completePlan).not.toHaveBeenCalled(); + expect(view.container.querySelector('.review-comment-delete')?.disabled).toBe( + true, + ); + await act(async () => resolvePlanReviewSave?.()); + await waitFor(() => expect(completePlan).toHaveBeenCalledTimes(1)); + expect(completePlan).toHaveBeenCalledWith( + expect.objectContaining({ threads: [expect.objectContaining({ id: 'thread-1' })] }), + 'closed', + ); +}); + +test('plan mode recovers from an unreadable review sidecar', async () => { + const markPlanReady = vi.fn(async () => {}); + window.codiff = createCodiffMock({ + getPlanReview: vi.fn(async () => { + throw new Error('Invalid plan review.'); + }), + markPlanReady, + }); + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('.plan-shell')).not.toBeNull()); + expect(view.container.querySelector('[role="alert"]')?.textContent).toContain( + 'Invalid plan review.', + ); + expect(view.container.querySelector('[contenteditable="true"]')).not.toBeNull(); + expect(markPlanReady).toHaveBeenCalledTimes(1); +}); diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx deleted file mode 100644 index 132d188e..00000000 --- a/core/__tests__/App-render.test.tsx +++ /dev/null @@ -1,3821 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { beforeEach, expect, test, vi } from 'vite-plus/test'; -import App from '../App.tsx'; -import { createDefaultConfig, defaultSettings } from '../config/defaults.ts'; -import { - consumeReloadSelection, - getReloadSelectionPath, - writeReloadSelection, -} from '../lib/reload-selection.ts'; -import type { - ChangedFile, - CommitMetadata, - NarrativeWalkthrough, - PlanReview, - RepositoryState, - ReviewSource, - WalkthroughProgressEvent, -} from '../types.ts'; -import { createChangedFile } from './helpers/fixtures.ts'; -import { renderReact, setInputValue, waitFor } from './helpers/react.tsx'; - -const reactActEnvironment = globalThis as typeof globalThis & { - ResizeObserver?: typeof ResizeObserver; - Worker?: typeof Worker; -}; -reactActEnvironment.ResizeObserver ??= class ResizeObserver { - disconnect() {} - observe() {} - unobserve() {} -}; -HTMLElement.prototype.scrollBy ??= function scrollBy() {}; -HTMLElement.prototype.scrollTo ??= function scrollTo() {}; -class StubWorker extends EventTarget { - constructor(_scriptURL: string | URL, _options?: WorkerOptions) { - super(); - } - onerror = null; - onmessage = null; - postMessage() {} - terminate() {} -} -reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; - -const gitSha = (value: string) => value as GitSha; - -beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - document.documentElement.style.removeProperty('--font-diff-line-height'); - document.documentElement.style.removeProperty('--font-diff-mono'); - document.documentElement.style.removeProperty('--font-diff-size'); -}); - -const repositoryState = { - branch: 'main', - files: [], - generatedAt: 1, - launchPath: '/repo', - root: '/repo', - source: { type: 'working-tree' }, -} satisfies RepositoryState; - -const createCommitMetadataFixture = (body: string): CommitMetadata => ({ - author: { - date: '2026-01-01T12:00:00Z', - email: 'author@example.com', - gravatarUrl: 'https://www.gravatar.com/avatar/fallback', - name: 'Author', - }, - body, - committer: { - date: '2026-01-01T13:00:00Z', - email: 'committer@example.com', - name: 'Committer', - }, - files: [ - { - additions: 1, - binary: false, - deletions: 1, - path: 'src/app.ts', - status: 'modified', - }, - ], - parents: ['parent-sha'], - ref: 'abc1234', - refs: ['main'], - shortRef: 'abc1234', - signature: { - key: 'SHA256:abcdefghijklmnopqrstuvwxyz0123456789', - signer: 'signer@example.test', - status: 'G', - }, - stats: { - additions: 1, - binaryFiles: 0, - deletions: 1, - files: 1, - renamedFiles: 0, - }, - subject: 'Commit subject', - trailers: [ - { - key: 'Co-authored-by', - value: 'Second Author ', - }, - ], -}); - -const createCodiffMock = (overrides: Partial = {}): Window['codiff'] => ({ - applyUpdate: vi.fn(async () => ({ - currentVersion: '1.9.2', - phase: 'idle' as const, - })), - askReviewAssistant: vi.fn(async () => ({ - reason: 'Unavailable in tests.', - status: 'unavailable' as const, - })), - completePlan: vi.fn(async () => {}), - createWalkthroughCommit: vi.fn(async () => ({ - hash: '0000000000000000000000000000000000000000', - status: 'committed' as const, - })), - decreaseCodeFontSize: vi.fn(async () => {}), - dismissUpdate: vi.fn(async () => ({ - currentVersion: '1.9.2', - phase: 'idle' as const, - })), - findDefinitions: vi.fn(async (request) => ({ - candidates: [], - identifier: request.identifier, - status: 'ready' as const, - })), - getAgentSkillStatus: vi.fn(async () => ({ - installed: true, - path: '/Users/reviewer/.codex/skills/codiff', - })), - getConfig: vi.fn(async () => createDefaultConfig()), - getDiffImageContent: vi.fn(async () => ({ - reason: 'Unavailable in tests.', - status: 'unavailable' as const, - })), - getDiffSectionContent: vi.fn(async () => { - throw new Error('Unexpected diff section load.'); - }), - getDiffSectionsContent: vi.fn(async () => ({ sections: [] })), - getFeatureFlags: vi.fn(async () => ({ - planSharing: false, - walkthroughSharing: false, - })), - getGitIdentity: vi.fn(async () => ({ - email: 'reviewer@example.com', - name: 'Reviewer', - })), - getKeyboardLayout: vi.fn(async () => null), - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async ({ kind, path }) => ({ - content: '# Plan\n', - id: `${kind}:${path}`, - kind, - path, - version: 'version', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - reason: 'Unavailable in tests.', - status: 'unavailable' as const, - })), - getPlanReview: vi.fn(async () => null), - getPreferences: vi.fn(async () => ({ - agentBackend: 'codex' as const, - claudeModel: defaultSettings.claudeModel, - codeFontFamily: defaultSettings.codeFontFamily, - codeFontSize: defaultSettings.codeFontSize, - copyCommentsOnClose: true, - diffStyle: 'split' as const, - editorCommand: '', - lastRepositoryPath: '/repo', - openAIModel: defaultSettings.openAIModel, - opencodeModel: defaultSettings.opencodeModel, - piModel: defaultSettings.piModel, - reviewCommentsPrefix: defaultSettings.reviewCommentsPrefix, - showOutdated: false, - showWhitespace: false, - sidebarPosition: defaultSettings.sidebarPosition, - theme: 'system' as const, - walkthroughPrompt: defaultSettings.walkthroughPrompt, - wordWrap: false, - })), - getRepositoryHistory: vi.fn(async () => ({ - entries: [], - root: '/repo', - })), - getRepositoryState: vi.fn(async () => repositoryState), - getReviewComments: vi.fn(async () => []), - getTerminalHelperStatus: vi.fn(async () => ({ - command: 'codiff', - installed: true, - path: '/usr/local/bin/codiff', - })), - getUpdateStatus: vi.fn(async () => ({ - currentVersion: '1.9.2', - phase: 'idle' as const, - })), - increaseCodeFontSize: vi.fn(async () => {}), - installAgentSkill: vi.fn(async () => ({ - installed: true, - path: '/Users/reviewer/.codex/skills/codiff', - })), - installTerminalHelper: vi.fn(async () => ({ - command: 'codiff', - installed: true, - path: '/usr/local/bin/codiff', - })), - isWindowFullScreen: vi.fn(async () => false), - markPlanReady: vi.fn(async () => {}), - onConfigChanged: vi.fn(() => () => {}), - onCopyPendingCommentsRequest: vi.fn(() => () => {}), - onFindInDiffs: vi.fn(() => () => {}), - onKeyboardLayoutChanged: vi.fn(() => () => {}), - onMarkdownDocumentChanged: vi.fn(() => () => {}), - onOpenReviewSource: vi.fn(() => () => {}), - onPlanCloseRequested: vi.fn(() => () => {}), - onRefreshRequest: vi.fn(() => () => {}), - onRepositoryChanged: vi.fn(() => () => {}), - onUpdateStatusChanged: vi.fn(() => () => {}), - onWalkthroughCommitOutput: vi.fn(() => () => {}), - onWalkthroughProgress: vi.fn(() => () => {}), - onWindowFullScreenChanged: vi.fn(() => () => {}), - openConfigFile: vi.fn(async () => {}), - openFile: vi.fn(async () => {}), - openReleasePage: vi.fn(async () => {}), - openRepositoryFolder: vi.fn(async () => {}), - resetCodeFontSize: vi.fn(async () => {}), - resolvePullRequestUrl: vi.fn(async () => 'https://github.com/owner/repo/pull/1'), - saveMarkdownDocument: vi.fn(async (request) => ({ - document: { - content: request.content, - id: `${request.kind}:${request.path}`, - kind: request.kind, - path: request.path, - version: 'next-version', - }, - status: 'saved' as const, - })), - savePlanReview: vi.fn(async (review) => review), - setDiffStyle: vi.fn(async () => {}), - setShowOutdated: vi.fn(async () => {}), - setWordWrap: vi.fn(async () => {}), - sharePlan: vi.fn(async () => ({ - status: 'uploaded' as const, - url: 'https://codiff.dev/p/test', - })), - shareWalkthrough: vi.fn(async () => ({ - status: 'uploaded' as const, - url: 'https://codiff.dev/w/test', - })), - showInFolder: vi.fn(async () => {}), - submitPullRequestComment: vi.fn(async () => { - throw new Error('Unexpected pull request comment submit.'); - }), - submitPullRequestReview: vi.fn(async () => {}), - updateWalkthroughCommitMessage: vi.fn(async () => ({ - reason: 'Unavailable in tests.', - status: 'unavailable' as const, - })), - ...overrides, -}); - -const createNarrativeWalkthroughFixture = ( - hunks: ReadonlyArray<{ - added: number; - path: string; - status: ChangedFile['status']; - }>, -) => - ({ - agent: 'codex', - chapters: [ - { - blurb: 'Review the implementation.', - icon: 'gear', - id: 'impl', - stops: [ - { - added: hunks.reduce((sum, hunk) => sum + hunk.added, 0), - deleted: 1, - hunkIds: hunks.map((hunk) => `${hunk.path}:unstaged:h1`), - hunks: hunks.map((hunk) => ({ - added: hunk.added, - anchor: { - display: hunk.path, - sectionId: `${hunk.path}:unstaged`, - side: 'both', - }, - deleted: hunk.path === 'src/app.ts' ? 1 : 0, - id: `${hunk.path}:unstaged:h1`, - path: hunk.path, - status: hunk.status, - })), - id: 'implementation-path', - importance: 'critical', - prose: 'Review these changes.', - title: 'Implementation path', - }, - ], - title: 'Implementation', - }, - ], - focus: 'Focus.', - generatedAt: '2026-06-07T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/repo' }, - source: { type: 'working-tree' }, - support: [], - title: 'Narrative', - version: 4, - }) satisfies NarrativeWalkthrough; - -const dispatchModK = () => { - const isMac = navigator.platform.toLowerCase().includes('mac'); - window.dispatchEvent(new KeyboardEvent('keydown', { ctrlKey: !isMac, key: 'k', metaKey: isMac })); -}; - -const renderAppForOpenFileShortcut = async (file: ChangedFile) => { - const openFile = vi.fn(async () => {}); - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [file], - })), - openFile, - }); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.codiff-file-header')).not.toBeNull(); - }); - - return { - openFile, - async [Symbol.asyncDispose]() { - await act(async () => root.unmount()); - container.remove(); - }, - }; -}; - -test('code font preferences update root CSS variables', async () => { - const nextConfig = createDefaultConfig(); - nextConfig.settings.codeFontFamily = 'JetBrains Mono'; - nextConfig.settings.codeFontSize = 14; - window.codiff = createCodiffMock({ - getConfig: vi.fn(async () => nextConfig), - }); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--font-diff-mono')).toBe( - '"JetBrains Mono", monospace', - ); - expect(document.documentElement.style.getPropertyValue('--font-diff-size')).toBe('14px'); - expect(document.documentElement.style.getPropertyValue('--font-diff-line-height')).toBe('22px'); - }); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('desktop app places the sidebar on the configured side', async () => { - const nextConfig = createDefaultConfig(); - nextConfig.settings.sidebarPosition = 'right'; - window.codiff = createCodiffMock({ - getConfig: vi.fn(async () => nextConfig), - }); - - await using app = await renderReact(); - - await waitFor(() => { - const shell = app.container.querySelector('.app-shell'); - expect(shell?.dataset.sidebarPosition).toBe('right'); - expect(shell?.style.gridTemplateColumns).toBe('minmax(0, 1fr) 0 292px'); - }); -}); - -test('empty code font family removes the root CSS variable', async () => { - document.documentElement.style.setProperty('--font-diff-mono', 'stale'); - window.codiff = createCodiffMock(); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--font-diff-mono')).toBe(''); - expect(document.documentElement.style.getPropertyValue('--font-diff-size')).toBe('13px'); - expect(document.documentElement.style.getPropertyValue('--font-diff-line-height')).toBe('20px'); - }); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('stale persisted collapsed sidebar state does not hide the sidebar on launch', async () => { - window.localStorage.setItem('codiff:sidebar-collapsed', 'true'); - window.codiff = createCodiffMock(); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.app-shell')?.classList.contains('sidebar-collapsed')).toBe( - false, - ); - expect(app.container.querySelector('.sidebar')).not.toBeNull(); - expect(app.container.querySelector('.review-top-bar [role="tablist"]')).not.toBeNull(); - }); - - const topBar = app.container.querySelector('.review-top-bar'); - expect(app.container.querySelector('.sidebar [role="tablist"]')).toBeNull(); - const modeControl = topBar?.querySelector('.review-mode-control'); - const modes = modeControl?.querySelectorAll('[role="tab"]') ?? []; - expect([...modes].map((mode) => mode.textContent)).toEqual(['Walkthrough', 'Tree', 'History']); - const sidebarToggle = topBar?.querySelector('.sidebar-toggle-button'); - await act(async () => sidebarToggle?.click()); - expect(app.container.querySelector('.app-shell')?.classList.contains('sidebar-collapsed')).toBe( - true, - ); - expect(app.container.querySelector('.review-top-bar')).toBe(topBar); -}); - -test('keeps source-loading errors in the open-source dialog', async () => { - const openReviewSourceListeners: Array[0]> = - []; - const getRepositoryState = vi.fn(async (source?: ReviewSource) => { - if (source?.type === 'branch-working-tree') { - throw new Error(`Branch "${source.ref}" does not exist in this repository.`); - } - return repositoryState; - }); - window.codiff = createCodiffMock({ - getRepositoryState, - onOpenReviewSource: vi.fn((callback) => { - openReviewSourceListeners.push(callback); - return () => {}; - }), - }); - - await using app = await renderReact(); - await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); - - await act(async () => openReviewSourceListeners[0]?.('branch')); - const input = app.container.querySelector('#open-review-source-input'); - const form = app.container.querySelector('form.open-review-source-dialog'); - if (!input || !form) { - throw new Error('Expected the open branch dialog.'); - } - - await setInputValue(input, 'missing-branch'); - await act(async () => { - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - }); - - await waitFor(() => { - expect(app.container.querySelector('[role="alert"]')?.textContent).toBe( - 'Branch "missing-branch" does not exist in this repository.', - ); - }); - expect(app.container.querySelector('.open-review-source-dialog')).not.toBeNull(); - expect(app.container.querySelector('.app-shell')).not.toBeNull(); - expect(app.container.textContent).not.toContain('Unable to read repository'); -}); - -test('top bar source menu opens the review source dialog', async () => { - window.codiff = createCodiffMock(); - - await using app = await renderReact(); - await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); - - const trigger = app.container.querySelector( - '.review-top-bar .open-review-source-trigger', - ); - if (!trigger) { - throw new Error('Expected the open review source trigger in the top bar.'); - } - await act(async () => { - trigger.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - const pullRequestItem = [ - ...document.querySelectorAll('[role="menuitem"]'), - ].find((item) => item.textContent === 'Open PR'); - if (!pullRequestItem) { - throw new Error('Expected an Open PR menu item.'); - } - await act(async () => { - pullRequestItem.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - const input = app.container.querySelector('#open-review-source-input'); - expect(input?.placeholder).toBe('#123 or https://github.com/owner/repo/pull/123'); - expect(document.querySelector('[role="menu"]')).toBeNull(); -}); - -test('repository label is plain text and clicking it does nothing', async () => { - const openRepositoryFolder = vi.fn(async () => {}); - window.codiff = createCodiffMock({ openRepositoryFolder }); - - await using app = await renderReact(); - await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); - - const label = app.container.querySelector('.review-top-bar-repository'); - if (!label) { - throw new Error('Expected the repository label in the top bar.'); - } - expect(label.tagName).toBe('SPAN'); - expect(label.textContent).toBe('/repo'); - expect(label.closest('.review-top-bar-repository-slot')?.getAttribute('title')).toBe('/repo'); - expect(app.container.querySelector('a.review-top-bar-repository')).toBeNull(); - expect(app.container.querySelector('button.review-top-bar-repository')).toBeNull(); - - await act(async () => { - label.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(openRepositoryFolder).not.toHaveBeenCalled(); -}); - -test('the branch name renders in the top bar context', async () => { - window.codiff = createCodiffMock(); - - await using app = await renderReact(); - await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); - - const branch = app.container.querySelector('.review-top-bar-branch'); - expect(branch?.textContent).toBe('main'); - expect(branch?.getAttribute('title')).toBe('main'); - expect(branch?.parentElement?.className).toBe('review-top-bar-context'); -}); - -test('empty repository state fills the review pane for centered layout', async () => { - window.codiff = createCodiffMock(); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - const emptyState = container.querySelector('.review > .empty-state'); - expect(emptyState?.textContent).toContain('No local changes'); - }); - const css = readFileSync(resolve('core/App.css'), 'utf8'); - const emptyStateRule = css.match(/\.review > \.empty-state \{([^}]*)\}/)?.[1]; - expect(emptyStateRule).toContain('flex: 1;'); - expect(emptyStateRule).toContain('min-width: 0;'); - expect(emptyStateRule).toContain('width: 100%;'); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('showWhitespace config changes reload the current repository state', async () => { - let configListener: ((config: ReturnType) => void) | null = null; - const nextConfig = createDefaultConfig(); - nextConfig.settings.showWhitespace = true; - const initialState = { - ...repositoryState, - files: [createChangedFile('src/initial.ts')], - }; - const reloadedState = { - ...repositoryState, - files: [createChangedFile('src/reloaded.ts')], - }; - const getRepositoryState = vi - .fn() - .mockResolvedValueOnce(initialState) - .mockResolvedValueOnce(reloadedState); - window.codiff = createCodiffMock({ - getRepositoryState, - onConfigChanged: vi.fn((callback) => { - configListener = callback; - return () => {}; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(container.textContent).toContain('src/initial.ts'); - }); - - await act(async () => { - configListener?.(nextConfig); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await waitFor(() => { - expect(container.textContent).toContain('src/reloaded.ts'); - }); - expect(getRepositoryState).toHaveBeenLastCalledWith({ type: 'working-tree' }); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('sidebar commit button toggles back to tree when commit view is open', async () => { - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/change.ts')], - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(container.querySelector('.sidebar-commit-button')?.textContent).toBe('Commit'); - }); - - await act(async () => { - container.querySelector('.sidebar-commit-button')?.click(); - }); - - await waitFor(() => { - expect(container.querySelector('.wt-commit')).not.toBeNull(); - expect(container.querySelector('.sidebar-commit-button')?.textContent).toBe('Tree'); - }); - - await act(async () => { - container.querySelector('.sidebar-commit-button')?.click(); - }); - - await waitFor(() => { - expect(container.querySelector('.wt-commit')).toBeNull(); - expect(container.querySelector('.sidebar-commit-button')?.textContent).toBe('Commit'); - }); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('repository reload restores the selected file when it still exists', async () => { - const firstFile = createChangedFile('src/first.ts'); - const secondFile = createChangedFile('src/second.ts'); - const nextState = { - ...repositoryState, - files: [firstFile, secondFile], - } satisfies RepositoryState; - - writeReloadSelection(nextState, secondFile.path); - const openFile = vi.fn(async () => {}); - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => nextState), - openFile, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - window.sessionStorage.clear(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.codiff-file-header')).not.toBeNull(); - }); - await act(async () => { - dispatchModK(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(openFile).toHaveBeenCalledWith(secondFile.path); -}); - -test('repository reload restores the selected file from the previous source', async () => { - const firstFile = createChangedFile('src/first.ts'); - const secondFile = createChangedFile('src/second.ts'); - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const nextState = { - ...repositoryState, - files: [firstFile, secondFile], - source, - } satisfies RepositoryState; - const getRepositoryState = vi.fn(async (requestedSource?: ReviewSource) => - requestedSource?.type === 'commit' ? nextState : repositoryState, - ); - - writeReloadSelection(nextState, secondFile.path); - const openFile = vi.fn(async () => {}); - - window.codiff = createCodiffMock({ - getRepositoryState, - openFile, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.codiff-file-header')).not.toBeNull(); - }); - await act(async () => { - dispatchModK(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(openFile).toHaveBeenCalledWith(secondFile.path); - expect(getRepositoryState).toHaveBeenCalledWith(source); -}); - -test('repository reload preserves a branch diff source even without a selected file', async () => { - const source = { - baseRef: 'base123', - headRef: 'head123', - ref: 'main', - type: 'branch-diff', - } satisfies ReviewSource; - const nextState = { - ...repositoryState, - files: [], - source, - } satisfies RepositoryState; - const getRepositoryState = vi.fn(async (requestedSource?: ReviewSource) => - requestedSource?.type === 'branch-diff' ? nextState : repositoryState, - ); - - writeReloadSelection(nextState, null); - - window.codiff = createCodiffMock({ - getRepositoryState, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - }); - expect(getRepositoryState).toHaveBeenCalledWith(source); -}); - -test('branch history keeps branch diff available after selecting uncommitted changes', async () => { - const branchSource = { - baseRef: 'base123', - headRef: 'head123', - ref: 'main', - type: 'branch-diff', - } satisfies ReviewSource; - const branchState = { - ...repositoryState, - branch: 'fork', - source: branchSource, - } satisfies RepositoryState; - const workingTreeState = { - ...repositoryState, - branch: 'fork', - source: { type: 'working-tree' }, - } satisfies RepositoryState; - const getRepositoryState = vi.fn(async (requestedSource?: ReviewSource) => - requestedSource?.type === 'working-tree' ? workingTreeState : branchState, - ); - - window.codiff = createCodiffMock({ - getRepositoryHistory: vi.fn(async () => ({ - entries: [ - { - author: 'Reviewer', - committedAt: Date.now(), - parents: [], - ref: '99e7b27', - subject: 'Add branch diff review mode', - }, - ], - root: '/repo', - })), - getRepositoryState, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const findButton = (label: string) => - Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label), - ); - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(findButton('Committed only vs main')).toBeTruthy(); - expect(findButton('All changes vs main')).toBeTruthy(); - }); - await act(async () => { - findButton('Uncommitted')?.click(); - }); - await waitFor(() => { - expect(getRepositoryState).toHaveBeenCalledWith({ type: 'working-tree' }); - expect(findButton('Committed only vs main')).toBeTruthy(); - }); - await act(async () => { - findButton('Committed only vs main')?.click(); - }); - await waitFor(() => { - expect(getRepositoryState).toHaveBeenCalledWith(branchSource); - }); -}); - -test('repository reload restores branch diff scope after selecting uncommitted changes', async () => { - const branchSource = { - baseRef: 'base123', - headRef: 'head123', - ref: 'main', - type: 'branch-diff', - } satisfies ReviewSource; - const workingTreeState = { - ...repositoryState, - branch: 'fork', - source: { type: 'working-tree' }, - } satisfies RepositoryState; - const getRepositoryHistory = vi.fn(async () => ({ - entries: [ - { - author: 'Reviewer', - committedAt: Date.now(), - parents: [], - ref: '99e7b27', - subject: 'Add branch diff review mode', - }, - ], - root: '/repo', - })); - const getRepositoryState = vi.fn(async () => workingTreeState); - - writeReloadSelection(workingTreeState, null, branchSource); - - window.codiff = createCodiffMock({ - getRepositoryHistory, - getRepositoryState, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const findButton = (label: string) => - Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label), - ); - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(findButton('Committed only vs main')).toBeTruthy(); - }); - expect(getRepositoryState).toHaveBeenCalledWith({ type: 'working-tree' }); - expect(getRepositoryHistory).toHaveBeenCalledWith(expect.any(Number), branchSource); -}); - -test('repository reload does not let stale selection override launch source', async () => { - const launchSource = { - ref: 'main', - type: 'branch-working-tree', - } satisfies ReviewSource; - const staleState = { - ...repositoryState, - source: { type: 'working-tree' }, - } satisfies RepositoryState; - const branchState = { - ...repositoryState, - source: launchSource, - } satisfies RepositoryState; - const getRepositoryState = vi.fn(async (requestedSource?: ReviewSource) => - requestedSource?.type === 'working-tree' ? staleState : branchState, - ); - - writeReloadSelection(staleState, null); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source: launchSource, - walkthrough: false, - })), - getRepositoryState, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - }); - expect(getRepositoryState).toHaveBeenCalledWith(undefined); - expect(getRepositoryState).not.toHaveBeenCalledWith(staleState.source); -}); - -test('repository reload colors only git status glyphs for files changed after reload', async () => { - const unchangedFile = createChangedFile('src/unchanged.ts', { - fingerprint: 'same', - }); - const changedFileBeforeReload = createChangedFile('src/changed.ts', { - fingerprint: 'before', - }); - const changedFileAfterReload = createChangedFile('src/changed.ts', { - fingerprint: 'after', - }); - const previousState = { - ...repositoryState, - files: [unchangedFile, changedFileBeforeReload], - } satisfies RepositoryState; - const nextState = { - ...repositoryState, - files: [unchangedFile, changedFileAfterReload], - } satisfies RepositoryState; - - writeReloadSelection(previousState, changedFileBeforeReload.path); - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => nextState), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - const shadowRoot = container.querySelector('file-tree-container')?.shadowRoot; - const styleText = - shadowRoot?.querySelector('style[data-codiff-reload-delta-git-status]')?.textContent ?? ''; - expect(styleText).toContain('[data-item-path="src/changed.ts"][data-item-git-status]'); - expect(styleText).not.toContain('[data-item-path="src/unchanged.ts"][data-item-git-status]'); - expect(styleText).toContain("> [data-item-section='git']"); - expect( - shadowRoot?.querySelector( - '[data-item-path="src/changed.ts"][data-item-git-status] > [data-item-section="git"]', - ), - ).toBeTruthy(); - expect( - shadowRoot?.querySelector( - '[data-item-path="src/unchanged.ts"][data-item-git-status] > [data-item-section="git"]', - ), - ).toBeTruthy(); - }); -}); - -test('walkthrough file reload keeps uncovered files in support', async () => { - const unchangedFile = createChangedFile('src/unchanged.ts', { - fingerprint: 'same', - }); - const changedFileBeforeReload = createChangedFile('src/changed.ts', { - fingerprint: 'before', - }); - const changedFileAfterReload = createChangedFile('src/changed.ts', { - fingerprint: 'after', - }); - const addedFile = createChangedFile('src/added.ts', { - fingerprint: 'added', - status: 'added', - }); - const previousState = { - ...repositoryState, - files: [unchangedFile, changedFileBeforeReload], - } satisfies RepositoryState; - const nextState = { - ...repositoryState, - files: [unchangedFile, changedFileAfterReload, addedFile], - } satisfies RepositoryState; - const narrativeWalkthrough = createNarrativeWalkthroughFixture([ - { added: 1, path: unchangedFile.path, status: 'modified' }, - ]); - - writeReloadSelection(previousState, changedFileBeforeReload.path); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: false, - walkthroughFile: '/tmp/walkthrough.json', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: narrativeWalkthrough, - })), - getRepositoryState: vi.fn(async () => nextState), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - await act(async () => { - container.querySelector('.wt-upnext')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('Support'); - expect(container.textContent).toContain('changed.ts'); - expect(container.textContent).toContain('added.ts'); - expect(container.textContent).not.toContain('Regenerate walkthrough'); - expect(container.textContent).not.toContain('Changed after the walkthrough was generated.'); - expect(container.textContent).not.toContain('unchanged.tsChanged'); - }); -}); - -test('reload forces a new generated walkthrough when local files changed', async () => { - const previousFile = createChangedFile('src/app.ts', { - fingerprint: 'before', - }); - const refreshedFile = createChangedFile('src/app.ts', { - fingerprint: 'after', - }); - const previousState = { - ...repositoryState, - files: [previousFile], - } satisfies RepositoryState; - const refreshedState = { - ...repositoryState, - files: [refreshedFile], - } satisfies RepositoryState; - const getNarrativeWalkthrough = vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: createNarrativeWalkthroughFixture([ - { added: 1, path: refreshedFile.path, status: 'modified' }, - ]), - })); - - writeReloadSelection(previousState, previousFile.path); - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => refreshedState), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledWith(refreshedState.source, { - force: true, - }); - expect(app.container.querySelector('.wt-stop-block')).not.toBeNull(); - }); -}); - -test('tree sidebar subtly mutes files currently marked viewed', async () => { - const viewedFile = createChangedFile('src/viewed.ts', { - fingerprint: 'viewed-current', - }); - const staleViewedFile = createChangedFile('src/stale.ts', { - fingerprint: 'stale-current', - }); - const nextState = { - ...repositoryState, - files: [viewedFile, staleViewedFile], - } satisfies RepositoryState; - - window.localStorage.setItem( - 'codiff:viewed:/repo', - JSON.stringify({ - [staleViewedFile.path]: 'stale-previous', - [viewedFile.path]: viewedFile.fingerprint, - }), - ); - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => nextState), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - const shadowRoot = container.querySelector('file-tree-container')?.shadowRoot; - const styleText = - shadowRoot?.querySelector('style[data-codiff-viewed-rows]')?.textContent ?? ''; - expect(styleText).toContain('[data-item-path="src/viewed.ts"]'); - expect(styleText).not.toContain('[data-item-path="src/stale.ts"]'); - expect(styleText).not.toContain('color-mix(in srgb, var(--viewed)'); - expect(styleText).toContain( - "[data-item-path=\"src/viewed.ts\"] > [data-item-section='icon'] > :where(:not([data-icon-name='file-tree-icon-chevron']))", - ); - expect(styleText).toContain("> [data-item-section='content']"); - expect(styleText).toContain('color: var(--muted)'); - }); -}); - -test('before unload saves the current source and selected file for any reload trigger', async () => { - const changedFile = createChangedFile('src/app.ts'); - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const nextState = { - ...repositoryState, - files: [changedFile], - source, - } satisfies RepositoryState; - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => nextState), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - }); - window.dispatchEvent(new Event('beforeunload')); - const selection = consumeReloadSelection(); - expect(selection?.source).toEqual(source); - expect(getReloadSelectionPath(selection, nextState)).toBe(changedFile.path); -}); - -test('Mod+K opens the selected file in the editor', async () => { - const changedFile = createChangedFile('src/app.ts'); - await using app = await renderAppForOpenFileShortcut(changedFile); - - await act(async () => { - dispatchModK(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(app.openFile).toHaveBeenCalledWith(changedFile.path); -}); - -test('Mod+K does not open deleted files', async () => { - const deletedFile = { - ...createChangedFile('src/removed.ts'), - status: 'deleted', - } satisfies ChangedFile; - await using app = await renderAppForOpenFileShortcut(deletedFile); - - await act(async () => { - dispatchModK(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(app.openFile).not.toHaveBeenCalled(); -}); - -test('commit messages use the shared source description presentation', async () => { - const changedFile = createChangedFile('src/app.ts'); - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const commitMetadata = createCommitMetadataFixture('## Details\n\nDetailed **commit** body.'); - const historyAvatarUrl = 'https://avatars.githubusercontent.com/u/1?v=4'; - - window.codiff = createCodiffMock({ - getRepositoryHistory: vi.fn(async () => ({ - entries: [ - { - author: commitMetadata.author.name, - committedAt: Date.parse(commitMetadata.author.date), - gravatarUrl: historyAvatarUrl, - parents: commitMetadata.parents, - ref: commitMetadata.ref, - subject: commitMetadata.subject, - }, - ], - root: '/repo', - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - commitMetadata, - files: [changedFile], - source, - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.codiff-source-description-header')).not.toBeNull(); - }); - await waitFor(() => { - expect(container.querySelector('.source-description-markdown')?.textContent).toContain( - 'Detailed commit body.', - ); - }); - const header = container.querySelector('.codiff-source-description-header'); - expect(header?.querySelector('.source-description-title')?.textContent).toBe( - commitMetadata.subject, - ); - expect(container.querySelector('.source-description-author-header')?.textContent).toContain( - commitMetadata.author.name, - ); - expect( - container.querySelector('.source-description-comment > .avatar.medium')?.src, - ).toBe(historyAvatarUrl); - expect(container.querySelector('[aria-label="Preview commit message"]')).not.toBeNull(); - expect(container.querySelector('.codiff-commit-details-header')).toBeNull(); - expect(container.querySelector('.commit-details-panel')).toBeNull(); - expect(container.textContent).not.toContain('Verified signature'); - expect(container.textContent).not.toContain('Co-authored-by'); -}); - -test('bodyless commits still render the author and profile image', async () => { - const commitMetadata = createCommitMetadataFixture(''); - const source = { - ref: commitMetadata.ref, - type: 'commit', - } satisfies ReviewSource; - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - commitMetadata, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.source-description-author-header')?.textContent).toContain( - commitMetadata.author.name, - ); - }); - expect( - app.container.querySelector('.source-description-comment > .avatar.medium') - ?.src, - ).toBe(commitMetadata.author.gravatarUrl); - expect( - app.container.querySelector('.source-description-author-header.without-description'), - ).not.toBeNull(); - expect(app.container.querySelector('.source-description-markdown')).toBeNull(); - expect( - app.container - .querySelector('.codiff-source-description-header') - ?.querySelector('.source-description-title')?.textContent, - ).toBe(commitMetadata.subject); -}); - -test('pull request descriptions render as provider-aware Markdown source context', async () => { - const changedFile = createChangedFile('src/app.ts'); - const cases: ReadonlyArray<{ - label: string; - source: Extract; - }> = [ - { - label: 'PR description', - source: { - author: { - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', - login: 'octocat', - url: 'https://github.com/octocat', - }, - description: '## Intent\n\nShip **review** context.', - number: 12, - provider: 'github', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - }, - }, - { - label: 'MR description', - source: { - description: '## Intent\n\nShip **review** context.', - number: 13, - provider: 'gitlab', - type: 'pull-request', - url: 'https://gitlab.example.com/group/project/-/merge_requests/13', - }, - }, - { - label: 'Description', - source: { - description: '## Intent\n\nShip **review** context.', - number: 14, - type: 'pull-request', - url: 'https://example.com/reviews/14', - }, - }, - ]; - - for (const { label, source } of cases) { - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [changedFile], - source, - })), - }); - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.codiff-source-description-header')).not.toBeNull(); - }); - const header = app.container.querySelector('.codiff-source-description-header'); - const body = app.container.querySelector('.source-description-markdown'); - expect(body?.textContent).toContain('Intent'); - expect(body?.textContent).toContain('Ship review context.'); - expect(header?.querySelector('.codiff-file-path')?.textContent).toBe(label); - expect(header?.querySelector('.source-description-title')).toBeNull(); - if (source.author) { - expect(header?.querySelector('.source-description-author')).toBeNull(); - expect( - app.container.querySelector('.source-description-author-header')?.textContent, - ).toContain(source.author.name ?? `@${source.author.login}`); - } else { - expect(header?.querySelector('.source-description-author')).toBeNull(); - } - const toggle = header?.querySelector('button.codiff-header-toggle'); - expect(toggle).not.toBeNull(); - expect(toggle?.getAttribute('aria-expanded')).toBe('true'); - expect(toggle?.type).toBe('button'); - } -}); - -test('pull request description collapse button toggles the markdown body', async () => { - const source = { - description: '## Intent\n\nShip **review** context.', - number: 12, - provider: 'github', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - } satisfies ReviewSource; - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.source-description-markdown')).not.toBeNull(); - }); - const repositoryLabel = app.container.querySelector('.review-top-bar-repository'); - const sourceLink = app.container.querySelector('.review-top-bar-source'); - expect(repositoryLabel).not.toBeNull(); - expect(sourceLink?.href).toBe(source.url); - expect(sourceLink?.textContent).toBe('PR #12'); - expect(sourceLink?.querySelector('svg')).not.toBeNull(); - let toggle = app.container.querySelector('button.codiff-header-toggle'); - expect(toggle?.getAttribute('aria-expanded')).toBe('true'); - expect(app.container.querySelector('.source-description-markdown')?.textContent).toContain( - 'Ship review context.', - ); - await act(async () => { - toggle?.click(); - }); - await waitFor(() => { - toggle = app.container.querySelector('button.codiff-header-toggle'); - expect(toggle?.getAttribute('aria-expanded')).toBe('false'); - expect(app.container.querySelector('.source-description-markdown')).toBeNull(); - }); - await act(async () => { - toggle?.click(); - }); - await waitFor(() => { - expect( - app.container - .querySelector('button.codiff-header-toggle') - ?.getAttribute('aria-expanded'), - ).toBe('true'); - expect(app.container.querySelector('.source-description-markdown')?.textContent).toContain( - 'Ship review context.', - ); - }); -}); - -test('missing pull request descriptions do not render placeholder source context', async () => { - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source: { - description: ' ', - number: 12, - provider: 'github', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - } satisfies ReviewSource, - })), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.codiff-file-header')).not.toBeNull(); - }); - expect(app.container.querySelector('.codiff-source-description-header')).toBeNull(); - expect(app.container.textContent).not.toContain('PR description'); -}); - -test('title-only pull request source context renders as a collapsed static header', async () => { - const cases: ReadonlyArray> = [ - { - description: ' ', - number: 12, - provider: 'github', - title: 'Title without a body', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - }, - { - number: 13, - provider: 'gitlab', - title: 'Merge request title only', - type: 'pull-request', - url: 'https://gitlab.example.com/group/project/-/merge_requests/13', - }, - ]; - - for (const source of cases) { - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.codiff-source-description-header')).not.toBeNull(); - }); - const header = app.container.querySelector('.codiff-source-description-header'); - expect(header?.classList.contains('collapsed')).toBe(true); - expect(header?.querySelector('.source-description-title')?.textContent).toBe(source.title); - expect(header?.querySelector('.codiff-header-toggle-static')).not.toBeNull(); - expect(header?.querySelector('button.codiff-header-toggle')).toBeNull(); - expect(header?.querySelector('.codiff-chevron-box')).toBeNull(); - expect(app.container.querySelector('.source-description-markdown')).toBeNull(); - } -}); - -test('narrative walkthrough stops show pull request descriptions once', async () => { - const changedFile = createChangedFile('src/app.ts'); - const source = { - description: '## Summary\n\nKeep the PR context visible.', - number: 12, - provider: 'github', - title: 'Keep context visible in walkthrough', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - } satisfies ReviewSource; - const narrativeWalkthrough = { - agent: 'codex', - chapters: [ - { - blurb: 'Review the implementation.', - icon: 'gear', - id: 'impl', - stops: [ - { - added: 1, - deleted: 1, - hunkIds: ['src/app.ts:unstaged:h1'], - hunks: [ - { - added: 1, - anchor: { - display: 'src/app.ts', - sectionId: 'src/app.ts:unstaged', - side: 'both', - }, - deleted: 1, - id: 'src/app.ts:unstaged:h1', - path: 'src/app.ts', - status: 'modified', - }, - ], - id: 's1', - importance: 'critical', - prose: 'Review this file.', - title: 'Implementation path', - }, - ], - title: 'Implementation', - }, - ], - focus: 'Focus.', - generatedAt: '2026-06-07T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/repo' }, - source, - support: [], - title: 'Narrative', - version: 4, - } satisfies NarrativeWalkthrough; - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source, - walkthrough: true, - walkthroughFile: '/tmp/walkthrough.json', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: narrativeWalkthrough, - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [changedFile], - source, - })), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - expect(app.container.querySelectorAll('.codiff-source-description-header')).toHaveLength(1); - expect(app.container.querySelectorAll('.source-description-markdown')).toHaveLength(1); - expect(app.container.textContent).toContain('Keep context visible in walkthrough'); - expect(app.container.textContent).toContain('Keep the PR context visible.'); -}); - -test('narrative walkthrough stops do not repeat commit details', async () => { - const changedFile = createChangedFile('src/app.ts'); - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const commitMetadata = { - author: { - date: '2026-01-01T12:00:00Z', - email: 'author@example.com', - name: 'Author', - }, - body: 'Detailed commit body.', - committer: { - date: '2026-01-01T13:00:00Z', - email: 'committer@example.com', - name: 'Committer', - }, - files: [ - { - additions: 1, - binary: false, - deletions: 1, - path: 'src/app.ts', - status: 'modified' as const, - }, - ], - parents: ['parent-sha'], - ref: 'abc1234', - refs: ['main'], - shortRef: 'abc1234', - signature: { - status: 'N', - }, - stats: { - additions: 1, - binaryFiles: 0, - deletions: 1, - files: 1, - renamedFiles: 0, - }, - subject: 'Commit subject', - trailers: [], - } satisfies CommitMetadata; - const narrativeWalkthrough = { - agent: 'codex', - chapters: [ - { - blurb: 'Review the implementation.', - icon: 'gear', - id: 'impl', - stops: [ - { - added: 1, - deleted: 1, - hunkIds: ['src/app.ts:unstaged:h1'], - hunks: [ - { - added: 1, - anchor: { - display: 'src/app.ts', - sectionId: 'src/app.ts:unstaged', - side: 'both', - }, - deleted: 1, - id: 'src/app.ts:unstaged:h1', - path: 'src/app.ts', - status: 'modified', - }, - ], - id: 's1', - importance: 'critical', - prose: 'Review this file without repeating the commit header.', - title: 'Implementation path', - }, - ], - title: 'Implementation', - }, - ], - focus: 'Focus.', - generatedAt: '2026-06-07T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/repo' }, - source, - support: [], - title: 'Narrative', - version: 4, - } satisfies NarrativeWalkthrough; - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source, - walkthrough: true, - walkthroughFile: '/tmp/walkthrough.json', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: narrativeWalkthrough, - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - commitMetadata, - files: [changedFile], - source, - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - expect(container.querySelector('.codiff-source-description-header')).toBeNull(); - expect(container.querySelector('.source-description-markdown')).toBeNull(); - expect(container.querySelector('.wt-stage-title')?.textContent).toContain('Implementation path'); -}); - -test('a walkthrough file loads even without the walkthrough launch flag', async () => { - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const narrativeWalkthrough = { - agent: 'claude', - chapters: [ - { - blurb: 'Review the implementation.', - icon: 'gear', - id: 'impl', - stops: [ - { - added: 1, - deleted: 1, - hunkIds: ['src/app.ts:unstaged:h1'], - hunks: [ - { - added: 1, - anchor: { - display: 'src/app.ts', - sectionId: 'src/app.ts:unstaged', - side: 'both', - }, - deleted: 1, - id: 'src/app.ts:unstaged:h1', - path: 'src/app.ts', - status: 'modified', - }, - ], - id: 'implementation-path', - importance: 'critical', - prose: 'Review this file.', - summary: 'The implementation path.', - title: 'Implementation path', - }, - ], - title: 'Implementation', - }, - ], - focus: 'Focus.', - generatedAt: '2026-06-07T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/repo' }, - source, - support: [], - title: 'Narrative', - version: 4, - } satisfies NarrativeWalkthrough; - - const getNarrativeWalkthrough = vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: narrativeWalkthrough, - })); - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source, - walkthrough: false, - walkthroughFile: '/tmp/walkthrough.json', - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - expect(container.querySelector('.walkthrough-error')).toBeNull(); -}); - -test('plan mode opens the Markdown editor without loading repository state', async () => { - const getRepositoryState = vi.fn(async () => repositoryState); - const completePlan = vi.fn(async (_review: PlanReview, _status: 'closed' | 'done') => {}); - const markPlanReady = vi.fn(async () => {}); - const sharePlan = vi.fn(async (_review: PlanReview) => ({ - status: 'uploaded' as const, - url: 'https://codiff.dev/p/shared-plan', - })); - const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; - const scrollIntoView = vi.fn(); - HTMLElement.prototype.scrollIntoView = scrollIntoView; - const storedReview = { - document: { - id: 'stale-plan-id', - path: '/tmp/old-plan.md', - version: 'stale-version', - }, - threads: [ - { - anchor: { - block: { - fingerprint: 'heading-fingerprint', - path: [1], - text: 'Execute this plan', - type: 'heading', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:00:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'thread-1', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: 'Keep the rollout steps explicit.', - createdAt: '2026-06-24T00:00:00.000Z', - id: 'message-1', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - { - anchor: { - block: { - fingerprint: 'list-item-fingerprint', - path: [2, 0], - text: 'First', - type: 'listitem', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:01:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'empty-thread', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: ' ', - createdAt: '2026-06-24T00:01:00.000Z', - id: 'empty-message', - updatedAt: '2026-06-24T00:01:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:01:00.000Z', - }, - ], - version: 1, - } satisfies PlanReview; - window.codiff = createCodiffMock({ - completePlan, - getFeatureFlags: vi.fn(async () => ({ - planSharing: true, - walkthroughSharing: false, - })), - getGitIdentity: vi.fn(async () => ({ - email: 'current-user@example.com', - gravatarUrl: 'https://example.com/current-user.png', - name: 'Current User', - })), - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: - '---\ntitle: Execute this plan\ndraft: true\n---\n\n# Execute this plan\n\n- First\n- Second\n\n```sh\nvp test\n```\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async () => storedReview), - getRepositoryState, - markPlanReady, - sharePlan, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - HTMLElement.prototype.scrollIntoView = originalScrollIntoView; - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.plan-shell')).not.toBeNull(); - }); - expect(getRepositoryState).not.toHaveBeenCalled(); - expect(markPlanReady).toHaveBeenCalledTimes(1); - expect(container.querySelector('.plan-title')?.textContent).toContain('plan.md'); - await waitFor(() => { - expect(container.querySelector('[data-editor-type="frontmatter"]')).not.toBeNull(); - }); - expect(container.querySelector('.mdx-editor-content h2')).toBeNull(); - expect(container.querySelectorAll('.mdx-editor-content ul > li')).toHaveLength(2); - await waitFor(() => { - expect(container.querySelector('.cm-content')).not.toBeNull(); - }); - expect(container.querySelector('.cm-gutters')).toBeNull(); - expect(container.querySelector('.cm-content')?.textContent).toContain('vp test'); - await waitFor(() => { - expect(container.querySelector('.plan-comment-thread')?.textContent).toContain( - 'Keep the rollout steps explicit.', - ); - }); - const planCommentAvatar = container.querySelector('.plan-comment-thread .avatar.medium'); - expect(planCommentAvatar?.tagName).toBe('SPAN'); - expect(planCommentAvatar?.textContent).toBe('RE'); - const commentTargetButton = container.querySelector('.plan-comment-target'); - expect(commentTargetButton?.textContent).toBe('Heading ยท Execute this plan'); - expect(commentTargetButton?.disabled).toBe(false); - await act(async () => { - commentTargetButton?.click(); - }); - expect(scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - }); - scrollIntoView.mockClear(); - expect(container.querySelector('[data-mdx-annotation-block~="thread-1"]')).not.toBeNull(); - expect(container.querySelectorAll('li[data-mdx-comment-block-type="listitem"]')).toHaveLength(2); - const planWorkspace = container.querySelector('.plan-workspace'); - const editorContent = container.querySelector('.mdx-editor-content'); - const commentBlock = container.querySelectorAll( - 'li[data-mdx-comment-block-type="listitem"]', - )[1]; - expect(planWorkspace).not.toBeNull(); - expect(editorContent).not.toBeNull(); - expect(commentBlock).toBeDefined(); - planWorkspace!.getBoundingClientRect = () => ({ - bottom: 700, - height: 650, - left: 100, - right: 1100, - toJSON: () => {}, - top: 50, - width: 1000, - x: 100, - y: 50, - }); - editorContent!.style.paddingRight = '24px'; - editorContent!.getBoundingClientRect = () => ({ - bottom: 650, - height: 550, - left: 120, - right: 1020, - toJSON: () => {}, - top: 100, - width: 900, - x: 120, - y: 100, - }); - commentBlock!.getBoundingClientRect = () => ({ - bottom: 224, - height: 24, - left: 144, - right: 996, - toJSON: () => {}, - top: 200, - width: 852, - x: 144, - y: 200, - }); - await act(async () => { - commentBlock!.dispatchEvent(new MouseEvent('pointermove', { bubbles: true })); - }); - await waitFor(() => { - expect(container.querySelector('.plan-comment-affordance')).not.toBeNull(); - }); - const commentAffordance = container.querySelector('.plan-comment-affordance')!; - expect(commentAffordance.dataset.mdxCommentButton).toBe(''); - expect(commentAffordance.style.getPropertyValue('--plan-comment-left')).toBe('896px'); - expect(commentAffordance.style.getPropertyValue('--plan-comment-width')).toBe('48px'); - expect(commentAffordance.querySelector(':scope > .plan-comment-add')).not.toBeNull(); - await act(async () => { - editorContent!.dispatchEvent( - new MouseEvent('pointerleave', { relatedTarget: commentAffordance }), - ); - }); - expect(container.querySelector('.plan-comment-affordance')).not.toBeNull(); - await act(async () => { - commentAffordance.dispatchEvent( - new MouseEvent('pointerout', { - bubbles: true, - relatedTarget: editorContent, - }), - ); - }); - expect(container.querySelector('.plan-comment-affordance')).not.toBeNull(); - await act(async () => { - commentAffordance.dispatchEvent( - new MouseEvent('pointerout', { - bubbles: true, - relatedTarget: planWorkspace, - }), - ); - }); - expect(container.querySelector('.plan-comment-affordance')).toBeNull(); - await act(async () => { - container - .querySelectorAll('li[data-mdx-comment-block-type="listitem"]')[0]! - .dispatchEvent(new MouseEvent('pointermove', { bubbles: true })); - commentBlock!.dispatchEvent(new MouseEvent('pointermove', { bubbles: true })); - }); - const addCommentButton = container.querySelector('.plan-comment-add'); - expect(addCommentButton).not.toBeNull(); - await act(async () => { - addCommentButton!.click(); - }); - const activeComment = container.querySelector('.plan-comment-thread.active'); - expect(activeComment).not.toBeNull(); - expect(activeComment!.closest('.plan-comment-position')?.style.top).not.toBe('0px'); - const activeCommentDelete = - activeComment!.querySelector('.review-comment-delete'); - expect(activeCommentDelete).not.toBeNull(); - await act(async () => { - activeCommentDelete!.click(); - }); - const commentRail = container.querySelector('.plan-comment-rail-scroll'); - const commentPosition = container.querySelector('.plan-comment-position'); - const annotatedBlock = container.querySelector( - '[data-mdx-annotation-block~="thread-1"]', - ); - expect(commentRail).not.toBeNull(); - expect(commentPosition).not.toBeNull(); - expect(annotatedBlock).not.toBeNull(); - commentRail!.style.overflowY = 'auto'; - commentRail!.getBoundingClientRect = () => ({ - bottom: 300, - height: 200, - left: 0, - right: 400, - toJSON: () => {}, - top: 100, - width: 400, - x: 0, - y: 100, - }); - commentPosition!.getBoundingClientRect = () => ({ - bottom: 360, - height: 100, - left: 0, - right: 400, - toJSON: () => {}, - top: 260, - width: 400, - x: 0, - y: 260, - }); - const scrollCommentRail = vi.fn(); - commentRail!.scrollTo = scrollCommentRail; - await act(async () => { - annotatedBlock!.click(); - }); - await waitFor(() => { - expect(scrollCommentRail).toHaveBeenCalledWith({ - behavior: 'smooth', - top: 68, - }); - }); - expect(scrollIntoView).not.toHaveBeenCalled(); - const deleteButtons = container.querySelectorAll('.review-comment-delete'); - expect(deleteButtons).toHaveLength(2); - await act(async () => { - deleteButtons[1]!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); - deleteButtons[1]!.click(); - }); - expect(scrollIntoView).not.toHaveBeenCalled(); - const shareButton = container.querySelector('.plan-share-button'); - expect(shareButton?.textContent).toContain('Share'); - await act(async () => { - shareButton?.click(); - }); - await waitFor(() => { - expect(sharePlan).toHaveBeenCalledTimes(1); - }); - expect(sharePlan.mock.calls[0]?.[0].threads).toHaveLength(1); - expect(shareButton?.textContent).toContain('Copied'); - await act(async () => { - container.querySelector('.plan-done-button')?.click(); - }); - await waitFor(() => { - expect(completePlan).toHaveBeenCalledTimes(1); - }); - expect(completePlan).toHaveBeenCalledWith( - expect.objectContaining({ - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'plan-version', - }, - threads: [ - expect.objectContaining({ - id: 'thread-1', - messages: [ - expect.objectContaining({ - body: 'Keep the rollout steps explicit.', - }), - ], - }), - ], - version: 1, - }), - 'done', - ); - expect(completePlan.mock.calls[0]?.[0].threads).toHaveLength(1); -}); - -test('plan mode resolves stored comments whose anchors were already removed', async () => { - const savePlanReview = vi.fn(async (review: PlanReview) => review); - const storedReview = { - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'old-plan-version', - }, - threads: [ - { - anchor: { - block: { - fingerprint: 'removed-heading-fingerprint', - path: [99], - text: 'Removed heading', - type: 'heading', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:00:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'detached-thread', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: 'Keep this comment as history.', - createdAt: '2026-06-24T00:00:00.000Z', - id: 'detached-message', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - version: 1, - } satisfies PlanReview; - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: '# Current plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async () => storedReview), - savePlanReview, - }); - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await using _resource = { - async [Symbol.asyncDispose]() { - await act(async () => root.unmount()); - container.remove(); - }, - }; - await act(async () => { - root.render(); - }); - await waitFor(() => { - expect(savePlanReview).toHaveBeenCalledWith( - expect.objectContaining({ - threads: [ - expect.objectContaining({ - id: 'detached-thread', - resolution: expect.objectContaining({ - reason: 'anchor-removed', - resolvedAt: expect.any(String), - }), - status: 'resolved', - }), - ], - }), - ); - }); - const resolvedSection = container.querySelector('.plan-resolved-comments'); - expect(resolvedSection?.open).toBe(false); - expect(resolvedSection?.querySelector('summary')?.textContent).toBe('Resolved comments (1)'); - expect(container.querySelector('.plan-comment-thread.resolved')?.textContent).toContain( - 'Resolved after target removal', - ); - expect(container.querySelector('[data-mdx-annotation-block~="detached-thread"]')).toBeNull(); - await act(async () => { - resolvedSection!.open = true; - resolvedSection!.dispatchEvent(new Event('toggle')); - }); - await act(async () => { - resolvedSection?.querySelector('.review-comment-delete')?.click(); - }); - await waitFor(() => { - expect(container.querySelector('.plan-resolved-comments')).toBeNull(); - expect(savePlanReview).toHaveBeenLastCalledWith( - expect.objectContaining({ - threads: [], - }), - ); - }); -}); - -test('plan mode keeps comments open when their anchors are removed during the current review', async () => { - let publishMarkdownChange: - | ((change: { - deleted: boolean; - document: { - content: string; - id: string; - kind: 'plan'; - path: string; - version: string; - }; - id: string; - }) => void) - | null = null; - const savePlanReview = vi.fn(async (review: PlanReview) => review); - const storedReview = { - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'plan-version', - }, - threads: [ - { - anchor: { - block: { - fingerprint: 'heading-fingerprint', - path: [0], - text: 'Current plan', - type: 'heading', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:00:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'live-thread', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: 'The agent still needs to process this.', - createdAt: '2026-06-24T00:00:00.000Z', - id: 'live-message', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - version: 1, - } satisfies PlanReview; - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: '# Current plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async () => storedReview), - onMarkdownDocumentChanged: vi.fn((callback) => { - publishMarkdownChange = callback; - return () => { - publishMarkdownChange = null; - }; - }), - savePlanReview, - }); - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await using _resource = { - async [Symbol.asyncDispose]() { - await act(async () => root.unmount()); - container.remove(); - }, - }; - await act(async () => { - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('[data-mdx-annotation-block~="live-thread"]')).not.toBeNull(); - expect(publishMarkdownChange).not.toBeNull(); - }); - savePlanReview.mockClear(); - await act(async () => { - publishMarkdownChange?.({ - deleted: false, - document: { - content: '# Replacement plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan', - path: '/tmp/plan.md', - version: 'next-plan-version', - }, - id: 'plan:/tmp/plan.md', - }); - }); - await waitFor(() => { - expect(container.querySelector('[data-mdx-annotation-block~="live-thread"]')).toBeNull(); - }); - expect(container.querySelector('.plan-resolved-comments')).toBeNull(); - expect(container.querySelector('.plan-comment-position .plan-comment-thread')).not.toBeNull(); - expect( - savePlanReview.mock.calls.some( - ([review]) => - review.threads.find((thread) => thread.id === 'live-thread')?.status === 'resolved', - ), - ).toBe(false); -}); - -test('closing plan mode flushes and returns a closed handoff', async () => { - const completePlan = vi.fn(async (_review: PlanReview, _status: 'closed' | 'done') => {}); - let blockPlanReviewSave = false; - let resolvePlanReviewSave: (() => void) | null = null; - const savePlanReview = vi.fn((review: PlanReview) => { - if (!blockPlanReviewSave) { - return Promise.resolve(review); - } - return new Promise((resolveSave) => { - resolvePlanReviewSave = () => resolveSave(review); - }); - }); - let requestClose: (() => void) | null = null; - window.codiff = createCodiffMock({ - completePlan, - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: '# Execute this plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async (): Promise => ({ - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'plan-version', - }, - threads: [ - { - anchor: { - block: { - fingerprint: 'heading-fingerprint', - path: [0], - text: 'Execute this plan', - type: 'heading', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:00:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'thread-1', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: 'Keep this requirement.', - createdAt: '2026-06-24T00:00:00.000Z', - id: 'message-1', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - version: 1, - })), - onPlanCloseRequested: vi.fn((callback) => { - requestClose = callback; - return () => { - requestClose = null; - }; - }), - savePlanReview, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.plan-shell')).not.toBeNull(); - expect(requestClose).not.toBeNull(); - expect(container.querySelector('.plan-comment-thread')).not.toBeNull(); - }); - await act(async () => { - await new Promise((resolveWait) => setTimeout(resolveWait, 75)); - }); - savePlanReview.mockClear(); - blockPlanReviewSave = true; - await act(async () => { - requestClose?.(); - }); - await waitFor(() => { - expect(savePlanReview).toHaveBeenCalledTimes(1); - }); - expect(completePlan).not.toHaveBeenCalled(); - expect( - [...container.querySelectorAll('[contenteditable]')].map((element) => - element.getAttribute('contenteditable'), - ), - ).toEqual(['false', 'false']); - expect(container.querySelector('.review-comment-delete')?.disabled).toBe(true); - await act(async () => { - resolvePlanReviewSave?.(); - }); - await waitFor(() => { - expect(completePlan).toHaveBeenCalledTimes(1); - }); - expect(completePlan).toHaveBeenCalledWith( - expect.objectContaining({ - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'plan-version', - }, - threads: [ - expect.objectContaining({ - id: 'thread-1', - }), - ], - version: 1, - }), - 'closed', - ); -}); - -test('plan mode recovers from an unreadable review sidecar', async () => { - const markPlanReady = vi.fn(async () => {}); - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: '# Execute this plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async () => { - throw new Error('Invalid plan review.'); - }), - markPlanReady, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.plan-shell')).not.toBeNull(); - }); - expect(container.querySelector('[role="alert"]')?.textContent).toContain('Invalid plan review.'); - expect(container.querySelector('[contenteditable="true"]')).not.toBeNull(); - expect(markPlanReady).toHaveBeenCalledTimes(1); -}); - -test('a walkthrough file that no longer anchors surfaces a dismissible banner', async () => { - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source, - walkthrough: false, - walkthroughFile: '/tmp/broken-walkthrough.json', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - reason: 'These changes were committed since the walkthrough was authored.', - status: 'unavailable' as const, - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.walkthrough-outdated-banner.visible')).not.toBeNull(); - }); - const banner = container.querySelector('.walkthrough-outdated-banner'); - expect(banner?.textContent).toContain('committed since the walkthrough was authored'); - expect(banner?.textContent).toContain('Showing history instead.'); - await act(async () => { - banner?.querySelector('.repository-change-dismiss')?.click(); - }); - expect(container.querySelector('.walkthrough-outdated-banner.visible')).toBeNull(); -}); - -test('repository changes show the update banner without refreshing the working tree', async () => { - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - const getRepositoryState = vi.fn(async () => repositoryState); - - window.codiff = createCodiffMock({ - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(onRepositoryChanged).not.toBeNull(); - }); - expect(container.querySelector('.repository-change-banner.visible')).toBeNull(); - expect(getRepositoryState).toHaveBeenCalledTimes(1); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(container.querySelector('.repository-change-banner.visible')).not.toBeNull(); - expect(getRepositoryState).toHaveBeenCalledTimes(1); -}); - -test('clicking the change banner refreshes the repository in place', async () => { - const initialFile = { - fingerprint: 'src/app.ts:1', - path: 'src/app.ts', - sections: [ - { - binary: false, - id: 'src/app.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }, - ], - status: 'modified', - } satisfies ChangedFile; - const addedFile = { - fingerprint: 'src/added.ts:1', - path: 'src/added.ts', - sections: [ - { - binary: false, - id: 'src/added.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/added.ts b/src/added.ts\n@@ -0,0 +1 @@\n+created\n', - }, - ], - status: 'added', - } satisfies ChangedFile; - - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - const getRepositoryState = vi.fn(async () => { - stateRequests += 1; - return stateRequests === 1 - ? { ...repositoryState, files: [initialFile] } - : { - ...repositoryState, - files: [ - addedFile, - { - ...initialFile, - fingerprint: 'src/app.ts:2', - sections: [ - { - binary: false, - id: 'src/app.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+newer\n', - }, - ], - }, - ], - }; - }); - - window.codiff = createCodiffMock({ - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(onRepositoryChanged).not.toBeNull(); - }); - expect(container.textContent).not.toContain('added.ts'); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - const refreshButton = container.querySelector('.repository-change-reload'); - expect(refreshButton).not.toBeNull(); - await act(async () => { - refreshButton?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - // New file appears without a window reload, and the banner clears. - expect(getRepositoryState).toHaveBeenCalledTimes(2); - expect(container.textContent).toContain('added.ts'); - expect(container.querySelector('.repository-change-banner.visible')).toBeNull(); - // The changed file must not appear twice (old + new version). - const appOccurrences = container.textContent?.split('app.ts').length ?? 1; - expect(appOccurrences - 1).toBeLessThanOrEqual(2); - expect(container.textContent).not.toContain('-old\n+new\n'); -}); - -test('refreshing all changes re-resolves the branch snapshot', async () => { - const initialSource = { - baseRef: 'base123', - headRef: 'head123', - ref: 'main', - type: 'branch-working-tree', - } satisfies ReviewSource; - const refreshedSource = { - baseRef: 'base123', - headRef: 'head456', - ref: 'main', - type: 'branch-working-tree', - } satisfies ReviewSource; - const initialFile = createChangedFile('src/initial.ts', { kind: 'commit' }); - const addedFile = createChangedFile('src/added.ts', { kind: 'commit' }); - const initialState = { - ...repositoryState, - branch: 'feature', - files: [initialFile], - source: initialSource, - } satisfies RepositoryState; - const refreshedState = { - ...initialState, - files: [initialFile, addedFile], - source: refreshedSource, - } satisfies RepositoryState; - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let branchWorkingTreeRequests = 0; - const getRepositoryState = vi.fn( - async (requestedSource) => { - if (requestedSource?.type === 'branch-diff') { - return { - ...refreshedState, - source: requestedSource, - }; - } - branchWorkingTreeRequests += 1; - return branchWorkingTreeRequests === 1 ? initialState : refreshedState; - }, - ); - const getRepositoryHistory = vi.fn(async () => ({ - entries: [], - root: '/repo', - })); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source: { ref: 'main', type: 'branch-working-tree' as const }, - walkthrough: false, - })), - getRepositoryHistory, - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - const findButton = (label: string) => - Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label), - ); - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(onRepositoryChanged).not.toBeNull(); - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('added.ts'); - }); - expect(getRepositoryState).toHaveBeenNthCalledWith(2, { - ref: 'main', - type: 'branch-working-tree', - }); - expect(getRepositoryHistory).toHaveBeenLastCalledWith(expect.any(Number), { - ref: 'main', - type: 'branch-working-tree', - }); - await act(async () => { - findButton('History')?.click(); - }); - await waitFor(() => { - expect(findButton('Committed only vs main')).toBeTruthy(); - }); - await act(async () => { - findButton('Committed only vs main')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(getRepositoryState).toHaveBeenLastCalledWith({ - baseRef: refreshedSource.baseRef, - headRef: refreshedSource.headRef, - ref: refreshedSource.ref, - type: 'branch-diff', - }); -}); - -test('refreshing changed viewed files expands them in place', async () => { - const initialFile = createChangedFile('src/viewed.ts', { - fingerprint: 'viewed:1', - }); - const refreshedFile = createChangedFile('src/viewed.ts', { - fingerprint: 'viewed:2', - patch: 'diff --git a/src/viewed.ts b/src/viewed.ts\n@@ -1 +1 @@\n-old\n+newer\n', - }); - window.localStorage.setItem( - 'codiff:viewed:/repo', - JSON.stringify({ [initialFile.path]: initialFile.fingerprint }), - ); - - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - const getRepositoryState = vi.fn(async () => { - stateRequests += 1; - return { - ...repositoryState, - files: [stateRequests === 1 ? initialFile : refreshedFile], - }; - }); - - window.codiff = createCodiffMock({ - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - const header = container.querySelector('.codiff-file-header'); - expect(header).not.toBeNull(); - expect(header?.classList.contains('collapsed')).toBe(true); - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - const refreshButton = container.querySelector('.repository-change-reload'); - expect(refreshButton).not.toBeNull(); - await act(async () => { - refreshButton?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - const header = container.querySelector('.codiff-file-header'); - expect(header).not.toBeNull(); - expect(header?.classList.contains('collapsed')).toBe(false); - expect(header?.querySelector('.codiff-viewed-button')?.getAttribute('aria-pressed')).toBe( - 'false', - ); - }); -}); - -test('refreshing changed files automatically regenerates the walkthrough', async () => { - const appFile = createChangedFile('src/app.ts'); - const addedFile = createChangedFile('src/added.ts', { - fingerprint: 'src/added.ts:1', - patch: 'diff --git a/src/added.ts b/src/added.ts\n@@ -0,0 +1 @@\n+created\n', - status: 'added', - }); - const laterFile = createChangedFile('src/later.ts', { - fingerprint: 'src/later.ts:1', - patch: 'diff --git a/src/later.ts b/src/later.ts\n@@ -0,0 +1 @@\n+later\n', - status: 'added', - }); - const initialWalkthrough = createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - ]); - const addedWalkthrough = { - ...createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - { added: 1, path: addedFile.path, status: 'added' }, - ]), - focus: 'Review the added file.', - }; - const laterWalkthrough = { - ...createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - { added: 1, path: addedFile.path, status: 'added' }, - { added: 1, path: laterFile.path, status: 'added' }, - ]), - focus: 'Review the later file.', - }; - - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - const getRepositoryState = vi.fn(async () => { - stateRequests += 1; - return { - ...repositoryState, - files: - stateRequests === 1 - ? [appFile] - : stateRequests === 2 - ? [appFile, addedFile] - : [appFile, addedFile, laterFile], - }; - }); - let walkthroughRequests = 0; - const getNarrativeWalkthrough = vi.fn(async () => { - walkthroughRequests += 1; - return { - status: 'ready' as const, - walkthrough: - walkthroughRequests === 1 - ? initialWalkthrough - : walkthroughRequests === 2 - ? addedWalkthrough - : laterWalkthrough, - }; - }); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(2); - expect(container.textContent).toContain('Review the added file.'); - expect(container.textContent).toContain('added.ts'); - expect(container.textContent).not.toContain('Regenerate walkthrough'); - expect(container.textContent).not.toContain('Changed after the walkthrough was generated.'); - }); - expect(getNarrativeWalkthrough).toHaveBeenLastCalledWith(repositoryState.source, { - force: true, - previousWalkthrough: initialWalkthrough, - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(3); - expect(container.textContent).toContain('Review the later file.'); - expect(container.textContent).toContain('added.ts'); - expect(container.textContent).toContain('later.ts'); - }); - expect(getNarrativeWalkthrough).toHaveBeenLastCalledWith(repositoryState.source, { - force: true, - previousWalkthrough: addedWalkthrough, - }); -}); - -test('drops automatic walkthrough regeneration when the same source refreshes again', async () => { - const appFile = createChangedFile('src/app.ts', { - fingerprint: 'src/app.ts:1', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }); - const addedFile = createChangedFile('src/added.ts', { - fingerprint: 'src/added.ts:1', - patch: 'diff --git a/src/added.ts b/src/added.ts\n@@ -0,0 +1 @@\n+added\n', - status: 'added', - }); - const laterFile = createChangedFile('src/later.ts', { - fingerprint: 'src/later.ts:1', - patch: 'diff --git a/src/later.ts b/src/later.ts\n@@ -0,0 +1 @@\n+later\n', - status: 'added', - }); - const initialWalkthrough = createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - ]); - const staleRegeneration = { - ...createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - { added: 1, path: addedFile.path, status: 'added' }, - ]), - focus: 'Stale regeneration.', - }; - const latestRegeneration = { - ...createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - { added: 1, path: addedFile.path, status: 'added' }, - { added: 1, path: laterFile.path, status: 'added' }, - ]), - focus: 'Latest regeneration.', - }; - - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - const getRepositoryState = vi.fn(async () => { - stateRequests += 1; - return { - ...repositoryState, - files: - stateRequests === 1 - ? [appFile] - : stateRequests === 2 - ? [appFile, addedFile] - : [appFile, addedFile, laterFile], - }; - }); - let walkthroughRequests = 0; - let resolveFirstRegeneration: - | ((result: { status: 'ready'; walkthrough: NarrativeWalkthrough }) => void) - | null = null; - let resolveSecondRegeneration: - | ((result: { status: 'ready'; walkthrough: NarrativeWalkthrough }) => void) - | null = null; - const getNarrativeWalkthrough = vi.fn(() => { - walkthroughRequests += 1; - if (walkthroughRequests === 1) { - return Promise.resolve({ - status: 'ready' as const, - walkthrough: initialWalkthrough, - }); - } - return new Promise<{ status: 'ready'; walkthrough: NarrativeWalkthrough }>((resolve) => { - if (walkthroughRequests === 2) { - resolveFirstRegeneration = resolve; - } else { - resolveSecondRegeneration = resolve; - } - }); - }); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const refresh = async () => { - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - }; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - }); - await refresh(); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(2); - expect(container.querySelector('.wt-stop-block')).toBeNull(); - expect(container.textContent).toContain('Generating walkthrough'); - expect(container.textContent).not.toContain('Regenerate walkthrough'); - }); - await refresh(); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(3); - }); - await act(async () => { - resolveFirstRegeneration?.({ - status: 'ready', - walkthrough: staleRegeneration, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(container.querySelector('.wt-stop-block')).toBeNull(); - expect(container.textContent).not.toContain('Stale regeneration.'); - await act(async () => { - resolveSecondRegeneration?.({ - status: 'ready', - walkthrough: latestRegeneration, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('Latest regeneration.'); - expect(container.textContent).toContain('later.ts'); - }); -}); - -test('committing and then editing again clears and regenerates the walkthrough', async () => { - const appFile = createChangedFile('src/app.ts'); - const nextFile = createChangedFile('src/next.ts', { - fingerprint: 'src/next.ts:1', - patch: 'diff --git a/src/next.ts b/src/next.ts\n@@ -0,0 +1 @@\n+next\n', - status: 'added', - }); - const initialWalkthrough = { - ...createNarrativeWalkthroughFixture([{ added: 1, path: appFile.path, status: 'modified' }]), - focus: 'Initial walkthrough.', - }; - const nextWalkthrough = { - ...createNarrativeWalkthroughFixture([{ added: 1, path: nextFile.path, status: 'added' }]), - focus: 'Fresh walkthrough.', - }; - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - let walkthroughRequests = 0; - let resolveNextWalkthrough: - | ((result: { status: 'ready'; walkthrough: NarrativeWalkthrough }) => void) - | null = null; - const getNarrativeWalkthrough = vi.fn(() => { - walkthroughRequests += 1; - if (walkthroughRequests === 1) { - return Promise.resolve({ - status: 'ready' as const, - walkthrough: initialWalkthrough, - }); - } - return new Promise<{ status: 'ready'; walkthrough: NarrativeWalkthrough }>((resolve) => { - resolveNextWalkthrough = resolve; - }); - }); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => { - stateRequests += 1; - return { - ...repositoryState, - files: stateRequests === 1 ? [appFile] : stateRequests === 2 ? [] : [nextFile], - }; - }), - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('No local changes'); - expect(container.querySelector('.wt-stop-block')).toBeNull(); - }); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(2); - expect(container.textContent).toContain('Generating walkthrough'); - expect(container.textContent).not.toContain('Initial walkthrough.'); - expect(container.querySelector('.wt-stop-block')).toBeNull(); - }); - expect(getNarrativeWalkthrough).toHaveBeenLastCalledWith(repositoryState.source, { - force: true, - previousWalkthrough: undefined, - }); - await act(async () => { - resolveNextWalkthrough?.({ status: 'ready', walkthrough: nextWalkthrough }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('Fresh walkthrough.'); - expect(container.textContent).toContain('next.ts'); - expect(container.textContent).not.toContain('Regenerate walkthrough'); - expect(container.textContent).not.toContain('Changed after the walkthrough was generated.'); - }); -}); - -test('walkthrough launch errors stay on the walkthrough tab without automatic retries', async () => { - const changedFile = { - fingerprint: 'src/app.ts:1', - path: 'src/app.ts', - sections: [ - { - binary: false, - id: 'src/app.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }, - ], - status: 'modified', - } satisfies ChangedFile; - const getNarrativeWalkthrough = vi.fn(async () => ({ - reason: 'Codex walkthrough timed out.', - status: 'unavailable' as const, - })); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [changedFile], - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const getTab = (label: string) => - Array.from(container.querySelectorAll('button[role="tab"]')).find((button) => - button.textContent?.includes(label), - ) as HTMLButtonElement | undefined; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.textContent).toContain('Walkthrough unavailable'); - }); - expect(getTab('Walkthrough')?.getAttribute('aria-selected')).toBe('true'); - expect(container.querySelector('.sidebar-walkthrough-status')).not.toBeNull(); - expect(container.querySelector('.sidebar .file-tree-shell')).toBeNull(); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - await act(async () => { - getTab('Tree')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - getTab('Walkthrough')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(container.textContent).toContain('Walkthrough unavailable'); - expect(container.querySelector('.sidebar .file-tree-shell')).toBeNull(); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); -}); - -test('walkthrough progress events replace the loading line without exposing agent output', async () => { - const changedFile = { - fingerprint: 'src/app.ts:1', - path: 'src/app.ts', - sections: [ - { - binary: false, - id: 'src/app.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }, - ], - status: 'modified', - } satisfies ChangedFile; - let onProgress: ((progress: WalkthroughProgressEvent) => void) | null = null; - let resolveWalkthrough: ((result: { reason: string; status: 'unavailable' }) => void) | null = - null; - const getNarrativeWalkthrough = vi.fn( - () => - new Promise<{ reason: string; status: 'unavailable' }>((resolve) => { - resolveWalkthrough = resolve; - }), - ); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [changedFile], - })), - onWalkthroughProgress: vi.fn((callback) => { - onProgress = callback; - return () => { - onProgress = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.textContent).toContain('Generating walkthroughโ€ฆ'); - }); - await act(async () => { - onProgress?.({ phase: 'agent-generation' }); - }); - expect(container.textContent).toContain('Analyzing changesโ€ฆ'); - expect(container.textContent).not.toContain('Generating walkthroughโ€ฆ'); - await act(async () => { - onProgress?.({ phase: 'response-received' }); - }); - expect(container.textContent).toContain('Building walkthroughโ€ฆ'); - expect(container.querySelector('.wt-generation')).toBeNull(); - await act(async () => { - resolveWalkthrough?.({ - reason: 'Stopped for test.', - status: 'unavailable', - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); -}); - -test('history filter matches commits by author name', async () => { - window.codiff = createCodiffMock({ - getRepositoryHistory: vi.fn(async () => ({ - entries: [ - { - author: 'Ada Lovelace', - committedAt: Date.now(), - parents: [], - ref: 'aaa1111', - subject: 'Fix parser', - }, - { - author: 'Grace Hopper', - committedAt: Date.now(), - parents: [], - ref: 'bbb2222', - subject: 'Update docs', - }, - ], - root: '/repo', - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const findButton = (label: string) => - Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label), - ); - const historySubjects = () => - Array.from(container.querySelectorAll('.history-entry-subject')).map( - (element) => element.textContent, - ); - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - }); - await act(async () => { - findButton('History')?.click(); - }); - await waitFor(() => { - expect(historySubjects()).toContain('Fix parser'); - expect(historySubjects()).toContain('Update docs'); - }); - const searchInput = container.querySelector('.sidebar-search'); - expect(searchInput).toBeTruthy(); - const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - await act(async () => { - setInputValue?.call(searchInput, 'grace'); - searchInput?.dispatchEvent(new Event('input', { bubbles: true })); - }); - await waitFor(() => { - expect(historySubjects()).toContain('Update docs'); - expect(historySubjects()).not.toContain('Fix parser'); - }); -}); - -test('Pi not-found walkthrough errors show the agent recovery panel', async () => { - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - agentBackend: 'pi' as const, - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - code: 'PI_NOT_FOUND' as const, - reason: 'Pi CLI was not found.', - status: 'unavailable' as const, - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.textContent).toContain('Pi CLI not found'); - }); - expect(container.textContent).toContain('Pi CLI was not found.'); - expect(container.textContent).toContain('Review Files'); -}); - -test('pull request comments hydrate through the public preload capability', async () => { - const file = { - fingerprint: 'src/app.ts:pull-request', - path: 'src/app.ts', - sections: [ - { - binary: false, - id: 'src/app.ts:pull-request:1', - kind: 'pull-request', - loadState: 'ready', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }, - ], - status: 'modified', - } satisfies ChangedFile; - const source = { - headSha: gitSha('b'.repeat(40)), - number: 1, - owner: 'octo', - provider: 'github', - repo: 'example', - type: 'pull-request', - url: 'https://github.com/octo/example/pull/1', - } satisfies ResolvedReviewSource; - const getReviewComments = vi.fn(async () => [ - { - author: { login: 'reviewer' }, - body: 'Loaded after the initial review state.', - filePath: file.path, - id: 'github:1', - lineNumber: 1, - side: 'additions' as const, - threadId: '1', - }, - ]); - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - branch: 'feature', - files: [file], - generatedAt: 1, - launchPath: '/repo', - reviewCommentsLoadState: 'not-loaded' as const, - root: '/repo', - source, - })), - getReviewComments, - }); - - await using view = await renderReact(); - await waitFor(() => { - expect(getReviewComments).toHaveBeenCalledWith(source); - expect(view.container.textContent).toContain('Loaded after the initial review state.'); - }); -}); diff --git a/core/__tests__/App-shell.test.tsx b/core/__tests__/App-shell.test.tsx new file mode 100644 index 00000000..91ad0e52 --- /dev/null +++ b/core/__tests__/App-shell.test.tsx @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + */ + +import { expect, test, vi } from 'vite-plus/test'; +import { createDefaultConfig } from '../config/defaults.ts'; +import { writeReloadSelection } from '../lib/reload-selection.ts'; +import type { GitSha, RepositoryState } from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const hostProps = vi.hoisted(() => vi.fn()); + +vi.mock('../app/RepositoryReviewHost.tsx', () => ({ + RepositoryReviewHost: (props: unknown) => { + hostProps(props); + return
Repository host
; + }, +})); + +import App from '../App.tsx'; + +const state = { + branch: 'main', + files: [], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' }, +} satisfies RepositoryState; +const gitSha = (character: string) => character.repeat(40) as GitSha; + +const installAppApi = ({ + launchOptions = { repositoryPathProvided: true, walkthrough: false }, + repositoryState = state, +}: { + launchOptions?: Awaited>; + repositoryState?: RepositoryState; +} = {}) => { + const config = createDefaultConfig(); + const api = { + getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/skill' })), + getConfig: vi.fn(async () => config), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => null), + getLaunchOptions: vi.fn(async () => launchOptions), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState: vi.fn(async () => repositoryState), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + onConfigChanged: vi.fn(() => () => {}), + }; + window.codiff = api as unknown as Window['codiff']; + return { api, config }; +}; + +test('App bootstraps the desktop shell before mounting the repository host', async () => { + const config = createDefaultConfig(); + window.codiff = { + getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/skill' })), + getConfig: vi.fn(async () => config), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: true })), + getGitIdentity: vi.fn(async () => ({ email: 'reviewer@example.com', name: 'Reviewer' })), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false })), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState: vi.fn(async () => state), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + onConfigChanged: vi.fn(() => () => {}), + } as unknown as Window['codiff']; + + const view = await renderReact(); + try { + await waitFor(() => expect(view.container.textContent).toContain('Repository host')); + expect(hostProps).toHaveBeenCalledWith( + expect.objectContaining({ + bootstrap: expect.objectContaining({ + historySource: null, + mainMode: 'review', + selectedPath: null, + sidebarMode: 'history', + state, + }), + config, + initialHistory: [], + launchOptions: expect.objectContaining({ walkthrough: false }), + walkthroughSharingEnabled: true, + }), + ); + } finally { + await view.cleanup(); + } +}); + +test('App restores branch History scope and reload deltas as one bootstrap value', async () => { + hostProps.mockClear(); + const branchSource = { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-diff', + } as const; + const previousState = { + ...state, + files: [createChangedFile('src/branch.ts', { fingerprint: 'before' })], + source: { ...branchSource, type: 'branch-working-tree' as const }, + } satisfies RepositoryState; + const nextState = { + ...previousState, + files: [createChangedFile('src/branch.ts', { fingerprint: 'after' })], + } satisfies RepositoryState; + writeReloadSelection(previousState, 'src/branch.ts', branchSource, 'review'); + const { api } = installAppApi({ repositoryState: nextState }); + + await using view = await renderReact(); + await waitFor(() => expect(view.container.textContent).toContain('Repository host')); + expect(api.getRepositoryState).toHaveBeenCalledWith({ + ref: 'feature', + type: 'branch-working-tree', + }); + expect(api.getRepositoryHistory).toHaveBeenCalledWith(30, branchSource); + expect(hostProps.mock.lastCall?.[0]).toMatchObject({ + bootstrap: { + historySource: branchSource, + reloadDeltaPaths: new Set(['src/branch.ts']), + selectedPath: 'src/branch.ts', + }, + }); +}); diff --git a/core/__tests__/FileTree.test.tsx b/core/__tests__/FileTree.test.tsx index 9c625a61..bd00f30b 100644 --- a/core/__tests__/FileTree.test.tsx +++ b/core/__tests__/FileTree.test.tsx @@ -8,6 +8,8 @@ import { ReviewFileTree } from '../app/components/FileTree.tsx'; import { createChangedFile } from './helpers/fixtures.ts'; import { renderReact, waitFor } from './helpers/react.tsx'; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; + test('review file trees share selection, activation, decorations, and row styling', async () => { const firstFile = createChangedFile('src/first.ts', { fingerprint: 'first-current', @@ -60,3 +62,36 @@ test('review file trees share selection, activation, decorations, and row stylin ).toBe('true'); }); }); + +test('reveals an initially selected restored path exactly once', async () => { + const scrollIntoView = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(() => {}); + const firstFile = createChangedFile('src/first.ts'); + const restoredFile = createChangedFile('src/restored.ts'); + await using view = await renderReact( + {}} + scrollSelectedPathIntoView + selectedPath={restoredFile.path} + showWhitespace={false} + />, + ); + + await waitFor(() => + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }), + ); + scrollIntoView.mockClear(); + await view.rerender( + {}} + scrollSelectedPathIntoView + selectedPath={restoredFile.path} + showWhitespace={false} + />, + ); + expect(scrollIntoView).not.toHaveBeenCalled(); + scrollIntoView.mockRestore(); +}); diff --git a/core/__tests__/RepositoryReviewHost-capabilities.test.tsx b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx new file mode 100644 index 00000000..4fa9c3b7 --- /dev/null +++ b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx @@ -0,0 +1,1388 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { expect, test, vi } from 'vite-plus/test'; +import type { CodiffConfig } from '../config/types.ts'; +import { + resolveRepositoryReviewBootstrap, + type RepositoryReviewBootstrap, +} from '../lib/repository-review-bootstrap.ts'; +import type { ReviewSurfaceProps } from '../ReviewSurface.tsx'; +import type { + CodiffLaunchOptions, + GitSha, + NarrativeWalkthrough, + NarrativeWalkthroughResult, + RepositoryHistory, + RepositoryState, + ResolvedReviewSource, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const surfaceProps = vi.hoisted(() => vi.fn()); +const writeReloadSelection = vi.hoisted(() => vi.fn()); + +vi.mock('../ReviewSurface.tsx', async (importOriginal) => ({ + ...(await importOriginal()), + ReviewSurface: (props: ReviewSurfaceProps) => { + surfaceProps(props); + return
{props.capabilities?.desktop?.beforeContent}
; + }, +})); + +vi.mock('../lib/reload-selection.ts', async (importOriginal) => ({ + ...(await importOriginal()), + writeReloadSelection, +})); + +import { RepositoryReviewHost } from '../app/RepositoryReviewHost.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; + +const createMemoryStorage = () => { + const values = new Map(); + return { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + removeItem: (key: string) => { + values.delete(key); + }, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + }; +}; + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createMemoryStorage(), +}); + +const unsubscribe = () => {}; +const gitSha = (character: string) => character.repeat(40) as GitSha; + +const stateFor = ( + source: ResolvedReviewSource, + files: RepositoryState['files'] = [], +): RepositoryState => ({ + branch: 'main', + files, + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source, +}); + +const installWindowApi = () => { + let findInDiffs: (() => void) | null = null; + let copyPendingComments: (() => string | Promise) | null = null; + let refreshRequest: (() => void) | null = null; + let repositoryChanged: (() => void) | null = null; + let windowFullScreenChanged: ((isFullScreen: boolean) => void) | null = null; + const api = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + askReviewAssistant: vi.fn(async () => ({ reply: 'Checked.', status: 'ready' as const })), + cancelDiffContentRequest: vi.fn(), + createWalkthroughCommit: vi.fn(async () => ({ status: 'committed' as const })), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getDiffImageContent: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' })), + getDiffSectionContent: vi.fn(async (request: { kind: string; path: string }) => ({ + binary: false, + id: `${request.path}:${request.kind}`, + kind: request.kind, + loadState: 'ready', + patch: '@@ -1 +1 @@\n-old\n+new\n', + })), + getNarrativeWalkthrough: vi.fn(async (): Promise => ({ + reason: 'Not used.', + status: 'unavailable', + })), + getRepositoryHistory: vi.fn(async (): Promise => ({ + entries: [], + root: '/repo', + })), + getRepositoryState: vi.fn(async () => stateFor({ type: 'working-tree' })), + getReviewComments: vi.fn(async () => []), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onCopyPendingCommentsRequest: vi.fn((callback: () => string | Promise) => { + copyPendingComments = callback; + return unsubscribe; + }), + onFindInDiffs: vi.fn((callback: () => void) => { + findInDiffs = callback; + return unsubscribe; + }), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn((callback: () => void) => { + refreshRequest = callback; + return unsubscribe; + }), + onRepositoryChanged: vi.fn((callback: () => void) => { + repositoryChanged = callback; + return unsubscribe; + }), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughCommitOutput: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn((callback: (isFullScreen: boolean) => void) => { + windowFullScreenChanged = callback; + return unsubscribe; + }), + openConfigFile: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + resolveReviewContext: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' })), + setDiffStyle: vi.fn(async () => {}), + setShowOutdated: vi.fn(async () => {}), + setWordWrap: vi.fn(async () => {}), + shareWalkthrough: vi.fn(async () => ({ status: 'shared', url: 'https://example.test' })), + submitPullRequestComment: vi.fn(async () => ({})), + submitPullRequestReview: vi.fn(async () => ({ status: 'submitted', submittedDraftIds: [] })), + updateWalkthroughCommitMessage: vi.fn(async () => ({ status: 'unavailable' })), + }; + window.codiff = api as unknown as Window['codiff']; + return { + api, + copyPendingComments: () => copyPendingComments, + findInDiffs: () => findInDiffs, + refreshRequest: () => refreshRequest, + repositoryChanged: () => repositoryChanged, + windowFullScreenChanged: () => windowFullScreenChanged, + }; +}; + +type RenderHostOptions = { + bootstrap?: Partial; + initialWalkthroughResult?: NarrativeWalkthroughResult; + launchOptions?: CodiffLaunchOptions; +}; + +const renderHost = async ( + state: RepositoryState, + config = createDefaultConfig(), + options: RenderHostOptions = {}, +) => { + const resolvedLaunchOptions = options.launchOptions ?? { + repositoryPathProvided: true, + walkthrough: false, + }; + const bootstrap = { + ...resolveRepositoryReviewBootstrap({ + launchOptions: resolvedLaunchOptions, + reloadSelection: null, + state, + }), + ...options.bootstrap, + }; + const render = (nextConfig: CodiffConfig) => ( + + ); + const view = await renderReact(render(config)); + return { + ...view, + rerenderConfig: (nextConfig: CodiffConfig) => view.rerender(render(nextConfig)), + }; +}; + +const walkthroughFor = ( + state: RepositoryState, + title: string, + agent: NarrativeWalkthrough['agent'] = 'codex', +): NarrativeWalkthrough => ({ + agent, + chapters: [], + focus: 'Review the generated change.', + generatedAt: '2026-08-05T00:00:00.000Z', + kind: 'narrative', + repo: { branch: state.branch, root: state.root }, + source: state.source, + support: [], + title, + version: 4, +}); + +const deferred = () => { + let resolve!: (value: Value) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +}; + +const getSurfaceProps = () => { + const props = surfaceProps.mock.lastCall?.[0] as ReviewSurfaceProps | undefined; + expect(props).toBeDefined(); + return props!; +}; + +test('routes every resolved Electron review source through the shared surface', async () => { + const sources = [ + { type: 'working-tree' }, + { baseSha: gitSha('a'), headSha: gitSha('b'), ref: 'feature', type: 'branch-diff' }, + { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-working-tree', + }, + { base: 'main', head: 'feature', symmetric: true, type: 'range' }, + { sha: gitSha('c'), type: 'commit' }, + { + headSha: gitSha('d'), + number: 12, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + }, + { + headSha: gitSha('e'), + number: 23, + projectPath: 'example/repo', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/example/repo/-/merge_requests/23', + }, + { + headSha: gitSha('f'), + number: 24, + type: 'pull-request', + url: 'https://reviews.example.com/example/repo/24', + }, + ] satisfies ReadonlyArray; + + for (const source of sources) { + surfaceProps.mockClear(); + installWindowApi(); + const view = await renderHost(stateFor(source)); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const props = getSurfaceProps(); + expect(props.snapshot.repository.source).toEqual(source); + expect(props.capabilities?.desktop).toBeDefined(); + expect(props.capabilities?.history?.currentSource).toEqual(source); + expect(props.capabilities?.content?.onLoadSection).toEqual(expect.any(Function)); + const hasProviderDestination = + source.type === 'pull-request' && + (source.provider === 'github' || source.provider === 'gitlab'); + expect(props.capabilities?.comments?.destination === 'provider').toBe(hasProviderDestination); + expect( + props.capabilities?.comments?.destination === 'provider' && + props.capabilities.comments.reviewSession != null, + ).toBe(hasProviderDestination); + expect(props.capabilities?.localReviewNotes != null).toBe(source.type !== 'pull-request'); + if (source.type === 'pull-request' && !hasProviderDestination) { + expect(props.capabilities?.comments).toBeUndefined(); + expect(props.capabilities?.localReviewNotes).toBeUndefined(); + } + } finally { + await view.cleanup(); + } + } +}); + +test('wires desktop commands, persistence, preferences, loading, and exact provider operations', async () => { + surfaceProps.mockClear(); + writeReloadSelection.mockClear(); + const { api, copyPendingComments, findInDiffs } = installWindowApi(); + const config = createDefaultConfig(); + config.settings.reviewCommentsPrefix = 'Team review notes'; + config.settings.showOutdated = false; + config.settings.wordWrap = true; + const source = { + headSha: gitSha('f'), + number: 12, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + } as const; + const view = await renderHost(stateFor(source), config); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const props = getSurfaceProps(); + const capabilities = props.capabilities!; + const commandIds = capabilities.desktop?.commands?.map((command) => command.id) ?? []; + expect(commandIds).toEqual( + expect.arrayContaining([ + 'copy-comments', + 'decrease-code-font-size', + 'increase-code-font-size', + 'open-config-file', + 'open-file', + 'reload', + 'reset-code-font-size', + 'toggle-diff-layout', + 'toggle-outdated-comments', + 'toggle-viewed', + ]), + ); + expect( + capabilities.desktop?.commands?.find((command) => command.id === 'copy-comments')?.title, + ).toBe('Copy Pending Review Comments'); + expect( + capabilities.desktop?.commands?.find((command) => command.id === 'copy-comments-and-close') + ?.title, + ).toBe('Copy Pending Review Comments and Close'); + const imageRequest = { kind: 'pull-request' as const, path: 'image.png', source }; + await capabilities.content?.onLoadImageContent?.(imageRequest); + expect(api.getDiffImageContent).toHaveBeenCalledWith({ + ...imageRequest, + requestId: 'image:1', + }); + const comments = capabilities.comments; + expect(comments?.destination).toBe('provider'); + if (!comments || comments.destination !== 'provider') { + throw new Error('Expected provider comment capabilities.'); + } + expect(comments.authoring.onAsk).toEqual(expect.any(Function)); + expect(capabilities.desktop?.onOpenFile).toEqual(expect.any(Function)); + expect(capabilities.history).toBeDefined(); + expect(capabilities.walkthrough?.onShare).toEqual(expect.any(Function)); + + const copyPacket = vi.fn(() => '# Pending provider packet'); + const bridge = { + copyPendingComments: copyPacket, + getPersistenceState: () => ({ mode: 'tree' as const, selectedPath: 'src/provider.ts' }), + openDiffSearch: vi.fn(), + }; + await act(async () => props.onCommandBridgeChange?.(bridge)); + findInDiffs()?.(); + expect(bridge.openDiffSearch).toHaveBeenCalledTimes(1); + expect(copyPendingComments()?.()).toBe('# Pending provider packet'); + expect(copyPacket).toHaveBeenCalledTimes(1); + window.dispatchEvent(new Event('beforeunload')); + expect(writeReloadSelection).toHaveBeenCalledWith( + expect.objectContaining({ source }), + 'src/provider.ts', + source, + 'review', + ); + + await capabilities.preferences?.wordWrap?.onChange(false); + await capabilities.preferences?.outdatedVisibility?.onChange(true); + expect(api.setWordWrap).toHaveBeenCalledWith(false); + expect(api.setShowOutdated).toHaveBeenCalledWith(true); + expect(capabilities.preferences?.wordWrap?.value).toBe(true); + expect(capabilities.preferences?.outdatedVisibility?.value).toBe(false); + expect(capabilities.preferences?.pendingCommentPrefix?.value).toBe('Team review notes'); + + expect(comments).toMatchObject({ + authoring: { canCreateInline: true, onAsk: expect.any(Function) }, + destination: 'provider', + inline: { onSubmit: expect.any(Function) }, + reviewSession: { + drafts: { onChange: expect.any(Function), value: expect.any(Array) }, + submit: expect.any(Function), + }, + }); + expect(comments.general).toBeUndefined(); + + const comment = { + body: 'Submit this comment.', + filePath: 'src/provider.ts', + lineNumber: 1, + side: 'additions' as const, + }; + await comments.inline.onSubmit?.(comment); + expect(api.submitPullRequestComment).toHaveBeenCalledWith({ comment, source }); + await comments.reviewSession?.submit({ + comments: [comment], + outcome: 'request-changes', + summary: 'Please address the inline feedback.', + }); + expect(api.submitPullRequestReview).toHaveBeenCalledWith({ + body: 'Please address the inline feedback.', + comments: [comment], + event: 'REQUEST_CHANGES', + source, + }); + } finally { + await view.cleanup(); + } +}); + +test('uses source-aware copy labels and default Markdown headings', async () => { + for (const source of [ + { type: 'working-tree' } as const, + { + headSha: gitSha('f'), + number: 12, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + } as const, + ]) { + surfaceProps.mockClear(); + installWindowApi(); + const view = await renderHost(stateFor(source)); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const capabilities = getSurfaceProps().capabilities!; + const provider = source.type === 'pull-request'; + expect( + capabilities.desktop?.commands?.find((command) => command.id === 'copy-comments')?.title, + ).toBe(provider ? 'Copy Pending Review Comments' : 'Copy Review Notes'); + expect(capabilities.preferences?.pendingCommentPrefix?.value).toBe( + provider ? '# Address these Pending Review Comments' : '# Address these Review Notes', + ); + } finally { + await view.cleanup(); + } + } +}); + +test('asks the review assistant with the flushed note value supplied by the surface', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const file = createChangedFile('src/immediate-ask.ts'); + const state = stateFor({ type: 'working-tree' }, [file]); + const view = await renderHost(state); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + getSurfaceProps().capabilities?.localReviewNotes?.onAsk?.({ + body: 'Check the just-flushed draft.', + filePath: file.path, + id: 'immediate-note', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + }); + }); + expect(api.askReviewAssistant).toHaveBeenCalledWith({ + comment: { + body: 'Check the just-flushed draft.', + filePath: file.path, + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + }, + source: state.source, + }); + } finally { + await view.cleanup(); + } +}); + +test('loads deferred section content for supported Electron sources', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const file = createChangedFile('src/local.ts'); + const deferredFile = { + ...file, + sections: file.sections.map((section) => ({ + ...section, + loadState: 'deferred' as const, + summary: { canLoad: true, reason: 'Load local contents.' }, + })), + }; + const view = await renderHost(stateFor({ type: 'working-tree' }, [deferredFile])); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await waitFor(() => expect(api.getDiffSectionContent).toHaveBeenCalledTimes(1)); + expect(api.getDiffSectionContent).toHaveBeenCalledWith({ + force: true, + kind: 'unstaged', + path: 'src/local.ts', + requestId: 'section:1', + showWhitespace: false, + source: { type: 'working-tree' }, + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.content?.itemVersionByKey).toEqual({ + 'src/local.ts': 1, + }), + ); + } finally { + await view.cleanup(); + } +}); + +test('bumps the mounted review key when deferred loading fails', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + api.getDiffSectionContent.mockRejectedValueOnce(new Error('content unavailable')); + const file = createChangedFile('src/failure.ts'); + const deferredFile = { + ...file, + sections: file.sections.map((section) => ({ + ...section, + loadState: 'deferred' as const, + summary: { canLoad: true, reason: 'Load local contents.' }, + })), + }; + const view = await renderHost(stateFor({ type: 'working-tree' }, [deferredFile])); + + try { + await waitFor(() => expect(api.getDiffSectionContent).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(getSurfaceProps().capabilities?.content?.itemVersionByKey).toEqual({ + 'src/failure.ts': 1, + }), + ); + } finally { + await view.cleanup(); + } +}); + +test('shows the repository-change banner without reading repository state', async () => { + surfaceProps.mockClear(); + const { api, repositoryChanged } = installWindowApi(); + const view = await renderHost( + stateFor({ type: 'working-tree' }, [createChangedFile('src/notification.ts')]), + ); + api.getRepositoryState.mockClear(); + + try { + await waitFor(() => expect(repositoryChanged()).toEqual(expect.any(Function))); + await act(async () => repositoryChanged()?.()); + await waitFor(() => + expect(view.container.querySelector('.repository-change-banner.visible')).not.toBeNull(), + ); + expect(api.getRepositoryState).not.toHaveBeenCalled(); + } finally { + await view.cleanup(); + } +}); + +test('forwards initial and refresh-calculated reload deltas', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const initialFile = createChangedFile('src/reload.ts', { + fingerprint: 'before', + patch: 'before', + }); + const refreshedFile = createChangedFile('src/reload.ts', { + fingerprint: 'after', + patch: 'after', + }); + api.getRepositoryState.mockResolvedValueOnce(stateFor({ type: 'working-tree' }, [refreshedFile])); + const view = await renderHost(stateFor({ type: 'working-tree' }, [initialFile]), undefined, { + bootstrap: { reloadDeltaPaths: new Set(['src/initial.ts']) }, + }); + + try { + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.reloadDeltaPaths).toEqual( + new Set(['src/initial.ts']), + ), + ); + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.reloadDeltaPaths).toEqual( + new Set(['src/reload.ts']), + ), + ); + } finally { + await view.cleanup(); + } +}); + +test('refreshes repository and History state atomically', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/before.ts')]); + const refreshedState = stateFor({ type: 'working-tree' }, [createChangedFile('src/after.ts')]); + const historyEntry = { + author: 'Ada Lovelace', + committedAt: Date.now(), + parentShas: [], + sha: gitSha('d'), + subject: 'Refresh atomically', + }; + api.getRepositoryState.mockResolvedValueOnce(refreshedState); + api.getRepositoryHistory.mockResolvedValueOnce({ entries: [historyEntry], root: '/repo' }); + const view = await renderHost(initialState); + + try { + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => refreshRequest()?.()); + await waitFor(() => { + const props = getSurfaceProps(); + expect(props.snapshot.files.map((file) => file.path)).toEqual(['src/after.ts']); + expect(props.capabilities?.history?.entries).toEqual([historyEntry]); + }); + expect(view.container.textContent).toContain('Review updated.'); + } finally { + await view.cleanup(); + } +}); + +test('an unchanged refresh preserves hydrated files and in-flight content and walkthrough work', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const baseFile = createChangedFile('src/stable.ts', { fingerprint: 'initial-provider-state' }); + const file = { + ...baseFile, + sections: baseFile.sections.map((section) => ({ + ...section, + loadState: 'deferred' as const, + summary: { canLoad: true, reason: 'Load exact contents.' }, + })), + }; + const refreshedFile = { + ...file, + fingerprint: 'new-provider-state', + sections: file.sections.map((section) => ({ + ...section, + id: 'provider-reordered-section', + summary: { canLoad: true, reason: 'Different hydration metadata.' }, + })), + }; + const content = deferred<{ + binary: boolean; + id: string; + kind: string; + loadState: string; + patch: string; + }>(); + const walkthrough = deferred(); + api.getRepositoryState.mockResolvedValueOnce(stateFor({ type: 'working-tree' }, [refreshedFile])); + api.getDiffSectionContent.mockImplementationOnce(() => content.promise); + api.getNarrativeWalkthrough.mockImplementationOnce(() => walkthrough.promise); + const view = await renderHost(stateFor({ type: 'working-tree' }, [file])); + + try { + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => { + void getSurfaceProps().capabilities?.content?.onLoadSection?.(file, file.sections[0]); + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + await Promise.resolve(); + }); + expect(api.getDiffSectionContent).toHaveBeenCalledOnce(); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledOnce(); + + await act(async () => refreshRequest()?.()); + await waitFor(() => expect(view.container.textContent).toContain('Review is up to date.')); + expect(getSurfaceProps().snapshot.files[0].fingerprint).toBe('initial-provider-state'); + expect(api.cancelDiffContentRequest).not.toHaveBeenCalled(); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledOnce(); + + await act(async () => { + content.resolve({ + binary: false, + id: file.sections[0].id, + kind: file.sections[0].kind, + loadState: 'ready', + patch: file.sections[0].patch, + }); + await content.promise; + walkthrough.resolve({ + status: 'ready', + walkthrough: walkthroughFor(stateFor({ type: 'working-tree' }, [file]), 'Preserved work'), + }); + await walkthrough.promise; + }); + await waitFor(() => + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Preserved work'), + ); + expect(getSurfaceProps().capabilities?.content?.itemVersionByKey).toEqual({ + 'src/stable.ts': 1, + }); + } finally { + await view.cleanup(); + } +}); + +test('shows refresh progress and keeps failures retryable', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/retry-refresh.ts')]); + const failedRefresh = deferred(); + api.getRepositoryState + .mockImplementationOnce(() => failedRefresh.promise) + .mockResolvedValueOnce(state); + const view = await renderHost(state); + + try { + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => refreshRequest()?.()); + expect(view.container.textContent).toContain('Refreshing reviewโ€ฆ'); + + await act(async () => { + failedRefresh.reject(new Error('Repository unavailable.')); + await failedRefresh.promise.catch(() => {}); + }); + await waitFor(() => expect(view.container.textContent).toContain('Refresh failed.')); + expect(view.container.textContent).toContain('Repository unavailable.'); + + const retry = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Retry', + ); + expect(retry).toBeDefined(); + await act(async () => retry?.click()); + await waitFor(() => expect(view.container.textContent).toContain('Review is up to date.')); + expect(api.getRepositoryState).toHaveBeenCalledTimes(2); + } finally { + await view.cleanup(); + } +}); + +test('expands changed viewed files while leaving unchanged viewed files collapsed', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const changed = createChangedFile('src/changed-viewed.ts', { fingerprint: 'before' }); + const unchanged = createChangedFile('src/unchanged-viewed.ts', { fingerprint: 'stable' }); + const initialState = stateFor({ type: 'working-tree' }, [changed, unchanged]); + const refreshedState = stateFor({ type: 'working-tree' }, [ + createChangedFile(changed.path, { fingerprint: 'after', patch: 'after' }), + unchanged, + ]); + api.getRepositoryState.mockResolvedValueOnce(refreshedState); + const view = await renderHost(initialState); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + getSurfaceProps().capabilities?.desktop?.onCollapsedChange?.( + new Set([changed.path, unchanged.path]), + ); + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.collapsed).toEqual( + new Set([changed.path, unchanged.path]), + ), + ); + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.collapsed).toEqual(new Set([unchanged.path])), + ); + } finally { + await view.cleanup(); + } +}); + +test('changed reviews keep the old walkthrough until an explicit replacement finishes', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/initial.ts')]); + const firstState = stateFor({ type: 'working-tree' }, [createChangedFile('src/first.ts')]); + const secondState = stateFor({ type: 'working-tree' }, [createChangedFile('src/second.ts')]); + const firstWalkthrough = deferred(); + const secondWalkthrough = deferred(); + api.getRepositoryState.mockResolvedValueOnce(firstState).mockResolvedValueOnce(secondState); + api.getNarrativeWalkthrough + .mockImplementationOnce(() => firstWalkthrough.promise) + .mockImplementationOnce(() => secondWalkthrough.promise); + const view = await renderHost(initialState, undefined, { + initialWalkthroughResult: { + status: 'ready', + walkthrough: walkthroughFor(initialState, 'Initial walkthrough'), + }, + }); + + try { + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/first.ts']), + ); + expect(api.getNarrativeWalkthrough).not.toHaveBeenCalled(); + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Initial walkthrough'); + expect(view.container.querySelector('.repository-refresh-banner.stale')).not.toBeNull(); + + const restart = () => + Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Restart generation', + ); + await act(async () => restart()?.click()); + await waitFor(() => expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(1)); + + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/second.ts']), + ); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(1); + expect(view.container.querySelector('.repository-refresh-banner.stale')).not.toBeNull(); + + await act(async () => { + firstWalkthrough.resolve({ + status: 'ready', + walkthrough: walkthroughFor(firstState, 'Old-code walkthrough'), + }); + await firstWalkthrough.promise; + }); + await waitFor(() => expect(getSurfaceProps().capabilities?.walkthrough?.status).toBe('ready')); + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Initial walkthrough'); + expect(view.container.querySelector('.repository-refresh-banner.stale')).not.toBeNull(); + + await act(async () => restart()?.click()); + await waitFor(() => expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(2)); + await act(async () => { + secondWalkthrough.resolve({ + status: 'ready', + walkthrough: walkthroughFor(secondState, 'Current walkthrough'), + }); + await secondWalkthrough.promise; + }); + await waitFor(() => + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Current walkthrough'), + ); + expect(view.container.querySelector('.repository-refresh-banner.stale')).toBeNull(); + } finally { + await view.cleanup(); + } +}); + +test('refresh exits commit mode for an empty tree and allows a new walkthrough after editing', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/committed.ts')]); + const emptyState = stateFor({ type: 'working-tree' }); + const editedState = stateFor({ type: 'working-tree' }, [createChangedFile('src/edited.ts')]); + api.getRepositoryState.mockResolvedValueOnce(emptyState).mockResolvedValueOnce(editedState); + const view = await renderHost(initialState, undefined, { + bootstrap: { mainMode: 'commit' }, + initialWalkthroughResult: { + status: 'ready', + walkthrough: walkthroughFor(initialState, 'Committed walkthrough'), + }, + }); + + try { + await waitFor(() => expect(getSurfaceProps().capabilities?.desktop?.commit?.open).toBe(true)); + await act(async () => refreshRequest()?.()); + await waitFor(() => expect(getSurfaceProps().capabilities?.desktop?.commit).toBeUndefined()); + + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/edited.ts']), + ); + api.getNarrativeWalkthrough.mockClear(); + await act(async () => getSurfaceProps().activeMode?.onChange('walkthrough')); + expect(api.getNarrativeWalkthrough).not.toHaveBeenCalled(); + const restart = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Restart generation', + ); + expect(restart).toBeDefined(); + await act(async () => restart?.click()); + await waitFor(() => expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(1)); + } finally { + await view.cleanup(); + } +}); + +test('uses the bootstrap mode and one-shot scroll target as controlled surface state', async () => { + surfaceProps.mockClear(); + installWindowApi(); + const files = [createChangedFile('src/first.ts'), createChangedFile('src/restored.ts')]; + const view = await renderHost(stateFor({ type: 'working-tree' }, files), undefined, { + bootstrap: { + initialScrollTarget: { + behavior: 'instant', + path: 'src/restored.ts', + request: 1, + }, + selectedPath: 'src/restored.ts', + sidebarMode: 'history', + }, + }); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const props = getSurfaceProps(); + expect(props.activeMode?.value).toBe('history'); + expect(props).not.toHaveProperty('initialMode'); + expect(props.capabilities?.content?.initialScrollTarget).toEqual({ + behavior: 'instant', + path: 'src/restored.ts', + request: 1, + }); + expect(props.capabilities?.preferences?.selectedPath?.value).toBe('src/restored.ts'); + } finally { + await view.cleanup(); + } +}); + +test('keeps desktop file state, fullscreen state, and active walkthrough targets controlled', async () => { + surfaceProps.mockClear(); + const { api, windowFullScreenChanged } = installWindowApi(); + const firstFile = createChangedFile('src/first.ts'); + const activeFile = createChangedFile('src/active.ts'); + const state = stateFor({ type: 'working-tree' }, [firstFile, activeFile]); + const view = await renderHost(state, undefined, { + bootstrap: { sidebarMode: 'walkthrough' }, + initialWalkthroughResult: { status: 'ready', walkthrough: walkthroughFor(state, 'Ready') }, + }); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().capabilities?.desktop).toMatchObject({ + collapsed: new Set(), + isWindowFullscreen: false, + viewed: {}, + }); + + await act(async () => { + getSurfaceProps().capabilities?.desktop?.onCollapsedChange?.(new Set(['src/active.ts'])); + getSurfaceProps().capabilities?.desktop?.onViewedChange?.({ + 'src/active.ts': activeFile.fingerprint, + }); + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop).toMatchObject({ + collapsed: new Set(['src/active.ts']), + viewed: { 'src/active.ts': activeFile.fingerprint }, + }), + ); + + await waitFor(() => expect(windowFullScreenChanged()).toEqual(expect.any(Function))); + await act(async () => windowFullScreenChanged()?.(true)); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.isWindowFullscreen).toBe(true), + ); + + await act(async () => { + getSurfaceProps().capabilities?.desktop?.onActiveWalkthroughReviewTargetChange?.({ + file: activeFile, + reviewIdentity: { fingerprint: activeFile.fingerprint, key: 'walkthrough:active' }, + }); + getSurfaceProps().capabilities?.desktop?.onOpenSelectedFile?.(); + }); + expect(api.openFile).toHaveBeenCalledWith('src/active.ts'); + } finally { + await view.cleanup(); + } +}); + +test('ignores a walkthrough result after History switches sources', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const sourceAState = stateFor({ type: 'working-tree' }, [createChangedFile('src/a.ts')]); + const sourceB = { sha: gitSha('b'), type: 'commit' } as const; + const sourceBRequest = { ref: gitSha('b'), type: 'commit' } as const; + const sourceBState = stateFor(sourceB, [createChangedFile('src/b.ts')]); + const pending = deferred(); + api.getNarrativeWalkthrough.mockImplementationOnce(() => pending.promise); + api.getRepositoryState.mockResolvedValueOnce(sourceBState); + const view = await renderHost(sourceAState); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + await Promise.resolve(); + }); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledWith({ type: 'working-tree' }, undefined); + + await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(sourceBRequest)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(sourceB)); + + await act(async () => { + pending.resolve({ + status: 'ready', + walkthrough: walkthroughFor(sourceAState, 'Stale source A walkthrough'), + }); + await pending.promise; + }); + expect(getSurfaceProps().snapshot.repository.source).toEqual(sourceB); + expect(getSurfaceProps().snapshot.walkthrough.title).not.toBe('Stale source A walkthrough'); + expect(getSurfaceProps().capabilities?.walkthrough).toMatchObject({ + status: 'idle', + unread: false, + }); + } finally { + await view.cleanup(); + } +}); + +test('keeps provider and local drafts in their own History source sessions', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const pullRequestSource = { + headSha: gitSha('f'), + number: 12, + owner: 'example', + provider: 'github', + repo: 'repo', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + } as const; + const commitSha = gitSha('a'); + const commitRequest = { ref: commitSha, type: 'commit' } as const; + const commitSource = { sha: commitSha, type: 'commit' } as const; + const pullRequestState = stateFor(pullRequestSource, [createChangedFile('src/provider.ts')]); + const commitState = stateFor(commitSource, [createChangedFile('src/commit.ts')]); + api.getRepositoryState + .mockResolvedValueOnce(commitState) + .mockResolvedValueOnce(pullRequestState) + .mockResolvedValueOnce(commitState); + const view = await renderHost(pullRequestState); + const providerDraft = { + body: 'Keep this provider draft.', + filePath: 'src/provider.ts', + id: 'provider-draft', + lineNumber: 1, + sectionId: 'src/provider.ts:unstaged', + side: 'additions' as const, + }; + const localNote = { + body: 'Keep this local commit note.', + filePath: 'src/commit.ts', + id: 'local-note', + lineNumber: 1, + sectionId: 'src/commit.ts:unstaged', + side: 'additions' as const, + }; + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().capabilities?.desktop).not.toHaveProperty('onRetargetToWorkingTree'); + const initialComments = getSurfaceProps().capabilities?.comments; + expect(initialComments?.destination).toBe('provider'); + if (!initialComments || initialComments.destination !== 'provider') { + throw new Error('Expected provider comment capabilities.'); + } + await act(async () => initialComments.reviewSession?.drafts.onChange([providerDraft])); + await waitFor(() => { + const comments = getSurfaceProps().capabilities?.comments; + expect(comments?.destination).toBe('provider'); + if (comments?.destination === 'provider') { + expect(comments.reviewSession?.drafts.value).toContainEqual(providerDraft); + } + }); + + await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(commitRequest)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(commitSource)); + expect(getSurfaceProps().capabilities?.localReviewNotes).toBeDefined(); + await act(async () => + getSurfaceProps().capabilities?.localReviewNotes?.drafts?.onChange([localNote]), + ); + await waitFor(() => + expect(getSurfaceProps().capabilities?.localReviewNotes?.drafts?.value).toContainEqual( + localNote, + ), + ); + + await act(async () => + getSurfaceProps().capabilities?.history?.onSelectSource(pullRequestSource), + ); + await waitFor(() => + expect(getSurfaceProps().snapshot.repository.source).toEqual(pullRequestSource), + ); + const restoredProviderComments = getSurfaceProps().capabilities?.comments; + expect(restoredProviderComments?.destination).toBe('provider'); + if (restoredProviderComments?.destination === 'provider') { + expect(restoredProviderComments.reviewSession?.drafts.value).toContainEqual(providerDraft); + } + + await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(commitRequest)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(commitSource)); + expect(getSurfaceProps().capabilities?.localReviewNotes?.drafts?.value).toContainEqual( + localNote, + ); + } finally { + await view.cleanup(); + } +}); + +test('keeps a newer walkthrough request active when an older request fails', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/retry.ts')]); + const older = deferred(); + const newer = deferred(); + api.getNarrativeWalkthrough + .mockImplementationOnce(() => older.promise) + .mockImplementationOnce(() => newer.promise); + const view = await renderHost(state); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + await Promise.resolve(); + }); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(2); + + await act(async () => { + older.reject(new Error('stale failure')); + await older.promise.catch(() => {}); + }); + expect(getSurfaceProps().capabilities?.walkthrough).toMatchObject({ + error: null, + status: 'generating', + }); + + await act(async () => { + newer.resolve({ status: 'ready', walkthrough: walkthroughFor(state, 'Newer result') }); + await newer.promise; + }); + await waitFor(() => expect(getSurfaceProps().capabilities?.walkthrough?.status).toBe('ready')); + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Newer result'); + } finally { + await view.cleanup(); + } +}); + +test('forwards complete agent-unavailable walkthrough metadata to the surface', async () => { + for (const code of [ + 'CODEX_NOT_FOUND', + 'CLAUDE_NOT_FOUND', + 'OPENCODE_NOT_FOUND', + 'PI_NOT_FOUND', + ] as const) { + surfaceProps.mockClear(); + installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile(`src/${code}.ts`)]); + const view = await renderHost(state, undefined, { + bootstrap: { sidebarMode: 'walkthrough' }, + initialWalkthroughResult: { + code, + reason: `${code} unavailable.`, + status: 'unavailable', + }, + }); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().capabilities?.walkthrough?.error).toMatchObject({ + code, + reason: `${code} unavailable.`, + }); + } finally { + await view.cleanup(); + } + } +}); + +test('marks background walkthrough completion unread until Walkthrough is activated', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/background.ts')]); + const pending = deferred(); + api.getNarrativeWalkthrough.mockImplementationOnce(() => pending.promise); + const view = await renderHost(state); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + getSurfaceProps().activeMode?.onChange('walkthrough'); + await Promise.resolve(); + }); + await waitFor(() => expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(1)); + await act(async () => getSurfaceProps().activeMode?.onChange('tree')); + await act(async () => { + pending.resolve({ status: 'ready', walkthrough: walkthroughFor(state, 'Background ready') }); + await pending.promise; + }); + await waitFor(() => expect(getSurfaceProps().capabilities?.walkthrough?.unread).toBe(true)); + + await act(async () => getSurfaceProps().activeMode?.onChange('walkthrough')); + await waitFor(() => expect(getSurfaceProps().capabilities?.walkthrough?.unread).toBe(false)); + } finally { + await view.cleanup(); + } +}); + +test('does not mark walkthrough completion unread while Walkthrough remains active', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/foreground.ts')]); + const pending = deferred(); + api.getNarrativeWalkthrough.mockImplementationOnce(() => pending.promise); + const view = await renderHost(state); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + getSurfaceProps().activeMode?.onChange('walkthrough'); + await Promise.resolve(); + }); + await act(async () => { + pending.resolve({ status: 'ready', walkthrough: walkthroughFor(state, 'Foreground ready') }); + await pending.promise; + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.walkthrough).toMatchObject({ + status: 'ready', + unread: false, + }), + ); + } finally { + await view.cleanup(); + } +}); + +test('uses the configured agent for placeholder walkthroughs and honors launch overrides', async () => { + const state = stateFor({ type: 'working-tree' }); + for (const agentBackend of ['codex', 'claude', 'opencode', 'pi'] as const) { + surfaceProps.mockClear(); + installWindowApi(); + const config = createDefaultConfig(); + config.settings.agentBackend = agentBackend; + const view = await renderHost(state, config); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().snapshot.walkthrough.agent).toBe(agentBackend); + } finally { + await view.cleanup(); + } + } + + surfaceProps.mockClear(); + installWindowApi(); + const config = createDefaultConfig(); + config.settings.agentBackend = 'codex'; + const view = await renderHost(state, config, { + launchOptions: { agentBackend: 'claude', repositoryPathProvided: true, walkthrough: false }, + }); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().snapshot.walkthrough.agent).toBe('claude'); + + const nextConfig = createDefaultConfig(); + nextConfig.settings.agentBackend = 'pi'; + await view.rerenderConfig(nextConfig); + expect(getSurfaceProps().snapshot.walkthrough.agent).toBe('claude'); + } finally { + await view.cleanup(); + } + + surfaceProps.mockClear(); + installWindowApi(); + const configuredView = await renderHost(state, config); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const nextConfig = createDefaultConfig(); + nextConfig.settings.agentBackend = 'opencode'; + await configuredView.rerenderConfig(nextConfig); + await waitFor(() => expect(getSurfaceProps().snapshot.walkthrough.agent).toBe('opencode')); + } finally { + await configuredView.cleanup(); + } +}); + +test('applies non-whitespace config updates without reloading repository state', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/config.ts')]); + const config = createDefaultConfig(); + const view = await renderHost(state, config); + api.getRepositoryState.mockClear(); + + try { + const nextConfig = createDefaultConfig(); + nextConfig.settings.wordWrap = true; + await view.rerenderConfig(nextConfig); + await waitFor(() => + expect(getSurfaceProps().capabilities?.preferences?.wordWrap?.value).toBe(true), + ); + expect(api.getRepositoryState).not.toHaveBeenCalled(); + } finally { + await view.cleanup(); + } +}); + +test('reloads the exact active source once when showWhitespace changes', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const source = { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-working-tree', + } as const; + const state = stateFor(source, [createChangedFile('src/whitespace.ts')]); + api.getRepositoryState.mockResolvedValueOnce(state); + const config = createDefaultConfig(); + const view = await renderHost(state, config); + + try { + const nextConfig = createDefaultConfig(); + nextConfig.settings.showWhitespace = true; + await view.rerenderConfig(nextConfig); + await waitFor(() => expect(api.getRepositoryState).toHaveBeenCalledTimes(1)); + expect(api.getRepositoryState).toHaveBeenCalledWith({ + ref: source.ref, + type: 'branch-working-tree', + }); + } finally { + await view.cleanup(); + } +}); + +test('discards stale repository results from rapid showWhitespace changes', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/initial.ts')]); + const staleState = stateFor({ type: 'working-tree' }, [createChangedFile('src/stale.ts')]); + const currentState = stateFor({ type: 'working-tree' }, [createChangedFile('src/current.ts')]); + const stale = deferred(); + const current = deferred(); + api.getRepositoryState + .mockImplementationOnce(() => stale.promise) + .mockImplementationOnce(() => current.promise); + const view = await renderHost(initialState); + + try { + const showWhitespace = createDefaultConfig(); + showWhitespace.settings.showWhitespace = true; + await view.rerenderConfig(showWhitespace); + const hideWhitespace = createDefaultConfig(); + hideWhitespace.settings.showWhitespace = false; + await view.rerenderConfig(hideWhitespace); + await waitFor(() => expect(api.getRepositoryState).toHaveBeenCalledTimes(2)); + + await act(async () => { + current.resolve(currentState); + await current.promise; + }); + await waitFor(() => + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/current.ts']), + ); + await act(async () => { + stale.resolve(staleState); + await stale.promise; + }); + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/current.ts']); + } finally { + await view.cleanup(); + } +}); + +test('keeps failed whitespace reloads recoverable without applying stale state', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/kept.ts')]); + api.getRepositoryState.mockRejectedValueOnce(new Error('Reload failed.')); + const view = await renderHost(initialState); + + try { + const showWhitespace = createDefaultConfig(); + showWhitespace.settings.showWhitespace = true; + await view.rerenderConfig(showWhitespace); + await waitFor(() => expect(view.container.textContent).toContain('Reload failed.')); + + api.getRepositoryState.mockResolvedValueOnce(initialState); + const hideWhitespace = createDefaultConfig(); + hideWhitespace.settings.showWhitespace = false; + await view.rerenderConfig(hideWhitespace); + await waitFor(() => expect(view.container.textContent).not.toContain('Reload failed.')); + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/kept.ts']); + } finally { + await view.cleanup(); + } +}); diff --git a/core/__tests__/RepositoryReviewHost.test.tsx b/core/__tests__/RepositoryReviewHost.test.tsx new file mode 100644 index 00000000..5ef21575 --- /dev/null +++ b/core/__tests__/RepositoryReviewHost.test.tsx @@ -0,0 +1,488 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { expect, test, vi } from 'vite-plus/test'; +import { RepositoryReviewHost } from '../app/RepositoryReviewHost.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; +import { resolveRepositoryReviewBootstrap } from '../lib/repository-review-bootstrap.ts'; +import type { + DiffImageContentRequest, + DiffSectionContentRequest, + GitSha, + NarrativeWalkthrough, + NarrativeWalkthroughResult, + RepositoryState, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const state = { + branch: 'main', + files: [], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' }, +} satisfies RepositoryState; + +const unsubscribe = () => {}; +const bootstrapFor = ( + repositoryState: RepositoryState, + launchOptions = { repositoryPathProvided: true, walkthrough: false } as const, +) => + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: null, + state: repositoryState, + }); + +const installCommitWindowApi = () => { + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + createWalkthroughCommit: vi.fn(async () => ({ status: 'committed' })), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughCommitOutput: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + updateWalkthroughCommitMessage: vi.fn(async () => ({ status: 'unavailable' })), + } as unknown as Window['codiff']; +}; + +test('RepositoryReviewHost renders local reviews through the shared surface', async () => { + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const view = await renderReact( + , + ); + try { + await waitFor(() => expect(view.container.querySelector('.review-surface')).not.toBeNull()); + expect(view.container.querySelector('main.review')).not.toBeNull(); + expect(view.container.querySelector('aside.sidebar')).not.toBeNull(); + } finally { + await view.cleanup(); + } +}); + +test('RepositoryReviewHost renders provider reviews through the shared surface', async () => { + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + const pullRequestState = { + ...state, + files: [createChangedFile('src/review.ts')], + source: { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github', + targetBranch: 'main', + title: 'Review the shared host', + type: 'pull-request', + url: 'https://github.com/example/review/pull/42', + }, + } satisfies RepositoryState; + + const view = await renderReact( + , + ); + try { + await waitFor(() => + expect(view.container.querySelector('.merge-request-shell')).not.toBeNull(), + ); + expect(view.container.textContent).toContain('Review the shared host'); + expect(view.container.textContent).toContain('src/review.ts'); + expect(view.container.querySelector('.sidebar-commit-button')).toBeNull(); + expect(view.container.querySelector('button[aria-label="Back to Codiff"]')).toBeNull(); + + const historyButton = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'History', + ); + await act(async () => historyButton?.click()); + await waitFor(() => expect(view.container.querySelector('.history-list')).not.toBeNull()); + expect( + Array.from(view.container.querySelectorAll('.history-entry-subject')).map( + (entry) => entry.textContent, + ), + ).toEqual(['Review the shared host', 'Review commit']); + expect(view.container.textContent).not.toContain('Uncommitted changes'); + } finally { + await view.cleanup(); + } +}); + +test('working-tree reviews open the standalone commit view with generated seed text', async () => { + installCommitWindowApi(); + const file = createChangedFile('src/commit.ts'); + const workingState = { ...state, files: [file] } satisfies RepositoryState; + const walkthrough = { + agent: 'claude', + chapters: [], + commit: { body: 'Explain why this commit exists.', title: 'Prepare the standalone commit' }, + focus: 'Review the commit.', + generatedAt: '2026-08-05T00:00:00.000Z', + kind: 'narrative', + repo: { branch: workingState.branch, root: workingState.root }, + source: workingState.source, + support: [], + title: 'Commit walkthrough', + version: 4, + } satisfies NarrativeWalkthrough; + const view = await renderReact( + , + ); + + try { + await waitFor(() => + expect(view.container.querySelector('.sidebar-commit-button')).not.toBeNull(), + ); + await act(async () => { + (view.container.querySelector('.sidebar-commit-button') as HTMLButtonElement).click(); + }); + await waitFor(() => expect(view.container.querySelector('.wt-commit')).not.toBeNull()); + expect( + (view.container.querySelector('.wt-commit-subject-field') as HTMLInputElement).value, + ).toBe('Prepare the standalone commit'); + expect( + (view.container.querySelector('.wt-commit-msg-input') as HTMLTextAreaElement).value, + ).toBe('Explain why this commit exists.'); + expect(view.container.querySelector('.sidebar-commit-button')?.textContent).toBe('Tree'); + + await act(async () => { + (view.container.querySelector('.sidebar-commit-button') as HTMLButtonElement).click(); + }); + await waitFor(() => expect(view.container.querySelector('.wt-commit')).toBeNull()); + } finally { + await view.cleanup(); + } +}); + +test('working-tree reviews restore the standalone commit view from bootstrap state', async () => { + installCommitWindowApi(); + const workingState = { + ...state, + files: [createChangedFile('src/restored-commit.ts')], + } satisfies RepositoryState; + const bootstrap = { + ...bootstrapFor(workingState), + mainMode: 'commit' as const, + }; + await using view = await renderReact( + , + ); + + await waitFor(() => expect(view.container.querySelector('.wt-commit')).not.toBeNull()); +}); + +test('standalone commit preparation remains available without a ready walkthrough', async () => { + const unavailable = { + reason: 'Walkthrough generation is unavailable.', + status: 'unavailable', + } satisfies NarrativeWalkthroughResult; + + for (const initialWalkthroughResult of [undefined, unavailable]) { + installCommitWindowApi(); + const workingState = { + ...state, + files: [createChangedFile('src/standalone.ts')], + } satisfies RepositoryState; + const view = await renderReact( + , + ); + try { + await waitFor(() => + expect(view.container.querySelector('.sidebar-commit-button')).not.toBeNull(), + ); + await act(async () => { + (view.container.querySelector('.sidebar-commit-button') as HTMLButtonElement).click(); + }); + await waitFor(() => expect(view.container.querySelector('.wt-commit')).not.toBeNull()); + } finally { + await view.cleanup(); + } + } +}); + +test('RepositoryReviewHost hydrates deferred provider comments', async () => { + const file = createChangedFile('src/review.ts'); + const source = { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github', + targetBranch: 'main', + title: 'Review the shared host', + type: 'pull-request', + url: 'https://github.com/example/review/pull/42', + } as const; + const pullRequestState = { + ...state, + files: [file], + reviewCommentsLoadState: 'not-loaded' as const, + source, + } satisfies RepositoryState; + const getReviewComments = vi.fn(async () => [ + { + author: { login: 'reviewer' }, + body: 'Loaded through the R04 review-comments capability.', + filePath: file.path, + id: 'github:1', + lineNumber: 1, + side: 'additions' as const, + threadId: '1', + }, + ]); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getReviewComments, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + await using view = await renderReact( + , + ); + + await waitFor(() => { + expect(getReviewComments).toHaveBeenCalledWith(source); + expect(view.container.textContent).toContain( + 'Loaded through the R04 review-comments capability.', + ); + }); +}); + +test('RepositoryReviewHost cancels an active lazy section request on unmount', async () => { + const file = { + ...createChangedFile('src/lazy.ts'), + sections: [ + { + binary: false, + id: 'src/lazy.ts:pull-request', + kind: 'pull-request' as const, + loadState: 'deferred' as const, + patch: '', + summary: { canLoad: true, reason: 'Load exact contents.' }, + }, + ], + }; + const pullRequestState = { + ...state, + files: [file], + reviewCommentsLoadState: 'loaded' as const, + source: { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github' as const, + targetBranch: 'main', + title: 'Lazy content', + type: 'pull-request' as const, + url: 'https://github.com/example/review/pull/42', + }, + } satisfies RepositoryState; + const getDiffSectionContent = vi.fn( + (_request: DiffSectionContentRequest) => new Promise(() => {}), + ); + const cancelDiffContentRequest = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest, + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getDiffSectionContent, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const view = await renderReact( + , + ); + await waitFor(() => expect(getDiffSectionContent).toHaveBeenCalledOnce()); + const requestId = getDiffSectionContent.mock.calls[0]![0].requestId; + expect(requestId).toMatch(/^section:/); + await view.cleanup(); + expect(cancelDiffContentRequest).toHaveBeenCalledWith(requestId); +}); + +test('RepositoryReviewHost cancels an active image request on unmount', async () => { + const file = { + ...createChangedFile('image.png'), + sections: [ + { + binary: true, + id: 'image.png:pull-request', + kind: 'pull-request' as const, + loadState: 'binary' as const, + patch: 'Binary files a/image.png and b/image.png differ', + }, + ], + }; + const pullRequestState = { + ...state, + files: [file], + reviewCommentsLoadState: 'loaded' as const, + source: { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github' as const, + targetBranch: 'main', + title: 'Image content', + type: 'pull-request' as const, + url: 'https://github.com/example/review/pull/42', + }, + } satisfies RepositoryState; + const getDiffImageContent = vi.fn((_request: DiffImageContentRequest) => new Promise(() => {})); + const cancelDiffContentRequest = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest, + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getDiffImageContent, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const view = await renderReact( + , + ); + await waitFor(() => expect(getDiffImageContent).toHaveBeenCalledOnce()); + const requestId = getDiffImageContent.mock.calls[0]![0].requestId; + expect(requestId).toMatch(/^image:/); + await view.cleanup(); + expect(cancelDiffContentRequest).toHaveBeenCalledWith(requestId); +}); diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index 1173a818..0a943494 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -298,8 +298,8 @@ test('combined branch Markdown edits only the final working-tree section', async const initialFile = createCombinedFile('# Edited\n', 'plan.md:combined-initial'); const refreshedFile = createCombinedFile('# Saved\n', 'plan.md:combined-refreshed'); const combinedSource = { - baseRef: 'base123', - headRef: 'head123', + baseSha: gitSha('base123'), + headSha: gitSha('head123'), ref: 'main', type: 'branch-working-tree', } satisfies ReviewSource; @@ -384,8 +384,8 @@ test('combined branch-only Markdown sections remain read-only', async () => { , ); await using _platform = { @@ -2545,7 +2545,7 @@ test('definition result is invalidated when the review source changes', async () files={[createChangedFile('src/next.ts', { kind: 'commit' })]} onFindDefinitions={onFindDefinitions} onOpenDefinition={() => {}} - source={{ ref: 'abcdef0', type: 'commit' }} + source={{ sha: gitSha('abcdef0'), type: 'commit' }} />, ); await act(async () => { diff --git a/core/__tests__/ReviewSurface-capabilities.test.tsx b/core/__tests__/ReviewSurface-capabilities.test.tsx index 5ee56e93..a29a287b 100644 --- a/core/__tests__/ReviewSurface-capabilities.test.tsx +++ b/core/__tests__/ReviewSurface-capabilities.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ -import { act, useState, type Dispatch, type SetStateAction } from 'react'; +import { act, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; import { createRoot } from 'react-dom/client'; import { expect, expectTypeOf, test, vi } from 'vite-plus/test'; import { createDefaultConfig } from '../config/defaults.ts'; @@ -402,6 +402,33 @@ test('copies provider drafts with the provider label and Markdown heading', asyn await waitFor(() => expect(writeText).toHaveBeenCalledWith(markdown)); }); +test('hides the persistent copy action while a desktop source switch is pending', async () => { + const file = snapshot.files[0]!; + await using view = await renderSurface({ + capabilities: { + desktop: { isSwitchingSource: true }, + localReviewNotes: { + drafts: { + onChange: vi.fn(), + value: [ + { + body: 'Keep this note through the source switch.', + filePath: file.path, + id: 'switching-note', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + }, + ], + }, + }, + }, + initialMode: 'tree', + }); + + expect(view.container.querySelector('.copy-comments-button')).toBeNull(); +}); + test('shows the configured sidebar shortcut in the collapse tooltip', async () => { const keymap = { ...createDefaultConfig().keymap, toggleSidebar: 'Alt+b' }; await using view = await renderSurface({ initialMode: 'tree', keymap }); @@ -474,6 +501,20 @@ test('preserves structured walkthrough failure metadata', async () => { expect(view.container.textContent).toContain('Pi CLI was not found.'); }); +test('renders the walkthrough unread indicator from host capability state', async () => { + await using view = await renderSurface({ + capabilities: { walkthrough: { unread: true } }, + initialMode: 'tree', + }); + expect(view.container.querySelector('.review-mode-dot')).not.toBeNull(); + + await view.render({ + capabilities: { walkthrough: { unread: false } }, + initialMode: 'tree', + }); + expect(view.container.querySelector('.review-mode-dot')).toBeNull(); +}); + test('composes host commands while keeping controlled preferences authoritative', async () => { const onModeChange = vi.fn(); const onWordWrapChange = vi.fn(); @@ -560,8 +601,10 @@ test('forwards controlled draft updates atomically across an asynchronous submis function ControlledSurface() { const [drafts, updateDrafts] = useState>([firstDraft]); - setDrafts = updateDrafts; - latestDrafts = drafts; + useEffect(() => { + setDrafts = updateDrafts; + latestDrafts = drafts; + }, [drafts]); return ( { await act(async () => { root.render( Codiff} + leading={ + + } mode="tree" modes={[{ icon: null, label: 'Tree', value: 'tree' }]} onModeChange={() => {}} @@ -114,43 +119,113 @@ test('share viewer shows the complete repository path when there is no repositor wordWrap: false, }, repository: { root: '/Users/ada/dev/codiff-web', source }, + reviewComments: [], version: 1, walkthrough: { agent: 'codex', chapters: [], - focus: 'Focus on the implementation.', + focus: 'Review the change.', generatedAt: '2026-06-19T00:00:00.000Z', kind: 'narrative', repo: { branch: 'main', root: '/Users/ada/dev/codiff-web' }, source, support: [], - title: 'Shared walkthrough', + title: 'Review', version: 4, }, } satisfies SharedWalkthroughSnapshot; - + const originalHome = process.env.HOME; + process.env.HOME = '/Users/ada'; const container = document.createElement('div'); document.body.append(container); - let root: Root | null = null; - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); + let root!: Root; + + try { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => { + expect(container.querySelector('.review-top-bar-repository')).not.toBeNull(); + }); + expect(container.querySelector('.review-top-bar-repository')?.textContent).toBe( + '~/dev/codiff-web', + ); + } finally { + if (originalHome == null) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + await act(async () => root.unmount()); + container.remove(); + } +}); + +test('shared review surface owns configured search and portable commands', async () => { + const file = createChangedFile('src/shared-command.ts'); + const snapshot = { + branch: 'main', + codiffVersion: 'test', + exportedAt: '2026-07-29T00:00:00.000Z', + files: [file], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, }, + repository: { root: '/repo', source: { type: 'working-tree' } }, + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Review the change.', + generatedAt: '2026-07-29T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'main', root: '/repo' }, + source: { type: 'working-tree' }, + support: [], + title: 'Review', + version: 4, + }, + } satisfies SharedWalkthroughSnapshot; + const keymap = { + ...createDefaultConfig().keymap, + commandBar: 'Ctrl+p', + diffSearch: 'Ctrl+g', }; - await act(async () => { - root = createRoot(container); - root.render(); - }); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); - await waitFor(() => { - expect(container.querySelector('.review-top-bar-repository')).not.toBeNull(); - }); - expect(container.querySelector('.review-top-bar-repository')?.textContent).toBe( - '~/dev/codiff-web', - ); + try { + await act(async () => { + root.render(); + }); + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 'g' }), + ); + }); + expect(container.querySelector('.diff-search-panel.visible')).not.toBeNull(); + + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 'p' }), + ); + }); + const commandBar = container.querySelector('.command-bar'); + expect(commandBar?.textContent).toContain('Find in Diffs'); + expect(commandBar?.textContent).not.toContain('Open File in Editor'); + expect(commandBar?.textContent).not.toContain('Open Config File'); + } finally { + await act(async () => root.unmount()); + container.remove(); + } }); test('shared walkthroughs switch between walkthrough and tree review modes', async () => { @@ -278,6 +353,12 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy await waitFor(() => { expect(container.querySelector('.walkthrough-list')).not.toBeNull(); }); + const walkthroughArc = container.querySelector('.wt-arc'); + const walkthroughBody = container.querySelector('.wt-hybrid'); + expect(walkthroughArc).not.toBeNull(); + expect(walkthroughBody).not.toBeNull(); + expect(walkthroughBody?.contains(walkthroughArc)).toBe(false); + expect(walkthroughArc?.parentElement).toBe(walkthroughBody?.parentElement); expect(container.querySelector('.sidebar-search')).toBeNull(); const deleteShare = container.querySelector( 'button[aria-label="Delete shared walkthrough"]', diff --git a/core/__tests__/app-command-hooks.test.tsx b/core/__tests__/app-command-hooks.test.tsx index 49e20ccb..217d335b 100644 --- a/core/__tests__/app-command-hooks.test.tsx +++ b/core/__tests__/app-command-hooks.test.tsx @@ -8,7 +8,6 @@ import { useAppCommands } from '../app/hooks/useAppCommands.ts'; import { useAppKeyboardShortcuts } from '../app/hooks/useAppKeyboardShortcuts.ts'; import { createDefaultConfig, defaultKeymap } from '../config/defaults.ts'; import { createReviewCommandTarget } from '../lib/review-command-target.ts'; -import type { RepositoryState } from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { renderReact } from './helpers/react.tsx'; @@ -49,16 +48,6 @@ test('app commands register the complete command set and delegate dynamic action ...createDefaultConfig().settings, }, }; - const stateRef = { - current: { - branch: 'main', - files: [file], - generatedAt: 1, - launchPath: '/repo', - root: '/repo', - source, - } satisfies RepositoryState, - }; const viewedRef = { current: { [target.reviewIdentity.key]: target.reviewIdentity.fingerprint, @@ -67,6 +56,7 @@ test('app commands register the complete command set and delegate dynamic action const changeSidebarMode = vi.fn(); const focusFileFilter = vi.fn(); const getReviewCommandTarget = vi.fn(() => target); + const onCopyPendingComments = vi.fn(() => '# Address these Review Notes'); const onOpenDiffSearch = vi.fn(); const onOpenReviewSource = vi.fn(); const onOpenSelectedFile = vi.fn(); @@ -74,14 +64,21 @@ test('app commands register the complete command set and delegate dynamic action const onToggleSidebar = vi.fn(); const onToggleViewed = vi.fn(); const onToggleWordWrap = vi.fn(); + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); let commands: ReturnType = []; await using _view = await renderReact( (commands = nextCommands)} options={{ changeSidebarMode, + copyPendingCommentsLabel: 'Copy Review Notes', focusFileFilter, getReviewCommandTarget, + onCopyPendingComments, onOpenDiffSearch, onOpenReviewSource, onOpenSelectedFile, @@ -90,8 +87,6 @@ test('app commands register the complete command set and delegate dynamic action onToggleViewed, onToggleWordWrap, preferencesRef, - reviewCommentsRef: { current: [] }, - stateRef, viewedRef, }} />, @@ -137,6 +132,7 @@ test('app commands register the complete command set and delegate dynamic action command('open-file').execute(); command('toggle-sidebar').execute(); command('toggle-word-wrap').execute(); + command('copy-comments').execute(); command('reload').execute(); command('toggle-viewed').execute(); expect(focusFileFilter).toHaveBeenCalledOnce(); @@ -147,9 +143,13 @@ test('app commands register the complete command set and delegate dynamic action expect(onToggleSidebar).toHaveBeenCalledOnce(); expect(onToggleWordWrap).toHaveBeenCalledOnce(); expect(onRefreshRepository).toHaveBeenCalledOnce(); + expect(onCopyPendingComments).toHaveBeenCalledOnce(); + expect(writeText).toHaveBeenCalledWith('# Address these Review Notes'); expect(command('open-file').description?.()).toBe(file.path); expect(onToggleViewed).toHaveBeenCalledWith(file, true, target.reviewIdentity); expect(command('toggle-word-wrap').description?.()).toBe('Enable Word Wrap'); + expect(command('copy-comments').title).toBe('Copy Review Notes'); + expect(command('copy-comments-and-close').title).toBe('Copy Review Notes and Close'); preferencesRef.current.wordWrap = true; expect(command('toggle-word-wrap').description?.()).toBe('Disable Word Wrap'); }); diff --git a/core/__tests__/app-review-comment-hooks.test.tsx b/core/__tests__/app-review-comment-hooks.test.tsx index 84623106..43e734c0 100644 --- a/core/__tests__/app-review-comment-hooks.test.tsx +++ b/core/__tests__/app-review-comment-hooks.test.tsx @@ -20,17 +20,6 @@ const workingTreeState = { root: '/repo', source: { type: 'working-tree' }, } satisfies RepositoryState; -const pullRequestState = { - ...workingTreeState, - source: { - number: 42, - owner: 'nkzw-tech', - provider: 'github', - repo: 'codiff', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/42', - }, -} satisfies RepositoryState; const comment: ReviewComment = { body: 'Review this', filePath: 'src/app.ts', @@ -51,7 +40,6 @@ function AppReviewCommentsHarness({ }) { const stateRef = useRef(state); const comments = useAppReviewComments({ - isReviewActionDisabled: () => false, onCommentFileChange, stateRef, }); @@ -118,86 +106,3 @@ test('app review comments request and store assistant replies', async () => { }); expect(onCommentFileChange).toHaveBeenCalledTimes(2); }); - -test('app review comments submit a draft and replace it with the remote comment', async () => { - const submitPullRequestComment = vi.fn(async () => ({ - author: { - login: 'reviewer', - name: 'Reviewer', - }, - body: comment.body, - filePath: comment.filePath, - id: 'remote-comment', - lineNumber: comment.lineNumber, - side: comment.side, - submittedAt: '2026-07-15T00:00:00.000Z', - url: 'https://github.com/nkzw-tech/codiff/pull/42#discussion_r1', - })); - window.codiff = { submitPullRequestComment } as unknown as Window['codiff']; - await using view = await renderAppReviewComments(pullRequestState); - const { getState, onCommentFileChange } = view; - - await act(async () => { - getState().setReviewComments([comment]); - }); - await act(async () => { - getState().submitPullRequestComment(comment.id); - }); - expect(submitPullRequestComment).toHaveBeenCalledWith({ - comment: { - body: comment.body, - filePath: comment.filePath, - lineNumber: comment.lineNumber, - side: comment.side, - }, - source: pullRequestState.source, - }); - await waitFor(() => { - expect(getState().reviewComments).toEqual([ - { - author: { - login: 'reviewer', - name: 'Reviewer', - }, - body: comment.body, - filePath: comment.filePath, - id: 'remote-comment', - isReadOnly: true, - lineNumber: comment.lineNumber, - sectionId: comment.sectionId, - side: comment.side, - submittedAt: '2026-07-15T00:00:00.000Z', - url: 'https://github.com/nkzw-tech/codiff/pull/42#discussion_r1', - }, - ]); - }); - expect(onCommentFileChange).toHaveBeenCalledTimes(2); -}); - -test('app review comments submit and clear pending review drafts', async () => { - const submitPullRequestReview = vi.fn(async () => {}); - window.codiff = { submitPullRequestReview } as unknown as Window['codiff']; - await using view = await renderAppReviewComments(pullRequestState); - const { getState } = view; - - await act(async () => { - getState().setReviewComments([comment]); - }); - await act(async () => { - await getState().submitPullRequestReview('COMMENT'); - }); - expect(submitPullRequestReview).toHaveBeenCalledWith({ - comments: [ - { - body: comment.body, - filePath: comment.filePath, - lineNumber: comment.lineNumber, - side: comment.side, - }, - ], - event: 'COMMENT', - source: pullRequestState.source, - }); - expect(getState().reviewComments).toEqual([]); - expect(getState().pullRequestReviewSubmitting).toBeNull(); -}); diff --git a/core/__tests__/helpers/react.tsx b/core/__tests__/helpers/react.tsx index e88b4ce3..d82edee8 100644 --- a/core/__tests__/helpers/react.tsx +++ b/core/__tests__/helpers/react.tsx @@ -5,24 +5,32 @@ export const renderReact = async (element: ReactNode) => { const container = document.createElement('div'); document.body.append(container); const root = createRoot(container); + let cleanedUp = false; await act(async () => { root.render(element); }); + const cleanup = async () => { + if (cleanedUp) { + return; + } + cleanedUp = true; + await act(async () => { + root.unmount(); + }); + container.remove(); + }; + return { + cleanup, container, rerender: async (nextElement: ReactNode) => { await act(async () => { root.render(nextElement); }); }, - async [Symbol.asyncDispose]() { - await act(async () => { - root.unmount(); - }); - container.remove(); - }, + [Symbol.asyncDispose]: cleanup, }; }; diff --git a/core/__tests__/repository-refresh.test.ts b/core/__tests__/repository-refresh.test.ts new file mode 100644 index 00000000..176e3e1c --- /dev/null +++ b/core/__tests__/repository-refresh.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from 'vite-plus/test'; +import { hasReviewedCodeChanged, reconcileRepositoryRefresh } from '../lib/repository-refresh.ts'; +import type { GitSha, RepositoryState } from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; + +const gitSha = (character: string) => character.repeat(40) as GitSha; +const stateFor = ( + files: RepositoryState['files'], + source: RepositoryState['source'] = { type: 'working-tree' }, +): RepositoryState => ({ + branch: 'main', + files, + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source, +}); + +test('reconciles changed paths, selection, collapsed state, and walkthrough refresh together', () => { + const previousState = stateFor([ + createChangedFile('src/changed.ts', { fingerprint: 'before', patch: 'before' }), + createChangedFile('src/unchanged.ts', { fingerprint: 'stable' }), + createChangedFile('src/removed.ts'), + ]); + const nextState = stateFor([ + createChangedFile('src/changed.ts', { fingerprint: 'after', patch: 'after' }), + createChangedFile('src/unchanged.ts', { fingerprint: 'stable' }), + ]); + + expect( + reconcileRepositoryRefresh({ + collapsed: new Set(['src/changed.ts', 'src/unchanged.ts']), + historySource: null, + mainMode: 'review', + nextState, + previousState, + selectedPath: 'src/removed.ts', + }), + ).toEqual({ + changedPaths: new Set(['src/changed.ts']), + collapsed: new Set(['src/unchanged.ts']), + historySource: null, + mainMode: 'review', + selectedPath: 'src/changed.ts', + walkthroughNeedsRefresh: true, + }); +}); + +test('repairs commit mode after the working tree becomes empty', () => { + const previousState = stateFor([createChangedFile('src/committed.ts')]); + const nextState = stateFor([]); + + expect( + reconcileRepositoryRefresh({ + collapsed: new Set(), + historySource: null, + mainMode: 'commit', + nextState, + previousState, + selectedPath: 'src/committed.ts', + }), + ).toMatchObject({ mainMode: 'review', selectedPath: null }); +}); + +test('retains the active History scope when refreshed state has no replacement', () => { + const historySource = { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-diff', + } as const; + const previousState = stateFor([], { + ...historySource, + type: 'branch-working-tree', + }); + const nextState = stateFor([], { type: 'working-tree' }); + + expect( + reconcileRepositoryRefresh({ + collapsed: new Set(), + historySource, + mainMode: 'review', + nextState, + previousState, + selectedPath: null, + }).historySource, + ).toEqual(historySource); +}); + +test('compares canonical reviewed code without provider order or revision metadata', () => { + const original = createChangedFile('src/reviewed.ts', { + fingerprint: 'provider-state-a', + patch: 'diff --git a/src/reviewed.ts b/src/reviewed.ts\n@@ -1 +1 @@\n-old\n+new\n', + }); + const withRange = { + ...original, + sections: original.sections.map((section) => ({ + ...section, + range: { + base: { + label: { kind: 'commit' as const, text: 'base' }, + sha: gitSha('a'), + }, + head: { + label: { kind: 'commit' as const, text: 'head' }, + sha: gitSha('b'), + }, + }, + })), + }; + const refreshed = { + ...withRange, + fingerprint: 'provider-state-b', + generated: true, + sections: withRange.sections.map((section) => ({ + ...section, + id: 'provider-order-2', + kind: 'pull-request' as const, + loadState: 'ready' as const, + newFile: { cacheKey: 'new-head:path', contents: 'new\n', name: 'src/reviewed.ts' }, + oldFile: { cacheKey: 'old-base:path', contents: 'old\n', name: 'src/reviewed.ts' }, + range: { + base: { + label: { kind: 'commit' as const, text: 'rebased' }, + sha: gitSha('c'), + }, + head: { + label: { kind: 'commit' as const, text: 'updated' }, + sha: gitSha('d'), + }, + }, + summary: { canLoad: false, reason: 'Hydrated after refresh.' }, + })), + }; + const other = createChangedFile('src/other.ts'); + + expect(hasReviewedCodeChanged([withRange, other], [other, refreshed])).toBe(false); +}); + +test('detects reviewed patch, blob, path, rename, and status changes', () => { + const file = createChangedFile('src/reviewed.ts'); + const binary = { + ...file, + sections: file.sections.map((section) => ({ + ...section, + binary: true, + patch: '', + summary: { fingerprint: 'blob-a', reason: 'Binary file changed.' }, + })), + }; + + expect( + hasReviewedCodeChanged( + [file], + [createChangedFile('src/reviewed.ts', { patch: `${file.sections[0].patch}+another\n` })], + ), + ).toBe(true); + expect( + hasReviewedCodeChanged( + [binary], + [ + { + ...binary, + sections: binary.sections.map((section) => ({ + ...section, + summary: { fingerprint: 'blob-b', reason: 'Different provider wording.' }, + })), + }, + ], + ), + ).toBe(true); + expect(hasReviewedCodeChanged([file], [{ ...file, path: 'src/moved.ts' }])).toBe(true); + expect(hasReviewedCodeChanged([file], [{ ...file, oldPath: 'src/old.ts' }])).toBe(true); + expect(hasReviewedCodeChanged([file], [{ ...file, status: 'renamed' }])).toBe(true); +}); diff --git a/core/__tests__/repository-review-bootstrap.test.ts b/core/__tests__/repository-review-bootstrap.test.ts new file mode 100644 index 00000000..8daf2146 --- /dev/null +++ b/core/__tests__/repository-review-bootstrap.test.ts @@ -0,0 +1,178 @@ +import { expect, test } from 'vite-plus/test'; +import type { ReloadSelection } from '../lib/reload-selection.ts'; +import { + resolveReloadSourceForLaunch, + resolveRepositoryReviewBootstrap, +} from '../lib/repository-review-bootstrap.ts'; +import type { + CodiffLaunchOptions, + GitSha, + RepositoryState, + ResolvedReviewSource, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; + +const gitSha = (character: string) => character.repeat(40) as GitSha; +const launchOptions = { + repositoryPathProvided: true, + walkthrough: false, +} satisfies CodiffLaunchOptions; + +const stateFor = ( + source: ResolvedReviewSource, + files: RepositoryState['files'] = [], +): RepositoryState => ({ + branch: 'main', + files, + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source, +}); + +const selectionFor = ( + state: RepositoryState, + overrides: Partial = {}, +): ReloadSelection => ({ + files: state.files.map(({ fingerprint, path, status }) => ({ fingerprint, path, status })), + mainMode: 'review', + root: state.root, + selectedPath: state.files[0]?.path ?? null, + source: state.source, + ...overrides, +}); + +test('resolves clean and changed working-tree startup modes', () => { + const cleanState = stateFor({ type: 'working-tree' }); + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: null, + state: cleanState, + }), + ).toMatchObject({ + historySource: null, + mainMode: 'review', + selectedPath: null, + sidebarMode: 'history', + source: { type: 'working-tree' }, + }); + + const changedState = stateFor({ type: 'working-tree' }, [ + createChangedFile('src/changed.ts', { fingerprint: 'after' }), + ]); + const previous = selectionFor(changedState, { + files: [{ fingerprint: 'before', path: 'src/changed.ts', status: 'modified' }], + }); + const bootstrap = resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: previous, + state: changedState, + }); + expect(bootstrap.sidebarMode).toBe('tree'); + expect(bootstrap.reloadDeltaPaths).toEqual(new Set(['src/changed.ts'])); +}); + +test('restores a valid working-tree commit view and one-shot instant scroll target', () => { + const state = stateFor({ type: 'working-tree' }, [ + createChangedFile('src/first.ts'), + createChangedFile('src/restored.ts'), + ]); + const bootstrap = resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(state, { + mainMode: 'commit', + selectedPath: 'src/restored.ts', + }), + state, + }); + + expect(bootstrap.mainMode).toBe('commit'); + expect(bootstrap.selectedPath).toBe('src/restored.ts'); + expect(bootstrap.initialScrollTarget).toEqual({ + behavior: 'instant', + path: 'src/restored.ts', + request: 1, + }); +}); + +test('rejects commit mode for empty and non-working-tree sources', () => { + const emptyState = stateFor({ type: 'working-tree' }); + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(emptyState, { mainMode: 'commit' }), + state: emptyState, + }).mainMode, + ).toBe('review'); + + const commitState = stateFor({ sha: gitSha('c'), type: 'commit' }, [ + createChangedFile('src/commit.ts'), + ]); + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(commitState, { mainMode: 'commit' }), + state: commitState, + }).mainMode, + ).toBe('review'); +}); + +test('restores branch History scope for branch-diff and branch-working-tree reviews', () => { + const branchSource = { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-diff', + } as const; + const branchState = stateFor(branchSource, [createChangedFile('src/branch.ts')]); + const workingTreeSource = { ...branchSource, type: 'branch-working-tree' } as const; + + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(branchState, { historySource: branchSource }), + state: branchState, + }).historySource, + ).toEqual(branchSource); + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(stateFor(workingTreeSource), { + historySource: branchSource, + source: workingTreeSource, + }), + state: stateFor(workingTreeSource), + }).historySource, + ).toEqual(branchSource); +}); + +test('lets an explicit launch source override stale reload state', () => { + const state = stateFor({ type: 'working-tree' }); + const selection = selectionFor(state); + expect( + resolveReloadSourceForLaunch(selection, { + ...launchOptions, + source: { ref: 'abc', type: 'commit' }, + }), + ).toBeUndefined(); + expect(resolveReloadSourceForLaunch(selection, launchOptions)).toEqual({ + type: 'working-tree', + }); +}); + +test('walkthrough-file startup selects Walkthrough without forcing regeneration', () => { + const state = stateFor({ type: 'working-tree' }, [ + createChangedFile('src/walkthrough.ts', { fingerprint: 'after' }), + ]); + const bootstrap = resolveRepositoryReviewBootstrap({ + launchOptions: { ...launchOptions, walkthroughFile: '/tmp/walkthrough.json' }, + reloadSelection: selectionFor(state, { + files: [{ fingerprint: 'before', path: 'src/walkthrough.ts', status: 'modified' }], + }), + state, + }); + + expect(bootstrap.sidebarMode).toBe('walkthrough'); + expect(bootstrap.forceInitialWalkthrough).toBe(false); +}); diff --git a/core/__tests__/review-history.test.ts b/core/__tests__/review-history.test.ts index 55ab4647..7c42cb3d 100644 --- a/core/__tests__/review-history.test.ts +++ b/core/__tests__/review-history.test.ts @@ -16,7 +16,7 @@ test('keeps revision SHA identity separate from labels and non-commit markers', const range = diffRange(base, head); expect(shaForRevision(base)).toBe(base.sha); - expect(range.head.label.text).toBe('head'); + expect(range.head?.label.text).toBe('head'); expect(() => shaForRevision({ kind: 'index', label: { kind: 'review-marker', text: 'Index' } }), ).toThrow('Expected a commit revision'); diff --git a/core/app/RepositoryReviewHost.tsx b/core/app/RepositoryReviewHost.tsx new file mode 100644 index 00000000..674fd6d8 --- /dev/null +++ b/core/app/RepositoryReviewHost.tsx @@ -0,0 +1,1569 @@ +import type { FileDiffLoadedFiles } from '@pierre/diffs'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { CodiffConfig } from '../config/types.ts'; +import { HISTORY_PAGE_SIZE } from '../lib/app-constants.ts'; +import { + type RepositoryLoadError, + type ReviewIdentity, + type SourceSession, +} from '../lib/app-types.ts'; +import { isPatchOnlyDiffSection, shouldLoadDiffSectionContents } from '../lib/diff.ts'; +import { sortFiles } from '../lib/files.ts'; +import { + getChangedPaths, + haveChangedFiles, + writeReloadSelection, +} from '../lib/reload-selection.ts'; +import { reconcileRepositoryRefresh } from '../lib/repository-refresh.ts'; +import type { RepositoryReviewBootstrap } from '../lib/repository-review-bootstrap.ts'; +import { resolveReviewCommandTarget } from '../lib/review-command-target.ts'; +import { getReviewCommentsFromState, mergeReviewComments } from '../lib/review-comments.ts'; +import { getFileReviewIdentity } from '../lib/review-identity.ts'; +import { + getHistorySource, + getRefreshSource, + getRepositoryLoadError, + getSourceKey, + getSourceLabel, + supportsLazyDiffContent, + usesViewedFileState, +} from '../lib/source.ts'; +import { readViewed, writeViewed } from '../lib/viewed.ts'; +import { + buildSharedReviewSnapshot, + ReviewSurface, + type ProviderReviewOutcome, + type ReviewMode, + type ReviewSurfaceCapabilities, + type ReviewSurfaceCommandBridge, + type ReviewWalkthroughStatus, +} from '../ReviewSurface.tsx'; +import type { + ChangedFile, + CodiffLaunchOptions, + CodiffPreferences, + CodiffUpdateStatus, + GitIdentity, + HistoryEntry, + NarrativeWalkthrough, + NarrativeWalkthroughResult, + OpenReviewSourceKind, + RepositoryState, + ReviewSource, + DiffImageContentRequest, + DiffImageContentResult, + DiffSection, + DiffSectionContentRequest, +} from '../types.ts'; +import { OpenReviewSourceDialog } from './components/OpenReviewSourceDialog.tsx'; +import { OpenReviewSourceMenu } from './components/OpenReviewSourceMenu.tsx'; +import { + RepositoryChangeBanner, + RepositoryLoadErrorPanel, + RepositoryRefreshBanner, + type RepositoryRefreshStatus, + ReviewSourceLoading, + UpdatePill, + WalkthroughOutdatedBanner, +} from './components/Panels.tsx'; +import { WalkthroughProgress } from './components/walkthrough/WalkthroughProgress.tsx'; +import type { WalkthroughFileError } from './components/WalkthroughFileError.tsx'; +import { useAppCommands } from './hooks/useAppCommands.ts'; +import { useAppReviewComments } from './hooks/useAppReviewComments.ts'; +import { useAppWalkthrough } from './hooks/useAppWalkthrough.ts'; +import { useDocumentAppearance } from './hooks/useDocumentAppearance.ts'; +import { useReviewFileState } from './hooks/useReviewState.ts'; + +const portableSurfaceCommandIds = new Set([ + 'diff-search', + 'file-filter', + 'sidebar-history', + 'sidebar-tree', + 'sidebar-walkthrough', + 'toggle-sidebar', + 'toggle-word-wrap', +]); +const defaultReviewCommentsPrefix = '# Address these Review Comments'; +const getReviewCommentsSourceKey = (state: RepositoryState) => { + const headSha = state.source.type === 'pull-request' ? (state.source.headSha ?? '') : ''; + return `${state.root}:${getSourceKey(state.source)}:${headSha}`; +}; + +type ReviewAuthoringMode = 'local-notes' | 'provider-comments' | 'read-only'; + +const getProviderMutationDestination = ( + source: RepositoryState['source'], +): 'github' | 'gitlab' | null => + source.type === 'pull-request' && (source.provider === 'github' || source.provider === 'gitlab') + ? source.provider + : null; + +const getReviewAuthoringMode = (source: RepositoryState['source']): ReviewAuthoringMode => + getProviderMutationDestination(source) + ? 'provider-comments' + : source.type === 'pull-request' + ? 'read-only' + : 'local-notes'; + +const getPendingCommentsLabel = (mode: ReviewAuthoringMode) => + mode === 'provider-comments' ? 'Copy Pending Review Comments' : 'Copy Review Notes'; + +const getPendingCommentsPrefix = (mode: ReviewAuthoringMode, configuredPrefix: string) => + configuredPrefix === defaultReviewCommentsPrefix + ? mode === 'provider-comments' + ? '# Address these Pending Review Comments' + : '# Address these Review Notes' + : configuredPrefix; + +const toPullRequestReviewEvent = ( + outcome: ProviderReviewOutcome, +): 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES' => { + switch (outcome) { + case 'approve': + return 'APPROVE'; + case 'comment': + return 'COMMENT'; + case 'request-changes': + return 'REQUEST_CHANGES'; + } +}; + +const createPlaceholderWalkthrough = ( + state: RepositoryState, + title: string, + agent: NarrativeWalkthrough['agent'], +): NarrativeWalkthrough => ({ + agent, + chapters: [], + focus: 'Generate a walkthrough to review this change in narrative order.', + generatedAt: new Date(state.generatedAt).toISOString(), + kind: 'narrative', + repo: { branch: state.branch, root: state.root }, + source: state.source, + support: [], + title, + version: 4, +}); + +const getFailedSectionLoadState = (section: DiffSection): DiffSection => + isPatchOnlyDiffSection(section) + ? { + ...section, + summary: { + canLoad: false, + reason: 'Codiff could not load full file context.', + }, + } + : { + ...section, + loadState: 'error', + summary: { + canLoad: false, + reason: 'Codiff could not load this file.', + }, + }; + +const getPreferencesFromConfig = ({ settings }: CodiffConfig): CodiffPreferences => ({ + ...settings, +}); + +const getCollapsedViewedPaths = ( + files: ReadonlyArray, + viewedFiles: Readonly>, +) => + new Set( + files.filter((file) => viewedFiles[file.path] === file.fingerprint).map((file) => file.path), + ); + +export type RepositoryReviewHostProps = { + bootstrap: RepositoryReviewBootstrap; + config: CodiffConfig; + disableCodeViewWorkerPool?: boolean; + gitIdentity: GitIdentity | null; + initialHistory?: ReadonlyArray; + initialWalkthroughFileError?: WalkthroughFileError | null; + initialWalkthroughLoading?: boolean; + initialWalkthroughResult?: NarrativeWalkthroughResult; + launchOptions: CodiffLaunchOptions; + walkthroughSharingEnabled?: boolean; +}; + +export function RepositoryReviewHost({ + bootstrap, + config, + disableCodeViewWorkerPool = false, + gitIdentity, + initialHistory = [], + initialWalkthroughFileError, + initialWalkthroughLoading = false, + initialWalkthroughResult, + launchOptions, + walkthroughSharingEnabled = false, +}: RepositoryReviewHostProps) { + const { + historySource: initialHistorySource, + initialScrollTarget, + mainMode: initialMainMode, + reloadDeltaPaths: initialReloadDeltaPaths, + selectedPath: initialSelectedPath, + sidebarMode: startupMode, + state: initialState, + } = bootstrap; + const [loadError, setLoadError] = useState(null); + const [historyEntries, setHistoryEntries] = useState>(initialHistory); + const [historyHasMore, setHistoryHasMore] = useState(initialHistory.length >= HISTORY_PAGE_SIZE); + const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE_SIZE); + const [historyLoading, setHistoryLoading] = useState(false); + const [historySource, setHistorySource] = useState(() => + initialHistorySource === undefined + ? (getHistorySource(initialState.source) ?? null) + : initialHistorySource, + ); + const [localChangesDetected, setLocalChangesDetected] = useState(false); + const [repositoryRefreshStatus, setRepositoryRefreshStatus] = + useState(null); + const [walkthroughStale, setWalkthroughStale] = useState(false); + const [openReviewSourceKind, setOpenReviewSourceKind] = useState( + null, + ); + const preferences = useMemo(() => getPreferencesFromConfig(config), [config]); + const [reloadDeltaPaths, setReloadDeltaPaths] = useState>( + () => initialReloadDeltaPaths, + ); + const [surfaceInitialScrollTarget, setSurfaceInitialScrollTarget] = useState(initialScrollTarget); + const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); + const [pendingSource, setPendingSource] = useState(null); + const [loadingSectionIds, setLoadingSectionIds] = useState>(() => new Set()); + const [, setSidebarCollapsed] = useState(false); + const [state, setState] = useState(() => ({ + ...initialState, + files: sortFiles(initialState.files), + })); + const [updateStatus, setUpdateStatus] = useState(null); + const historyRequestRef = useRef(0); + const historySourceRef = useRef(null); + const diffContentRequestCounterRef = useRef(0); + const diffContentRequestIdsRef = useRef>(new Set()); + const loadingSectionKeysRef = useRef>(new Set()); + const reviewCommentsInFlightRef = useRef(null); + const reviewCommentsRequestRef = useRef(0); + const repositoryRefreshRequestRef = useRef(0); + const surfaceCommandBridgeRef = useRef(null); + const sourceSessionsRef = useRef>(new Map()); + const stateRef = useRef(state); + const initialViewed = usesViewedFileState(initialState.source) + ? readViewed(initialState.root) + : {}; + const initialCollapsed = getCollapsedViewedPaths(initialState.files, initialViewed); + const collapsedRef = useRef>(initialCollapsed); + const expandedGeneratedRef = useRef>(new Set()); + const preferencesRef = useRef(preferences); + const previousShowWhitespaceRef = useRef(config.settings.showWhitespace); + const selectedPathRef = useRef(null); + const sourceRequestRef = useRef(0); + const stateGenerationRef = useRef(0); + const markdownRefreshQueueRef = useRef>(Promise.resolve()); + const viewedRef = useRef>(initialViewed); + const persistViewed = useCallback((nextViewed: Record) => { + const currentState = stateRef.current; + if (currentState && usesViewedFileState(currentState.source)) { + writeViewed(currentState.root, nextViewed); + } + }, []); + const { + bumpItemVersion, + collapsed, + expandedGenerated, + itemVersionByKey, + selectedPath, + setCollapsed, + setExpandedGenerated, + setItemVersionByKey, + setSelectedPath, + setViewed, + toggleViewed: toggleReviewViewed, + viewed, + } = useReviewFileState({ + initialCollapsed, + initialSelectedPath, + initialViewed, + onViewedChange: persistViewed, + }); + const toggleViewed = useCallback( + (file: ChangedFile, isViewed: boolean, reviewIdentity?: ReviewIdentity) => { + if (!stateRef.current) { + return; + } + if (reviewIdentity) { + toggleReviewViewed(file, isViewed, reviewIdentity); + } else { + toggleReviewViewed(file, isViewed); + } + }, + [toggleReviewViewed], + ); + + const { askCodex, resetCommentFocus, reviewComments, reviewCommentsRef, setReviewComments } = + useAppReviewComments({ + onCommentFileChange: bumpItemVersion, + stateRef, + }); + const hydrateReviewComments = useCallback( + (requestedState: RepositoryState) => { + if ( + requestedState.source.type !== 'pull-request' || + requestedState.reviewCommentsLoadState !== 'not-loaded' + ) { + return; + } + const sourceKey = getReviewCommentsSourceKey(requestedState); + const generation = stateGenerationRef.current; + const inFlightKey = `${generation}:${sourceKey}`; + if (reviewCommentsInFlightRef.current === inFlightKey) { + return; + } + reviewCommentsInFlightRef.current = inFlightKey; + const request = reviewCommentsRequestRef.current + 1; + reviewCommentsRequestRef.current = request; + const isCurrent = () => { + const current = stateRef.current; + return ( + reviewCommentsRequestRef.current === request && + stateGenerationRef.current === generation && + current?.source.type === 'pull-request' && + getReviewCommentsSourceKey(current) === sourceKey + ); + }; + + void window.codiff + .getReviewComments(requestedState.source) + .then((loadedComments) => { + if (!isCurrent()) { + return; + } + const current = stateRef.current!; + const hydratedState = { + ...current, + reviewComments: loadedComments, + reviewCommentsError: undefined, + reviewCommentsLoadState: 'loaded' as const, + }; + stateRef.current = hydratedState; + setState(hydratedState); + setReviewComments((comments) => + mergeReviewComments( + getReviewCommentsFromState(hydratedState), + comments.filter((comment) => !comment.isReadOnly), + ), + ); + }) + .catch((error: unknown) => { + if (!isCurrent()) { + return; + } + const current = stateRef.current!; + const failedState = { + ...current, + reviewCommentsError: error instanceof Error ? error.message : String(error), + reviewCommentsLoadState: 'failed' as const, + }; + stateRef.current = failedState; + setState(failedState); + }) + .finally(() => { + if (reviewCommentsInFlightRef.current === inFlightKey) { + reviewCommentsInFlightRef.current = null; + } + }); + }, + [setReviewComments], + ); + + useEffect(() => { + if (state?.source.type === 'pull-request' && state.reviewCommentsLoadState === 'not-loaded') { + hydrateReviewComments(state); + } + }, [hydrateReviewComments, state]); + + const { + activeReviewCommandTargetRef, + cancelWalkthroughRequest, + changeSidebarMode, + closeCommitView, + commitWalkthrough, + enabledShareWalkthrough, + loadNarrativeWalkthrough, + mainModeRef, + narrativeNavigation, + narrativeWalkthrough, + narrativeWalkthroughRef, + openCommitView, + plainCommitModel, + refreshWalkthroughForState, + setMainMode, + setNarrativeWalkthrough, + setShareWalkthroughEnabled, + setSidebarMode, + setWalkthroughError, + setWalkthroughFileError, + setWalkthroughLoading, + setWalkthroughUnread, + showPlainCommitView, + sidebarMode, + sidebarModeRef, + subscribeToCommitOutput, + updateActiveWalkthroughReviewTarget, + updateWalkthroughCommitMessage, + walkthroughError, + walkthroughErrorRef, + walkthroughFileError, + walkthroughLoading, + walkthroughProgress, + walkthroughUnread, + } = useAppWalkthrough({ + initialMainMode, + initialSidebarMode: startupMode, + initialWalkthroughFileError, + initialWalkthroughLoading, + initialWalkthroughResult, + preferencesRef, + state, + stateGenerationRef, + stateRef, + }); + const [commentsMode, setCommentsMode] = useState(false); + const activeSurfaceMode: ReviewMode = commentsMode ? 'comments' : sidebarMode; + const changeSurfaceMode = useCallback( + (mode: ReviewMode) => { + if (mode === 'comments') { + setMainMode('review'); + setCommentsMode(true); + return; + } + setCommentsMode(false); + if (mode === 'walkthrough' && walkthroughStale) { + setMainMode('review'); + setSidebarMode('walkthrough'); + setWalkthroughUnread(false); + return; + } + changeSidebarMode(mode); + }, + [changeSidebarMode, setMainMode, setSidebarMode, setWalkthroughUnread, walkthroughStale], + ); + + const cancelDiffContentRequests = useCallback(() => { + for (const requestId of diffContentRequestIdsRef.current) { + window.codiff.cancelDiffContentRequest(requestId); + } + diffContentRequestIdsRef.current.clear(); + }, []); + useEffect(() => cancelDiffContentRequests, [cancelDiffContentRequests]); + + const requestDiffSectionContent = useCallback((request: DiffSectionContentRequest) => { + const requestId = `section:${diffContentRequestCounterRef.current + 1}`; + diffContentRequestCounterRef.current += 1; + diffContentRequestIdsRef.current.add(requestId); + return window.codiff + .getDiffSectionContent({ ...request, requestId }) + .finally(() => diffContentRequestIdsRef.current.delete(requestId)); + }, []); + + const requestDiffImageContent = useCallback( + (request: DiffImageContentRequest): Promise => { + const requestId = `image:${diffContentRequestCounterRef.current + 1}`; + diffContentRequestCounterRef.current += 1; + diffContentRequestIdsRef.current.add(requestId); + return window.codiff + .getDiffImageContent({ ...request, requestId }) + .finally(() => diffContentRequestIdsRef.current.delete(requestId)); + }, + [], + ); + + const loadDiffSection = useCallback( + (file: ChangedFile, section: DiffSection, repositoryState = stateRef.current) => { + const currentState = repositoryState; + if ( + !currentState || + !supportsLazyDiffContent(currentState.source) || + !shouldLoadDiffSectionContents(section) + ) { + return; + } + + const sourceKey = getSourceKey(currentState.source); + const stateGeneration = stateGenerationRef.current; + const reviewKey = getFileReviewIdentity(file).key; + const key = `${currentState.root}:${sourceKey}:${section.id}`; + if (loadingSectionKeysRef.current.has(key)) { + return; + } + + loadingSectionKeysRef.current.add(key); + setLoadingSectionIds((current) => new Set(current).add(section.id)); + + return requestDiffSectionContent({ + force: true, + kind: section.kind, + path: file.path, + showWhitespace: preferencesRef.current.showWhitespace, + source: currentState.source, + }) + .then((loadedSection) => { + if ( + stateGenerationRef.current !== stateGeneration || + stateRef.current?.root !== currentState.root || + getSourceKey(stateRef.current.source) !== sourceKey + ) { + return; + } + setState((current) => { + if ( + stateGenerationRef.current !== stateGeneration || + !current || + current.root !== currentState.root || + getSourceKey(current.source) !== sourceKey + ) { + return current; + } + + return { + ...current, + files: current.files.map((candidate) => + candidate.path === file.path + ? { + ...candidate, + sections: candidate.sections.map((candidateSection) => + candidateSection.id === section.id ? loadedSection : candidateSection, + ), + } + : candidate, + ), + }; + }); + bumpItemVersion(reviewKey); + }) + .catch(() => { + if ( + stateGenerationRef.current !== stateGeneration || + stateRef.current?.root !== currentState.root || + getSourceKey(stateRef.current.source) !== sourceKey + ) { + return; + } + setState((current) => { + if ( + stateGenerationRef.current !== stateGeneration || + !current || + current.root !== currentState.root || + getSourceKey(current.source) !== sourceKey + ) { + return current; + } + + return { + ...current, + files: current.files.map((candidate) => + candidate.path === file.path + ? { + ...candidate, + sections: candidate.sections.map((candidateSection) => + candidateSection.id === section.id + ? getFailedSectionLoadState(candidateSection) + : candidateSection, + ), + } + : candidate, + ), + }; + }); + bumpItemVersion(reviewKey); + }) + .finally(() => { + loadingSectionKeysRef.current.delete(key); + setLoadingSectionIds((current) => { + const next = new Set(current); + next.delete(section.id); + return next; + }); + }); + }, + [bumpItemVersion, requestDiffSectionContent], + ); + + // Fetches full file contents for a patch-only section so the CodeView + // `loadDiffFiles` option can hydrate the rendered diff in place. Unlike + // `loadDiffSection`, this must not touch React state: replacing the section + // would reset the hydrated diff object's identity. + const loadDiffSectionContents = useCallback( + async (file: ChangedFile, section: DiffSection): Promise => { + const currentState = stateRef.current; + if (!currentState || !supportsLazyDiffContent(currentState.source)) { + throw new Error(`Cannot load diff contents for '${file.path}'.`); + } + + const loadedSection = await requestDiffSectionContent({ + force: true, + kind: section.kind, + path: file.path, + showWhitespace: preferencesRef.current.showWhitespace, + source: currentState.source, + }); + if (!loadedSection.newFile) { + throw new Error(`No file contents available for '${file.path}'.`); + } + + return { + newFile: loadedSection.newFile, + oldFile: loadedSection.oldFile ?? null, + }; + }, + [requestDiffSectionContent], + ); + + const refreshMarkdownFile = useCallback( + (file: ChangedFile, _section: DiffSection) => { + const refresh = async () => { + const currentState = stateRef.current; + if ( + !currentState || + (currentState.source.type !== 'working-tree' && + currentState.source.type !== 'branch-working-tree') + ) { + return true; + } + const sourceRequest = sourceRequestRef.current; + const stateGeneration = stateGenerationRef.current; + const sourceKey = getSourceKey(currentState.source); + + try { + const nextState = await window.codiff.getRepositoryState( + getRefreshSource(currentState.source), + ); + const orderedState = { + ...nextState, + files: sortFiles(nextState.files), + }; + if ( + sourceRequestRef.current !== sourceRequest || + stateGenerationRef.current !== stateGeneration || + stateRef.current?.root !== currentState.root || + getSourceKey(stateRef.current.source) !== sourceKey + ) { + return false; + } + + const changedPaths = getChangedPaths(currentState.files, orderedState.files); + const walkthroughNeedsRefresh = haveChangedFiles(currentState.files, orderedState.files); + stateGenerationRef.current += 1; + stateRef.current = orderedState; + setState(orderedState); + setLocalChangesDetected(false); + setReviewComments(getReviewCommentsFromState(orderedState)); + if (walkthroughNeedsRefresh) { + refreshWalkthroughForState(orderedState); + } + setCollapsed((current) => { + const next = new Set(current); + for (const path of changedPaths) { + next.delete(path); + } + return next; + }); + setSelectedPath((current) => + current && orderedState.files.some((candidate) => candidate.path === current) + ? current + : (orderedState.files[0]?.path ?? null), + ); + if (changedPaths.size === 0) { + bumpItemVersion(file.path); + } else { + for (const path of changedPaths) { + bumpItemVersion(path); + } + } + return true; + } catch { + setLocalChangesDetected(true); + return false; + } + }; + + const result = markdownRefreshQueueRef.current.then(refresh, refresh); + markdownRefreshQueueRef.current = result.then( + () => {}, + () => {}, + ); + return result; + }, + [bumpItemVersion, refreshWalkthroughForState, setCollapsed, setReviewComments, setSelectedPath], + ); + + const saveCurrentSourceSession = useCallback(() => { + const currentState = stateRef.current; + if (!currentState) { + return; + } + + sourceSessionsRef.current.set(getSourceKey(currentState.source), { + collapsed: new Set(collapsedRef.current), + expandedGenerated: new Set(expandedGeneratedRef.current), + narrativeWalkthrough: narrativeWalkthroughRef.current, + reviewComments: reviewCommentsRef.current, + selectedPath: selectedPathRef.current, + viewed: viewedRef.current, + walkthroughError: walkthroughErrorRef.current, + walkthroughFiles: currentState.files.map(({ fingerprint, path, status }) => ({ + fingerprint, + path, + status, + })), + }); + }, [narrativeWalkthroughRef, reviewCommentsRef, walkthroughErrorRef]); + + useEffect( + () => + window.codiff.onRepositoryChanged(() => { + setLocalChangesDetected(true); + }), + [], + ); + + useEffect(() => { + void window.codiff.isWindowFullScreen().then(setIsWindowFullscreen, () => {}); + return window.codiff.onWindowFullScreenChanged(setIsWindowFullscreen); + }, []); + + useEffect(() => { + setShareWalkthroughEnabled(walkthroughSharingEnabled); + }, [setShareWalkthroughEnabled, walkthroughSharingEnabled]); + + useEffect(() => { + if (!state || !supportsLazyDiffContent(state.source) || !selectedPath) { + return; + } + + const selectedFile = state.files.find((file) => file.path === selectedPath); + if (!selectedFile) { + return; + } + + const loadableSections = selectedFile.sections.filter(shouldLoadDiffSectionContents); + + if (!loadableSections.length) { + return; + } + + for (const section of loadableSections) { + loadDiffSection(selectedFile, section, state); + } + }, [loadDiffSection, selectedPath, state]); + + useEffect(() => { + const previousShowWhitespace = previousShowWhitespaceRef.current; + const nextPreferences = preferences; + previousShowWhitespaceRef.current = nextPreferences.showWhitespace; + + if (previousShowWhitespace === nextPreferences.showWhitespace) { + return; + } + + const currentState = stateRef.current; + if (!currentState) { + return; + } + + const request = sourceRequestRef.current + 1; + sourceRequestRef.current = request; + stateGenerationRef.current += 1; + loadingSectionKeysRef.current.clear(); + setLoadingSectionIds(new Set()); + + window.codiff + .getRepositoryState(getRefreshSource(currentState.source)) + .then((nextState) => { + if (sourceRequestRef.current !== request) { + return; + } + + const orderedState = { + ...nextState, + files: sortFiles(nextState.files), + }; + const nextSelectedPath = + selectedPathRef.current && + orderedState.files.some((file) => file.path === selectedPathRef.current) + ? selectedPathRef.current + : (orderedState.files[0]?.path ?? null); + const nextViewed = usesViewedFileState(orderedState.source) + ? readViewed(orderedState.root) + : {}; + const walkthroughNeedsRefresh = haveChangedFiles(currentState.files, orderedState.files); + + stateRef.current = orderedState; + setState(orderedState); + if (walkthroughNeedsRefresh) { + refreshWalkthroughForState(orderedState); + } + setSelectedPath(nextSelectedPath); + setReloadDeltaPaths(new Set()); + setItemVersionByKey({}); + setReviewComments(getReviewCommentsFromState(orderedState)); + setViewed(nextViewed); + setCollapsed(getCollapsedViewedPaths(orderedState.files, nextViewed)); + setExpandedGenerated(new Set()); + setLoadError(null); + }) + .catch((error: unknown) => { + if (sourceRequestRef.current !== request) { + return; + } + setLoadError(getRepositoryLoadError(error)); + }); + }, [ + preferences, + setCollapsed, + setExpandedGenerated, + setItemVersionByKey, + refreshWalkthroughForState, + setReviewComments, + setSelectedPath, + setViewed, + ]); + + useDocumentAppearance({ + cleanupCodeFontProperties: true, + clearEmptyCodeFontFamily: true, + codeFontFamily: preferences.codeFontFamily, + codeFontSize: preferences.codeFontSize, + theme: preferences.theme, + }); + + useEffect(() => { + let canceled = false; + window.codiff + .getUpdateStatus() + .then((status) => { + if (!canceled) { + setUpdateStatus(status); + } + }) + .catch(() => {}); + + const unsubscribe = window.codiff.onUpdateStatusChanged(setUpdateStatus); + return () => { + canceled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + historySourceRef.current = historySource; + }, [historySource]); + + useEffect(() => { + collapsedRef.current = collapsed; + }, [collapsed]); + + useEffect(() => { + expandedGeneratedRef.current = expandedGenerated; + }, [expandedGenerated]); + + useEffect(() => { + preferencesRef.current = preferences; + }, [preferences]); + + useEffect(() => { + const removeListener = window.codiff.onCopyPendingCommentsRequest(() => { + return surfaceCommandBridgeRef.current?.copyPendingComments() ?? ''; + }); + return removeListener; + }, [reviewCommentsRef]); + + useEffect(() => { + selectedPathRef.current = selectedPath; + }, [selectedPath]); + + useEffect(() => { + viewedRef.current = viewed; + }, [viewed]); + + const toggleSidebar = useCallback(() => { + setSidebarCollapsed((current) => !current); + }, []); + + const toggleWordWrap = useCallback(() => { + void window.codiff.setWordWrap(!preferencesRef.current.wordWrap).catch(() => {}); + }, []); + + const expandSidebar = useCallback(() => { + setSidebarCollapsed(false); + }, []); + + const focusFileFilter = useCallback(() => { + expandSidebar(); + requestAnimationFrame(() => { + const input = document.querySelector('.sidebar-search'); + input?.focus(); + input?.select(); + }); + }, [expandSidebar]); + + const openFile = useCallback((file: ChangedFile) => { + // Deleted files are still shown in diffs, but there is no current file to open. + if (file.status === 'deleted') { + return; + } + + void window.codiff.openFile(file.path).catch(() => {}); + }, []); + + const getReviewCommandTarget = useCallback(() => { + const currentState = stateRef.current; + if (!currentState) { + return null; + } + + return resolveReviewCommandTarget({ + activeTarget: activeReviewCommandTargetRef.current, + files: currentState.files, + selectedPath: selectedPathRef.current, + source: currentState.source, + useActiveTarget: + mainModeRef.current === 'review' && + sidebarModeRef.current === 'walkthrough' && + narrativeWalkthroughRef.current != null, + }); + }, [activeReviewCommandTargetRef, mainModeRef, narrativeWalkthroughRef, sidebarModeRef]); + + const openSelectedFile = useCallback(() => { + const target = getReviewCommandTarget(); + + if (target) { + openFile(target.file); + } + }, [getReviewCommandTarget, openFile]); + + const openSurfaceDiffSearch = useCallback(() => { + surfaceCommandBridgeRef.current?.openDiffSearch(); + }, []); + const copyPendingComments = useCallback( + () => surfaceCommandBridgeRef.current?.copyPendingComments() ?? '', + [], + ); + useEffect(() => window.codiff.onFindInDiffs(openSurfaceDiffSearch), [openSurfaceDiffSearch]); + const updateSurfaceCommandBridge = useCallback((bridge: ReviewSurfaceCommandBridge | null) => { + surfaceCommandBridgeRef.current = bridge; + }, []); + + const loadMoreHistory = useCallback(() => { + if (historyLoading || !historyHasMore) { + return; + } + + const nextLimit = historyLimit + HISTORY_PAGE_SIZE; + const request = historyRequestRef.current + 1; + historyRequestRef.current = request; + setHistoryLoading(true); + window.codiff + .getRepositoryHistory(nextLimit, historySource ?? undefined) + .then((history) => { + if (historyRequestRef.current !== request) { + return; + } + + setHistoryEntries(history.entries); + setHistoryLimit(nextLimit); + setHistoryHasMore(history.entries.length >= nextLimit); + }) + .catch(() => { + if (historyRequestRef.current === request) { + setHistoryHasMore(false); + } + }) + .finally(() => { + if (historyRequestRef.current === request) { + setHistoryLoading(false); + } + }); + }, [historyHasMore, historyLimit, historyLoading, historySource]); + + // Refresh the repository state in place after the working tree changed. + // Unlike a window reload, this keeps all review UI state (selection, scroll, + // search, walkthrough navigation, commit drafts, pending comments) and only + // re-renders files whose reviewed code actually changed. + useEffect(() => { + if (repositoryRefreshStatus?.phase !== 'complete' || walkthroughStale) { + return; + } + const timeout = window.setTimeout(() => setRepositoryRefreshStatus(null), 2500); + return () => window.clearTimeout(timeout); + }, [repositoryRefreshStatus, walkthroughStale]); + + const refreshRepository = useCallback(() => { + const previousState = stateRef.current; + if (!previousState || pendingSource) { + return; + } + + setRepositoryRefreshStatus({ phase: 'refreshing' }); + const request = repositoryRefreshRequestRef.current + 1; + repositoryRefreshRequestRef.current = request; + const sourceRequest = sourceRequestRef.current; + const refreshSource = getRefreshSource(previousState.source); + const refreshHistorySource = historySourceRef.current + ? getRefreshSource(historySourceRef.current) + : undefined; + const pendingReviewComments = reviewComments.filter((comment) => !comment.isReadOnly); + + Promise.all([ + window.codiff.getRepositoryState(refreshSource), + window.codiff.getRepositoryHistory(historyLimit, refreshHistorySource), + ]) + .then(([nextState, history]) => { + if ( + repositoryRefreshRequestRef.current !== request || + sourceRequestRef.current !== sourceRequest + ) { + return; + } + + const requestedState = { + ...nextState, + files: sortFiles(nextState.files), + }; + const reconciliation = reconcileRepositoryRefresh({ + collapsed: collapsedRef.current, + historySource: historySourceRef.current, + mainMode: mainModeRef.current, + nextState: requestedState, + previousState, + selectedPath: selectedPathRef.current, + }); + const reviewedCodeChanged = reconciliation.walkthroughNeedsRefresh; + const orderedState = reviewedCodeChanged + ? requestedState + : { ...requestedState, files: previousState.files }; + + if (reviewedCodeChanged) { + cancelDiffContentRequests(); + stateGenerationRef.current += 1; + if ( + sidebarModeRef.current === 'walkthrough' || + narrativeWalkthrough != null || + walkthroughLoading + ) { + setWalkthroughStale(true); + } + } + stateRef.current = orderedState; + setState(orderedState); + setReloadDeltaPaths(reconciliation.changedPaths); + for (const path of reconciliation.changedPaths) { + bumpItemVersion(path); + } + setCollapsed(reconciliation.collapsed); + setHistoryEntries(history.entries); + setHistoryHasMore(history.entries.length >= historyLimit); + setHistorySource(reconciliation.historySource); + setReviewComments( + mergeReviewComments(getReviewCommentsFromState(orderedState), pendingReviewComments), + ); + setSelectedPath(reconciliation.selectedPath); + if (reconciliation.mainMode !== mainModeRef.current) { + setMainMode(reconciliation.mainMode); + } + setLocalChangesDetected(false); + setRepositoryRefreshStatus({ phase: 'complete', updated: reviewedCodeChanged }); + }) + .catch((error: unknown) => { + if ( + repositoryRefreshRequestRef.current === request && + sourceRequestRef.current === sourceRequest + ) { + setRepositoryRefreshStatus({ + phase: 'failed', + reason: error instanceof Error ? error.message : String(error), + }); + } + // Keep the current state; the banner stays up as a retry affordance. + }); + }, [ + bumpItemVersion, + cancelDiffContentRequests, + historyLimit, + mainModeRef, + narrativeWalkthrough, + pendingSource, + reviewComments, + setCollapsed, + setMainMode, + setReviewComments, + setSelectedPath, + sidebarModeRef, + walkthroughLoading, + ]); + + // โŒ˜R / the View menu's "Refresh Changes" item route here from the main + // process instead of reloading the window. + useEffect(() => window.codiff.onRefreshRequest(refreshRepository), [refreshRepository]); + + useEffect(() => { + const writeCurrentReloadSelection = () => { + const persistenceState = surfaceCommandBridgeRef.current?.getPersistenceState(); + writeReloadSelection( + stateRef.current, + persistenceState?.selectedPath ?? selectedPathRef.current, + historySourceRef.current, + mainModeRef.current, + ); + }; + + window.addEventListener('beforeunload', writeCurrentReloadSelection); + return () => window.removeEventListener('beforeunload', writeCurrentReloadSelection); + }, [mainModeRef]); + + const selectSource = useCallback( + (source: ReviewSource, options: { throwOnError?: boolean } = {}) => { + const currentState = stateRef.current; + const sourceKey = getSourceKey(source); + const currentDisplayKey = getSourceKey(pendingSource ?? currentState?.source ?? source); + if (currentDisplayKey === sourceKey) { + return Promise.resolve(); + } + + saveCurrentSourceSession(); + cancelDiffContentRequests(); + cancelWalkthroughRequest(); + repositoryRefreshRequestRef.current += 1; + const request = sourceRequestRef.current + 1; + sourceRequestRef.current = request; + setPendingSource(source); + setRepositoryRefreshStatus(null); + setWalkthroughStale(false); + setSurfaceInitialScrollTarget(null); + setLoadError(null); + resetCommentFocus(); + setReloadDeltaPaths(new Set()); + setMainMode('review'); + setWalkthroughUnread(false); + + return window.codiff + .getRepositoryState(source) + .then((nextState) => { + if (sourceRequestRef.current !== request) { + return; + } + + const orderedState = { + ...nextState, + files: sortFiles(nextState.files), + }; + const session = sourceSessionsRef.current.get(getSourceKey(orderedState.source)); + const nextViewed = + session?.viewed ?? + (usesViewedFileState(orderedState.source) ? readViewed(orderedState.root) : {}); + const nextSelectedPath = + session?.selectedPath && + orderedState.files.some((file) => file.path === session.selectedPath) + ? session.selectedPath + : (orderedState.files[0]?.path ?? null); + const nextCollapsed = + session?.collapsed ?? getCollapsedViewedPaths(orderedState.files, nextViewed); + const nextExpandedGenerated = session?.expandedGenerated ?? new Set(); + const sessionWalkthroughIsCurrent = + session?.narrativeWalkthrough != null && + !haveChangedFiles(session.walkthroughFiles, orderedState.files); + const nextNarrativeWalkthrough = sessionWalkthroughIsCurrent + ? (session?.narrativeWalkthrough ?? null) + : null; + + stateGenerationRef.current += 1; + stateRef.current = orderedState; + setState(orderedState); + setHistorySource(getHistorySource(orderedState.source) ?? historySource); + setCollapsed(new Set(nextCollapsed)); + setExpandedGenerated(new Set(nextExpandedGenerated)); + setItemVersionByKey({}); + setReviewComments(session?.reviewComments ?? getReviewCommentsFromState(orderedState)); + setReloadDeltaPaths(new Set()); + setViewed(nextViewed); + setSelectedPath(nextSelectedPath); + setNarrativeWalkthrough(nextNarrativeWalkthrough); + setWalkthroughError( + sessionWalkthroughIsCurrent ? (session.walkthroughError ?? null) : null, + ); + setWalkthroughLoading(false); + setWalkthroughUnread(false); + setLocalChangesDetected(false); + setPendingSource(null); + if (!sessionWalkthroughIsCurrent) { + refreshWalkthroughForState(orderedState, session?.narrativeWalkthrough ?? null); + } + }) + .catch((error: unknown) => { + if (sourceRequestRef.current === request) { + setLoadError(getRepositoryLoadError(error)); + setWalkthroughLoading(false); + setPendingSource(null); + } + if (options.throwOnError) { + throw error; + } + }); + }, + [ + cancelDiffContentRequests, + cancelWalkthroughRequest, + historySource, + pendingSource, + refreshWalkthroughForState, + resetCommentFocus, + saveCurrentSourceSession, + setCollapsed, + setExpandedGenerated, + setItemVersionByKey, + setMainMode, + setNarrativeWalkthrough, + setReviewComments, + setSelectedPath, + setViewed, + setWalkthroughError, + setWalkthroughLoading, + setWalkthroughUnread, + ], + ); + + const openReviewSource = useCallback( + async (kind: OpenReviewSourceKind, value: string) => { + if (kind === 'pull-request') { + const url = await window.codiff.resolvePullRequestUrl(value); + await selectSource({ type: 'pull-request', url }, { throwOnError: true }); + return; + } + + await selectSource( + kind === 'branch' + ? { ref: value, type: 'branch-working-tree' } + : { ref: value, type: 'commit' }, + { throwOnError: true }, + ); + }, + [selectSource], + ); + + const showOpenReviewSourceDialog = useCallback((kind: OpenReviewSourceKind) => { + setOpenReviewSourceKind(kind); + }, []); + + const openRepositoryFolder = useCallback(() => { + void window.codiff.openRepositoryFolder().catch(() => {}); + }, []); + + useEffect( + () => window.codiff.onOpenReviewSource(showOpenReviewSourceDialog), + [showOpenReviewSourceDialog], + ); + + const commandBarCommands = useAppCommands({ + changeSidebarMode: changeSurfaceMode, + copyPendingCommentsLabel: state + ? getPendingCommentsLabel(getReviewAuthoringMode(state.source)) + : 'Copy Review Notes', + focusFileFilter, + getReviewCommandTarget, + onCopyPendingComments: copyPendingComments, + onOpenDiffSearch: openSurfaceDiffSearch, + onOpenReviewSource: showOpenReviewSourceDialog, + onOpenSelectedFile: openSelectedFile, + onRefreshRepository: refreshRepository, + onToggleSidebar: toggleSidebar, + onToggleViewed: toggleViewed, + onToggleWordWrap: toggleWordWrap, + preferencesRef, + viewedRef, + }); + const desktopCommands = useMemo( + () => commandBarCommands.filter((command) => !portableSurfaceCommandIds.has(command.id)), + [commandBarCommands], + ); + + if (loadError) { + return ( +
+
+ +
+
+ ); + } + + if (!state) { + return null; + } + + const source = state.source; + const title = + source.type === 'pull-request' + ? source.title?.trim() || getSourceLabel(source) + : source.type === 'commit' + ? state.commitMetadata?.subject?.trim() || getSourceLabel(source) + : getSourceLabel(source); + const walkthroughStatus: ReviewWalkthroughStatus = walkthroughLoading + ? 'generating' + : narrativeWalkthrough + ? 'ready' + : walkthroughError + ? 'failed' + : 'idle'; + const walkthroughAgent = launchOptions.agentBackend ?? config.settings.agentBackend; + const snapshot = buildSharedReviewSnapshot({ + preferences, + state, + title, + walkthrough: + narrativeWalkthrough ?? createPlaceholderWalkthrough(state, title, walkthroughAgent), + }); + const branchSource = + historySource?.type === 'branch-diff' + ? historySource + : historySource?.type === 'branch-working-tree' && + historySource.baseSha && + historySource.headSha + ? { + baseSha: historySource.baseSha, + headSha: historySource.headSha, + ref: historySource.ref, + type: 'branch-diff' as const, + } + : null; + const isLocalCommitSource = source.type === 'working-tree'; + const providerSource = + source.type === 'pull-request' && getProviderMutationDestination(source) ? source : null; + const reviewAuthoringMode = getReviewAuthoringMode(source); + const annotationCapabilities: ReviewSurfaceCapabilities = providerSource + ? { + comments: { + authoring: { + canCreateInline: true, + onAsk: askCodex, + }, + destination: 'provider', + inline: { + onSubmit: (comment) => + window.codiff.submitPullRequestComment({ comment, source: providerSource }), + }, + reviewSession: { + drafts: { + onChange: setReviewComments, + value: reviewComments, + }, + submit: ({ comments, outcome, summary }) => + window.codiff.submitPullRequestReview({ + ...(summary ? { body: summary } : {}), + comments, + event: toPullRequestReviewEvent(outcome), + source: providerSource, + }), + }, + }, + } + : source.type === 'pull-request' + ? {} + : { + localReviewNotes: { + canCreateInline: true, + drafts: { + onChange: setReviewComments, + value: reviewComments, + }, + onAsk: askCodex, + }, + }; + + return ( + + + { + const currentState = stateRef.current; + if (!currentState) { + return; + } + setWalkthroughStale(false); + void loadNarrativeWalkthrough(currentState.source, { + force: true, + previousWalkthrough: narrativeWalkthroughRef.current ?? undefined, + }); + }} + onRetry={refreshRepository} + status={repositoryRefreshStatus} + walkthroughStale={walkthroughStale} + /> + setWalkthroughFileError(null)} + reason={walkthroughFileError?.reason ?? null} + /> + {pendingSource ? : null} + {openReviewSourceKind ? ( + setOpenReviewSourceKind(null)} + onOpen={(value) => openReviewSource(openReviewSourceKind, value)} + /> + ) : null} + + ), + collapsed, + commands: desktopCommands, + ...(isLocalCommitSource && state.files.length > 0 + ? { + commit: { + branch: state.branch, + draft: narrativeNavigation, + model: plainCommitModel, + onCommit: commitWalkthrough, + onCommitOutput: subscribeToCommitOutput, + onToggle: showPlainCommitView ? closeCommitView : openCommitView, + onUpdateMessage: updateWalkthroughCommitMessage, + open: showPlainCommitView, + }, + } + : {}), + disableCodeViewWorkerPool, + isSwitchingSource: pendingSource != null, + isWindowFullscreen, + onActiveWalkthroughReviewTargetChange: updateActiveWalkthroughReviewTarget, + onCollapsedChange: setCollapsed, + onFindDefinitions: window.codiff.findDefinitions, + onOpenDefinition: (candidate) => { + void window.codiff.openFile(candidate.path, candidate.lineNumber).catch(() => {}); + }, + onOpenFile: openFile, + onOpenSelectedFile: openSelectedFile, + onViewedChange: setViewed, + reloadDeltaPaths, + sidebarFooter: updateStatus ? ( + { + window.codiff.applyUpdate().then(setUpdateStatus, () => {}); + }} + onDismiss={() => { + window.codiff.dismissUpdate().then(setUpdateStatus, () => {}); + }} + status={updateStatus} + /> + ) : null, + sourceMenu: ( + + ), + viewed, + }, + history: { + branchSource, + currentSource: pendingSource ?? source, + entries: historyEntries, + hasMore: historyHasMore, + loading: historyLoading, + onLoadMore: loadMoreHistory, + onSelectSource: selectSource, + pullRequestSource: historySource?.type === 'pull-request' ? historySource : null, + }, + preferences: { + diffLayout: { + onChange: (value) => { + void window.codiff.setDiffStyle(value).catch(() => {}); + }, + value: preferences.diffStyle, + }, + outdatedVisibility: { + onChange: (value) => { + void window.codiff.setShowOutdated(value).catch(() => {}); + }, + value: preferences.showOutdated, + }, + pendingCommentPrefix: { + onChange: () => {}, + value: getPendingCommentsPrefix(reviewAuthoringMode, preferences.reviewCommentsPrefix), + }, + selectedPath: { + onChange: setSelectedPath, + value: selectedPath, + }, + wordWrap: { + onChange: (value) => { + void window.codiff.setWordWrap(value).catch(() => {}); + }, + value: preferences.wordWrap, + }, + }, + walkthrough: { + ...(isLocalCommitSource + ? { + commit: commitWalkthrough, + commitOutput: subscribeToCommitOutput, + updateCommitMessage: updateWalkthroughCommitMessage, + } + : {}), + error: walkthroughError, + onGenerate: () => loadNarrativeWalkthrough(source), + onShare: enabledShareWalkthrough, + progress: ( + + ), + status: walkthroughStatus, + unread: walkthroughUnread, + }, + }} + externalUrl={source.type === 'pull-request' ? source.url : undefined} + gitIdentity={gitIdentity} + key={getSourceKey(source)} + keymap={config.keymap} + onCommandBridgeChange={updateSurfaceCommandBridge} + providerLabel={ + source.type === 'pull-request' && source.provider === 'gitlab' ? 'GitLab' : 'GitHub' + } + sidebarPosition={config.settings.sidebarPosition} + snapshot={snapshot} + title={title} + /> + ); +} diff --git a/core/app/components/FileTree.tsx b/core/app/components/FileTree.tsx index 31e17b9c..2e0a8f9e 100644 --- a/core/app/components/FileTree.tsx +++ b/core/app/components/FileTree.tsx @@ -35,6 +35,7 @@ export function ReviewFileTree({ showWhitespace: boolean; viewed?: Readonly>; }) { + const initialScrollPendingRef = useRef(scrollSelectedPathIntoView); const treeHostRef = useRef(null); const paths = useMemo(() => files.map((file) => file.path), [files]); const filePathSet = useMemo(() => new Set(paths), [paths]); @@ -152,6 +153,10 @@ export function ReviewFileTree({ const selectedPaths = model.getSelectedPaths(); if (selectedPaths.length === 1 && selectedPaths[0] === selectedPath) { + if (initialScrollPendingRef.current) { + initialScrollPendingRef.current = false; + scrollPathIntoView(selectedPath); + } return; } @@ -159,10 +164,11 @@ export function ReviewFileTree({ model.getItem(path)?.deselect(); } model.getItem(selectedPath)?.select(); - if (scrollSelectedPathIntoView) { - requestAnimationFrame(() => scrollPathIntoView(selectedPath)); + if (initialScrollPendingRef.current) { + initialScrollPendingRef.current = false; + scrollPathIntoView(selectedPath); } - }, [model, scrollPathIntoView, scrollSelectedPathIntoView, selectedPath]); + }, [model, scrollPathIntoView, selectedPath]); const handleTreeClick = useCallback( (event: MouseEvent) => { diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index d797c464..6180bca3 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -85,6 +85,63 @@ export function RepositoryChangeBanner({ ); } +export type RepositoryRefreshStatus = + | { phase: 'complete'; updated: boolean } + | { phase: 'failed'; reason: string } + | { phase: 'refreshing' }; + +export function RepositoryRefreshBanner({ + onRestartWalkthrough, + onRetry, + status, + walkthroughStale, +}: { + onRestartWalkthrough: () => void; + onRetry: () => void; + status: RepositoryRefreshStatus | null; + walkthroughStale: boolean; +}) { + const visible = walkthroughStale || status != null; + const failed = status?.phase === 'failed'; + + return ( +
+ + {walkthroughStale ? ( + <> + Walkthrough out of date. + The reviewed code changed. + + ) : status?.phase === 'refreshing' ? ( + Refreshing reviewโ€ฆ + ) : status?.phase === 'complete' ? ( + {status.updated ? 'Review updated.' : 'Review is up to date.'} + ) : failed ? ( + <> + Refresh failed. + {status.reason} + + ) : null} + + {walkthroughStale ? ( + + ) : failed ? ( + + ) : null} +
+ ); +} + export type { CodiffUpdateStatus as UpdateStatus } from '../../types.ts'; export function UpdatePill({ @@ -382,11 +439,13 @@ export function DiffSearchPanel({ } export function CopyCommentsButton({ + actionLabel, comments, files, reviewCommentsPrefix, showWhitespace, }: { + actionLabel?: string; comments: ReadonlyArray; files: ReadonlyArray; reviewCommentsPrefix: string; @@ -415,16 +474,19 @@ export function CopyCommentsButton({ return ( - ) : navigation.mode === 'stop' && next ? ( - - ) : navigation.mode === 'stop' && supportAvailable ? ( - + ) : navigation.mode === 'stop' && next ? ( + + ) : navigation.mode === 'stop' && supportAvailable ? ( + - ) : navigation.mode === 'stop' && committable ? ( - + ) : navigation.mode === 'stop' && committable ? ( + - ) : navigation.mode === 'support' && committable ? ( - + ) : navigation.mode === 'support' && committable ? ( + - ) : navigation.mode === 'support' ? ( - + ) : navigation.mode === 'support' ? ( + - ) : null} -
+ + ) : null} +
+ ); } diff --git a/core/app/hooks/useAppCommands.ts b/core/app/hooks/useAppCommands.ts index d6173bb8..b2c0d9fa 100644 --- a/core/app/hooks/useAppCommands.ts +++ b/core/app/hooks/useAppCommands.ts @@ -1,20 +1,16 @@ import { useEffect, useRef, useState, type RefObject } from 'react'; -import type { ReviewComment, ReviewIdentity, SidebarMode } from '../../lib/app-types.ts'; +import type { ReviewIdentity, SidebarMode } from '../../lib/app-types.ts'; import { createCommandRegistry, type Command } from '../../lib/command-registry.ts'; import type { ReviewCommandTarget } from '../../lib/review-command-target.ts'; -import { buildReviewCommentsMarkdown } from '../../lib/review-comments.ts'; import { isReviewIdentityViewed } from '../../lib/review-identity.ts'; -import type { - ChangedFile, - CodiffPreferences, - OpenReviewSourceKind, - RepositoryState, -} from '../../types.ts'; +import type { ChangedFile, CodiffPreferences, OpenReviewSourceKind } from '../../types.ts'; type UseAppCommandsOptions = { changeSidebarMode: (mode: SidebarMode) => void; + copyPendingCommentsLabel: string; focusFileFilter: () => void; getReviewCommandTarget: () => ReviewCommandTarget | null; + onCopyPendingComments: () => string; onOpenDiffSearch: () => void; onOpenReviewSource: (kind: OpenReviewSourceKind) => void; onOpenSelectedFile: () => void; @@ -23,15 +19,15 @@ type UseAppCommandsOptions = { onToggleViewed: (file: ChangedFile, isViewed: boolean, reviewIdentity: ReviewIdentity) => void; onToggleWordWrap: () => void; preferencesRef: RefObject; - reviewCommentsRef: RefObject>; - stateRef: RefObject; viewedRef: RefObject>; }; export function useAppCommands({ changeSidebarMode, + copyPendingCommentsLabel, focusFileFilter, getReviewCommandTarget, + onCopyPendingComments, onOpenDiffSearch, onOpenReviewSource, onOpenSelectedFile, @@ -40,8 +36,6 @@ export function useAppCommands({ onToggleViewed, onToggleWordWrap, preferencesRef, - reviewCommentsRef, - stateRef, viewedRef, }: UseAppCommandsOptions) { const registryRef = useRef(createCommandRegistry()); @@ -101,37 +95,17 @@ export function useAppCommands({ }), registry.register({ execute: () => { - const currentState = stateRef.current; - if (!currentState) { - return; - } - - const markdown = buildReviewCommentsMarkdown( - currentState.files, - reviewCommentsRef.current, - preferencesRef.current.showWhitespace, - preferencesRef.current.reviewCommentsPrefix, - ); + const markdown = onCopyPendingComments(); if (markdown) { void navigator.clipboard.writeText(markdown); } }, id: 'copy-comments', - title: 'Copy Review Comments', + title: copyPendingCommentsLabel, }), registry.register({ execute: () => { - const currentState = stateRef.current; - if (!currentState) { - return; - } - - const markdown = buildReviewCommentsMarkdown( - currentState.files, - reviewCommentsRef.current, - preferencesRef.current.showWhitespace, - preferencesRef.current.reviewCommentsPrefix, - ); + const markdown = onCopyPendingComments(); if (markdown) { void navigator.clipboard.writeText(markdown).then(() => { window.close(); @@ -141,7 +115,7 @@ export function useAppCommands({ } }, id: 'copy-comments-and-close', - title: 'Copy Review Comments and Close', + title: `${copyPendingCommentsLabel} and Close`, }), registry.register({ description: () => getReviewCommandTarget()?.file.path ?? null, @@ -238,8 +212,10 @@ export function useAppCommands({ }; }, [ changeSidebarMode, + copyPendingCommentsLabel, focusFileFilter, getReviewCommandTarget, + onCopyPendingComments, onOpenDiffSearch, onOpenReviewSource, onOpenSelectedFile, @@ -248,8 +224,6 @@ export function useAppCommands({ onToggleViewed, onToggleWordWrap, preferencesRef, - reviewCommentsRef, - stateRef, viewedRef, ]); diff --git a/core/app/hooks/useAppReviewComments.ts b/core/app/hooks/useAppReviewComments.ts index 1c9ec630..70da3abd 100644 --- a/core/app/hooks/useAppReviewComments.ts +++ b/core/app/hooks/useAppReviewComments.ts @@ -1,47 +1,24 @@ import { useCallback, useState, type RefObject } from 'react'; import type { ReviewComment } from '../../lib/app-types.ts'; -import { - getPendingPullRequestReviewComments, - getReviewCommentRangeProps, - toPullRequestReviewComment, -} from '../../lib/review-comments.ts'; -import type { - PullRequestReviewEvent, - PullRequestReviewStatus, - RepositoryState, - ReviewAssistantRequest, -} from '../../types.ts'; +import { getReviewCommentRangeProps } from '../../lib/review-comments.ts'; +import type { RepositoryState, ReviewAssistantRequest } from '../../types.ts'; import { useReviewCommentDrafts } from './useReviewCommentDrafts.ts'; type UseAppReviewCommentsOptions = { - isReviewActionDisabled: ( - reviewStatus: PullRequestReviewStatus | undefined, - event: PullRequestReviewEvent, - ) => boolean; onCommentFileChange: (filePath: string) => void; stateRef: RefObject; }; export function useAppReviewComments({ - isReviewActionDisabled, onCommentFileChange, stateRef, }: UseAppReviewCommentsOptions) { const [reviewComments, setReviewComments] = useState>([]); - const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = - useState(null); const commentDrafts = useReviewCommentDrafts({ comments: reviewComments, onCommentFileChange, setComments: setReviewComments, }); - const { - activeReviewCommentDraftRef, - activeReviewCommentDraftState, - clearCommentFocus, - reviewCommentsRef, - updateActiveReviewCommentDraft, - } = commentDrafts; const updateCodexReply = useCallback( (commentId: string, filePath: string, codexReply: NonNullable) => { @@ -60,26 +37,6 @@ export function useAppReviewComments({ [onCommentFileChange], ); - const updateRemoteSubmit = useCallback( - (commentId: string, remoteSubmit: ReviewComment['remoteSubmit']) => { - setReviewComments((current) => - current.map((comment) => - comment.id === commentId - ? { - ...comment, - remoteSubmit, - } - : comment, - ), - ); - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if (comment) { - onCommentFileChange(comment.filePath); - } - }, - [onCommentFileChange, reviewCommentsRef], - ); - const askCodex = useCallback( (comment: ReviewComment) => { const currentState = stateRef.current; @@ -131,132 +88,10 @@ export function useAppReviewComments({ [stateRef, updateCodexReply], ); - const submitPullRequestComment = useCallback( - (commentId: string) => { - const currentState = stateRef.current; - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if ( - currentState?.source.type !== 'pull-request' || - !comment || - comment.body.trim().length === 0 || - comment.remoteSubmit?.status === 'submitting' - ) { - return; - } - - updateRemoteSubmit(comment.id, { status: 'submitting' }); - updateActiveReviewCommentDraft(null); - void window.codiff - .submitPullRequestComment({ - comment: toPullRequestReviewComment(comment), - source: currentState.source, - }) - .then((submittedComment) => { - clearCommentFocus(comment.id); - setReviewComments((current) => - current.map((candidate) => - candidate.id === comment.id - ? { - author: submittedComment.author, - body: submittedComment.body, - filePath: submittedComment.filePath, - id: submittedComment.id, - isReadOnly: true, - ...(submittedComment.anchor === 'file' ? { anchor: 'file' as const } : {}), - ...(submittedComment.lineNumber != null - ? { lineNumber: submittedComment.lineNumber } - : {}), - sectionId: comment.sectionId, - ...(submittedComment.side ? { side: submittedComment.side } : {}), - ...getReviewCommentRangeProps(submittedComment), - submittedAt: submittedComment.submittedAt, - ...(submittedComment.threadId ? { threadId: submittedComment.threadId } : {}), - url: submittedComment.url, - } - : candidate, - ), - ); - onCommentFileChange(comment.filePath); - }) - .catch((error: unknown) => { - updateRemoteSubmit(comment.id, { - error: error instanceof Error ? error.message : String(error), - status: 'error', - }); - }); - }, - [ - clearCommentFocus, - onCommentFileChange, - reviewCommentsRef, - stateRef, - updateActiveReviewCommentDraft, - updateRemoteSubmit, - ], - ); - - const submitPullRequestReview = useCallback( - (event: PullRequestReviewEvent, body?: string) => { - const currentState = stateRef.current; - if ( - currentState?.source.type !== 'pull-request' || - pullRequestReviewSubmitting || - isReviewActionDisabled(currentState.source.reviewStatus, event) - ) { - return; - } - - const pendingComments = getPendingPullRequestReviewComments( - reviewCommentsRef.current, - activeReviewCommentDraftRef.current, - ); - if (event === 'COMMENT' && pendingComments.length === 0 && !body?.trim()) { - return; - } - const pendingCommentIds = new Set(pendingComments.map((comment) => comment.id)); - setPullRequestReviewSubmitting(event); - return window.codiff - .submitPullRequestReview({ - ...(body ? { body } : {}), - comments: pendingComments.map((comment) => toPullRequestReviewComment(comment)), - event, - source: currentState.source, - }) - .then(() => { - updateActiveReviewCommentDraft(null); - setReviewComments((current) => - current.filter((comment) => !pendingCommentIds.has(comment.id)), - ); - }) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - throw error; - }) - .finally(() => { - setPullRequestReviewSubmitting(null); - }); - }, - [ - activeReviewCommentDraftRef, - isReviewActionDisabled, - pullRequestReviewSubmitting, - reviewCommentsRef, - stateRef, - updateActiveReviewCommentDraft, - ], - ); - - const hasPendingReviewComments = - getPendingPullRequestReviewComments(reviewComments, activeReviewCommentDraftState).length > 0; - return { ...commentDrafts, askCodex, - hasPendingReviewComments, - pullRequestReviewSubmitting, reviewComments, setReviewComments, - submitPullRequestComment, - submitPullRequestReview, }; } diff --git a/core/app/hooks/useAppWalkthrough.ts b/core/app/hooks/useAppWalkthrough.ts index 41cb5ffe..62e905ca 100644 --- a/core/app/hooks/useAppWalkthrough.ts +++ b/core/app/hooks/useAppWalkthrough.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; import type { SidebarMode, WalkthroughError } from '../../lib/app-types.ts'; import { buildCommitModel, buildGenericCommitModel } from '../../lib/narrative-walkthrough.ts'; +import type { ReloadMainMode } from '../../lib/reload-selection.ts'; import { createReviewCommandTarget, type ReviewCommandTarget, @@ -10,6 +11,7 @@ import type { ChangedFile, CodiffPreferences, NarrativeWalkthrough, + NarrativeWalkthroughResult, NarrativeWalkthroughRequestOptions, RepositoryState, SharedWalkthroughSnapshot, @@ -22,9 +24,12 @@ import { useNarrativeNavigation } from '../components/walkthrough/useNarrativeNa import { nextWalkthroughResponseLabelIndex } from '../components/walkthrough/WalkthroughProgress.tsx'; import type { WalkthroughFileError } from '../components/WalkthroughFileError.tsx'; -type MainMode = 'commit' | 'review'; - type UseAppWalkthroughOptions = { + initialMainMode?: ReloadMainMode; + initialSidebarMode?: SidebarMode; + initialWalkthroughFileError?: WalkthroughFileError | null; + initialWalkthroughLoading?: boolean; + initialWalkthroughResult?: NarrativeWalkthroughResult; preferencesRef: RefObject; state: RepositoryState | null; stateGenerationRef: RefObject; @@ -34,22 +39,31 @@ type UseAppWalkthroughOptions = { const emptyFiles: ReadonlyArray = []; export function useAppWalkthrough({ + initialMainMode = 'review', + initialSidebarMode = 'tree', + initialWalkthroughFileError = null, + initialWalkthroughLoading = false, + initialWalkthroughResult, preferencesRef, state, stateGenerationRef, stateRef, }: UseAppWalkthroughOptions) { - const [mainMode, setMainMode] = useState('review'); + const [mainMode, setMainMode] = useState(initialMainMode); + const initialWalkthrough = + initialWalkthroughResult?.status === 'ready' ? initialWalkthroughResult.walkthrough : null; const [narrativeWalkthrough, setNarrativeWalkthrough] = useState( - null, + initialWalkthrough, ); const [shareWalkthroughEnabled, setShareWalkthroughEnabled] = useState(false); - const [sidebarMode, setSidebarMode] = useState('tree'); - const [walkthroughError, setWalkthroughError] = useState(null); + const [sidebarMode, setSidebarMode] = useState(initialSidebarMode); + const [walkthroughError, setWalkthroughError] = useState(() => + initialWalkthroughResult?.status === 'unavailable' ? initialWalkthroughResult : null, + ); const [walkthroughFileError, setWalkthroughFileError] = useState( - null, + initialWalkthroughFileError, ); - const [walkthroughLoading, setWalkthroughLoading] = useState(false); + const [walkthroughLoading, setWalkthroughLoadingState] = useState(initialWalkthroughLoading); const [walkthroughProgress, setWalkthroughProgress] = useState<{ phase: WalkthroughProgressEvent['phase'] | null; responseLabelIndex: number; @@ -58,10 +72,11 @@ export function useAppWalkthrough({ const [walkthroughSharing, setWalkthroughSharing] = useState(false); const [walkthroughUnread, setWalkthroughUnread] = useState(false); const activeReviewCommandTargetRef = useRef(null); - const mainModeRef = useRef('review'); - const narrativeWalkthroughRef = useRef(null); - const sidebarModeRef = useRef('tree'); - const walkthroughErrorRef = useRef(null); + const mainModeRef = useRef(initialMainMode); + const narrativeWalkthroughRef = useRef(narrativeWalkthrough); + const sidebarModeRef = useRef(initialSidebarMode); + const walkthroughErrorRef = useRef(walkthroughError); + const walkthroughLoadingRef = useRef(initialWalkthroughLoading); const walkthroughRequestRef = useRef(0); const navigationResetKey = state ? `${state.root}:${getSourceKey(state.source)}` : ''; const narrativeNavigation = useNarrativeNavigation( @@ -112,9 +127,27 @@ export function useAppWalkthrough({ responseLabelIndex: nextWalkthroughResponseLabelIndex(current.responseLabelIndex), stageRevision: current.stageRevision + 1, })); - setWalkthroughLoading(true); + walkthroughLoadingRef.current = true; + setWalkthroughLoadingState(true); + }, []); + + const setWalkthroughLoading = useCallback((loading: boolean) => { + walkthroughLoadingRef.current = loading; + setWalkthroughLoadingState(loading); + if (!loading) { + setWalkthroughProgress((current) => + current.phase == null + ? current + : { ...current, phase: null, stageRevision: current.stageRevision + 1 }, + ); + } }, []); + const cancelWalkthroughRequest = useCallback(() => { + walkthroughRequestRef.current += 1; + setWalkthroughLoading(false); + }, [setWalkthroughLoading]); + const commitWalkthrough = useCallback( (request: WalkthroughCommitRequest) => window.codiff.createWalkthroughCommit({ @@ -150,7 +183,7 @@ export function useAppWalkthrough({ getSourceKey(stateRef.current?.source ?? source) === sourceKey; startWalkthroughLoading(); setWalkthroughError(null); - window.codiff + return window.codiff .getNarrativeWalkthrough(source, options) .then((result) => { if (!isCurrentState()) { @@ -160,7 +193,7 @@ export function useAppWalkthrough({ if (result.status === 'ready') { setNarrativeWalkthrough(result.walkthrough); if (sidebarModeRef.current === 'walkthrough') { - setSidebarMode('walkthrough'); + setWalkthroughUnread(false); } else { setWalkthroughUnread(true); } @@ -179,12 +212,15 @@ export function useAppWalkthrough({ }); }) .finally(() => { + // A repository refresh can advance the state generation without + // cancelling this request. Reject its old-code result above, but let + // the request that still owns the loading slot clear it on completion. if (walkthroughRequestRef.current === request) { setWalkthroughLoading(false); } }); }, - [startWalkthroughLoading, stateGenerationRef, stateRef], + [setWalkthroughLoading, startWalkthroughLoading, stateGenerationRef, stateRef], ); const refreshWalkthroughForState = useCallback( @@ -192,7 +228,11 @@ export function useAppWalkthrough({ nextState: RepositoryState, previousWalkthrough: NarrativeWalkthrough | null = narrativeWalkthroughRef.current, ) => { - if (sidebarModeRef.current !== 'walkthrough' && previousWalkthrough == null) { + if ( + sidebarModeRef.current !== 'walkthrough' && + previousWalkthrough == null && + !walkthroughLoadingRef.current + ) { return; } @@ -209,7 +249,7 @@ export function useAppWalkthrough({ previousWalkthrough: previousWalkthrough ?? undefined, }); }, - [loadNarrativeWalkthrough], + [loadNarrativeWalkthrough, setWalkthroughLoading], ); const changeSidebarMode = useCallback( @@ -239,7 +279,14 @@ export function useAppWalkthrough({ loadNarrativeWalkthrough(state.source); }, - [loadNarrativeWalkthrough, narrativeWalkthrough, state, walkthroughError, walkthroughLoading], + [ + loadNarrativeWalkthrough, + narrativeWalkthrough, + setWalkthroughLoading, + state, + walkthroughError, + walkthroughLoading, + ], ); const openCommitView = useCallback(() => { @@ -335,10 +382,12 @@ export function useAppWalkthrough({ return { activeReviewCommandTargetRef, + cancelWalkthroughRequest, changeSidebarMode, closeCommitView, commitWalkthrough, enabledShareWalkthrough: shareWalkthroughEnabled ? shareWalkthrough : undefined, + loadNarrativeWalkthrough, mainModeRef, narrativeNavigation, narrativeWalkthrough, diff --git a/core/global.d.ts b/core/global.d.ts index 1253b3c4..d72a6e5b 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -50,6 +50,7 @@ declare global { codiff: { applyUpdate: () => Promise; askReviewAssistant: (request: ReviewAssistantRequest) => Promise; + cancelDiffContentRequest: (requestId: string) => void; completePlan: (review: PlanReview, status: PlanHandoffStatus) => Promise; createWalkthroughCommit: ( request: WalkthroughCommitRequest, diff --git a/core/lib/repository-refresh.ts b/core/lib/repository-refresh.ts new file mode 100644 index 00000000..eb939915 --- /dev/null +++ b/core/lib/repository-refresh.ts @@ -0,0 +1,106 @@ +import type { RepositoryState, ReviewSource } from '../types.ts'; +import type { ReloadMainMode } from './reload-selection.ts'; +import { getHistorySource } from './source.ts'; + +type RefreshFile = RepositoryState['files'][number]; + +const getSectionCodeIdentity = (section: RefreshFile['sections'][number]) => { + if (section.patch) { + return `patch\0${section.patch}`; + } + + if (section.summary?.fingerprint) { + return `blob\0${section.summary.fingerprint}`; + } + + if (section.oldFile || section.newFile) { + return `contents\0${section.oldFile?.contents ?? ''}\0${section.newFile?.contents ?? ''}`; + } + + return 'unavailable'; +}; + +const getFileCodeIdentity = (file: RefreshFile) => + [ + file.path, + file.oldPath ?? '', + file.status, + ...file.sections.map(getSectionCodeIdentity).toSorted(), + ].join('\0'); + +const getFilesByPath = (files: RepositoryState['files']) => + new Map(files.map((file) => [file.path, getFileCodeIdentity(file)])); + +const getReviewedCodeChangedPaths = ( + previousFiles: RepositoryState['files'], + nextFiles: RepositoryState['files'], +) => { + const previousByPath = getFilesByPath(previousFiles); + const changedPaths = new Set(); + for (const file of nextFiles) { + if (previousByPath.get(file.path) !== getFileCodeIdentity(file)) { + changedPaths.add(file.path); + } + } + return changedPaths; +}; + +export const hasReviewedCodeChanged = ( + previousFiles: RepositoryState['files'], + nextFiles: RepositoryState['files'], +) => { + if (previousFiles.length !== nextFiles.length) { + return true; + } + + const previousIdentities = previousFiles.map(getFileCodeIdentity).toSorted(); + const nextIdentities = nextFiles.map(getFileCodeIdentity).toSorted(); + return previousIdentities.some((identity, index) => identity !== nextIdentities[index]); +}; + +export type RepositoryRefreshReconciliation = { + changedPaths: ReadonlySet; + collapsed: Set; + historySource: ReviewSource | null; + mainMode: ReloadMainMode; + selectedPath: string | null; + walkthroughNeedsRefresh: boolean; +}; + +export const reconcileRepositoryRefresh = ({ + collapsed, + historySource, + mainMode, + nextState, + previousState, + selectedPath, +}: { + collapsed: ReadonlySet; + historySource: ReviewSource | null; + mainMode: ReloadMainMode; + nextState: RepositoryState; + previousState: RepositoryState; + selectedPath: string | null; +}): RepositoryRefreshReconciliation => { + const changedPaths = getReviewedCodeChangedPaths(previousState.files, nextState.files); + const nextCollapsed = new Set(collapsed); + for (const path of changedPaths) { + nextCollapsed.delete(path); + } + + return { + changedPaths, + collapsed: nextCollapsed, + historySource: getHistorySource(nextState.source) ?? historySource, + mainMode: + mainMode === 'commit' && + (nextState.source.type !== 'working-tree' || nextState.files.length === 0) + ? 'review' + : mainMode, + selectedPath: + selectedPath != null && nextState.files.some((file) => file.path === selectedPath) + ? selectedPath + : (nextState.files[0]?.path ?? null), + walkthroughNeedsRefresh: hasReviewedCodeChanged(previousState.files, nextState.files), + }; +}; diff --git a/core/lib/repository-review-bootstrap.ts b/core/lib/repository-review-bootstrap.ts new file mode 100644 index 00000000..b5e108a3 --- /dev/null +++ b/core/lib/repository-review-bootstrap.ts @@ -0,0 +1,91 @@ +import type { + CodiffLaunchOptions, + RepositoryState, + ResolvedReviewSource, + ReviewSource, +} from '../types.ts'; +import type { ReviewScrollTarget, SidebarMode } from './app-types.ts'; +import { + getReloadDeltaPaths, + getReloadHistorySource, + getReloadMainMode, + getReloadSelectionPath, + haveReloadedFilesChanged, + type ReloadMainMode, + type ReloadSelection, +} from './reload-selection.ts'; +import { + getHistorySource, + getRefreshSource, + getSourceKey, + shouldStartInHistoryWhenEmpty, +} from './source.ts'; + +export type RepositoryReviewBootstrap = { + forceInitialWalkthrough: boolean; + historySource: ReviewSource | null; + initialScrollTarget: ReviewScrollTarget | null; + mainMode: ReloadMainMode; + reloadDeltaPaths: ReadonlySet; + selectedPath: string | null; + sidebarMode: SidebarMode; + source: ResolvedReviewSource; + state: RepositoryState; +}; + +export const resolveReloadSourceForLaunch = ( + reloadSelection: ReloadSelection | null, + launchOptions: CodiffLaunchOptions, +): ReviewSource | undefined => { + if (!reloadSelection) { + return undefined; + } + if (!launchOptions.source) { + return getRefreshSource(reloadSelection.source); + } + return getSourceKey(reloadSelection.source) === getSourceKey(launchOptions.source) + ? getRefreshSource(reloadSelection.source) + : undefined; +}; + +export const resolveRepositoryReviewBootstrap = ({ + launchOptions, + reloadSelection, + state, +}: { + launchOptions: CodiffLaunchOptions; + reloadSelection: ReloadSelection | null; + state: RepositoryState; +}): RepositoryReviewBootstrap => { + const restoredSelectedPath = getReloadSelectionPath(reloadSelection, state); + const selectedPath = restoredSelectedPath ?? state.files[0]?.path ?? null; + const requestedMainMode = getReloadMainMode(reloadSelection, state); + const mainMode: ReloadMainMode = + requestedMainMode === 'commit' && state.source.type === 'working-tree' && state.files.length > 0 + ? 'commit' + : 'review'; + const walkthroughRequested = Boolean(launchOptions.walkthrough || launchOptions.walkthroughFile); + const sidebarMode: SidebarMode = walkthroughRequested + ? 'walkthrough' + : shouldStartInHistoryWhenEmpty(state.source) && state.files.length === 0 + ? 'history' + : 'tree'; + + return { + forceInitialWalkthrough: + walkthroughRequested && + !launchOptions.walkthroughFile && + haveReloadedFilesChanged(reloadSelection, state), + historySource: + getReloadHistorySource(reloadSelection, state) ?? getHistorySource(state.source) ?? null, + initialScrollTarget: restoredSelectedPath + ? { behavior: 'instant', path: restoredSelectedPath, request: 1 } + : null, + mainMode, + reloadDeltaPaths: getReloadDeltaPaths(reloadSelection, state), + selectedPath, + sidebarMode, + source: state.source, + state, + }; +}; diff --git a/core/types/review-identity.ts b/core/types/review-identity.ts index 0e5155be..7c2c6f15 100644 --- a/core/types/review-identity.ts +++ b/core/types/review-identity.ts @@ -142,6 +142,7 @@ export type DiffSectionContentRequest = { force?: boolean; kind: DiffSection['kind']; path: string; + requestId?: string; showWhitespace?: boolean; source?: ResolvedReviewSource; }; @@ -160,7 +161,7 @@ export type DefinitionSearchRequest = { lineNumber: number; path: string; side: 'additions' | 'deletions'; - source: ReviewSource; + source: ResolvedReviewSource; }; export type DefinitionCandidate = { @@ -186,6 +187,7 @@ export type DefinitionSearchResult = export type DiffImageContentRequest = { kind: DiffSection['kind']; path: string; + requestId?: string; source?: ResolvedReviewSource; }; diff --git a/electron/__tests__/definition-search.test.ts b/electron/__tests__/definition-search.test.ts index 4eb0ca48..d0deeda7 100644 --- a/electron/__tests__/definition-search.test.ts +++ b/electron/__tests__/definition-search.test.ts @@ -86,7 +86,7 @@ test('marks historical snapshot candidates as unsafe for editor fallback', async const result = await findDefinitions(directory.path, { ...request, kind: 'commit', - source: { ref: 'HEAD', type: 'commit' }, + source: { sha: 'HEAD' as import('../../core/types.ts').GitSha, type: 'commit' }, }); expect(result.status).toBe('ready'); diff --git a/electron/__tests__/diff-content-cancellation.test.ts b/electron/__tests__/diff-content-cancellation.test.ts new file mode 100644 index 00000000..76c8e699 --- /dev/null +++ b/electron/__tests__/diff-content-cancellation.test.ts @@ -0,0 +1,56 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { git, runWithCommandSignal } = require('../git-state/common.cjs') as { + git: (repoPath: string, args: ReadonlyArray) => Promise; + runWithCommandSignal: (signal: AbortSignal, callback: () => Value) => Value; +}; + +const waitForFile = async (path: string) => { + for (let index = 0; index < 100; index += 1) { + try { + return await readFile(path, 'utf8'); + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${path}.`); +}; + +test('diff-content cancellation terminates the underlying Git command', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-diff-cancel-')); + const command = join(directory, 'git'); + const pidPath = join(directory, 'pid'); + const signalPath = join(directory, 'signal'); + const previousPath = process.env.PATH; + try { + await writeFile( + command, + `#!/usr/bin/env node +const { writeFileSync } = require('node:fs'); +writeFileSync(${JSON.stringify(pidPath)}, String(process.pid)); +process.on('SIGTERM', () => { + writeFileSync(${JSON.stringify(signalPath)}, 'SIGTERM'); + process.exit(0); +}); +setInterval(() => {}, 1000); +`, + ); + await chmod(command, 0o755); + process.env.PATH = `${directory}:${previousPath ?? ''}`; + const controller = new AbortController(); + const pending = runWithCommandSignal(controller.signal, () => git(directory, ['status'])); + await waitForFile(pidPath); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + await expect(waitForFile(signalPath)).resolves.toBe('SIGTERM'); + } finally { + process.env.PATH = previousPath; + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/electron/__tests__/provider-review-state.test.ts b/electron/__tests__/provider-review-state.test.ts index beabc20f..b8a82153 100644 --- a/electron/__tests__/provider-review-state.test.ts +++ b/electron/__tests__/provider-review-state.test.ts @@ -20,6 +20,7 @@ const { rangeArtifactToPullRequestFiles } = require('../git-state/review-range-s rangeArtifactToPullRequestFiles: ( artifact: import('../../core/index.ts').RangeArtifact, number: number, + options?: { deferContents?: boolean }, ) => ReadonlyArray; }; @@ -126,6 +127,42 @@ test('keeps a complete empty Range Artifact empty', () => { expect(files).toEqual([]); }); +test('carries immutable blob identity when a provider omits patch material', () => { + const artifact = { + baseSha: 'a'.repeat(40) as import('../../core/types.ts').GitSha, + coverage: 'complete' as const, + files: [ + { + coverage: 'opaque' as const, + newObjectId: '2'.repeat(40), + oldObjectId: '1'.repeat(40), + path: 'src/deferred.ts', + status: 'modified' as const, + }, + ], + headSha: 'b'.repeat(40) as import('../../core/types.ts').GitSha, + provenance: { + kind: 'github-api' as const, + project: { + host: 'github.com', + project: 'example/repo', + provider: 'github' as const, + }, + }, + }; + const first = rangeArtifactToPullRequestFiles(artifact, 7, { deferContents: true }); + const rebased = rangeArtifactToPullRequestFiles( + { ...artifact, baseSha: 'c'.repeat(40) as import('../../core/types.ts').GitSha }, + 7, + { deferContents: true }, + ); + + expect(first[0].sections[0].summary?.fingerprint).toBeTruthy(); + expect(rebased[0].sections[0].summary?.fingerprint).toBe( + first[0].sections[0].summary?.fingerprint, + ); +}); + test('renders a visible unavailable item for a wholly truncated Range Artifact', () => { const files = rangeArtifactToPullRequestFiles( { diff --git a/electron/__tests__/window-identity.test.ts b/electron/__tests__/window-identity.test.ts index 91e967e4..8ed911bf 100644 --- a/electron/__tests__/window-identity.test.ts +++ b/electron/__tests__/window-identity.test.ts @@ -11,41 +11,57 @@ import { } from '../../core/__tests__/helpers/resources.ts'; const require = createRequire(import.meta.url); -const { findMatchingWindowIdentity, getWindowIdentity, getWindowIdentityForRepositoryState } = - require('../window-identity.cjs') as { - findMatchingWindowIdentity: ( - identity: { key: string } | null, - existingIdentities: ReadonlyMap, - ) => number | null; - getWindowIdentity: ( - repositoryPath: string, - launchOptions?: { - source?: - | { type: 'working-tree' } - | { ref: string; type: 'branch' } - | { baseSha: string; headSha: string; ref: string; type: 'branch-diff' } - | { ref: string; type: 'commit' } - | { - number?: number; - owner?: string; - repo?: string; - type: 'pull-request'; - url: string; - }; - walkthrough?: boolean; - walkthroughFile?: string; - planFile?: string; - planResultFile?: string; - }, - ) => { key: string; repositoryRoot: string; sourceKey: string } | null; - getWindowIdentityForRepositoryState: (state: { - root: string; - source: +const { + findMatchingWindowIdentity, + getWindowIdentity, + getWindowIdentityForRepositoryState, + storeResolvedWindowState, +} = require('../window-identity.cjs') as { + findMatchingWindowIdentity: ( + identity: { key: string } | null, + existingIdentities: ReadonlyMap, + ) => number | null; + getWindowIdentity: ( + repositoryPath: string, + launchOptions?: { + source?: | { type: 'working-tree' } - | { sha: string; type: 'commit' } - | { baseSha: string; headSha: string; ref: string; type: 'branch-diff' }; - }) => { key: string; repositoryRoot: string; sourceKey: string } | null; - }; + | { ref: string; type: 'branch' } + | { baseSha: string; headSha: string; ref: string; type: 'branch-diff' } + | { ref: string; type: 'commit' } + | { + number?: number; + owner?: string; + repo?: string; + type: 'pull-request'; + url: string; + }; + walkthrough?: boolean; + walkthroughFile?: string; + planFile?: string; + planResultFile?: string; + }, + ) => { key: string; repositoryRoot: string; sourceKey: string } | null; + getWindowIdentityForRepositoryState: (state: { + root: string; + source: + | { type: 'working-tree' } + | { sha: string; type: 'commit' } + | { baseSha: string; headSha: string; ref: string; type: 'branch-diff' }; + }) => { key: string; repositoryRoot: string; sourceKey: string } | null; + storeResolvedWindowState: ( + webContentsId: number, + state: { root: string; source: { type: 'working-tree' } }, + stores: { + identities: Map; + launchOptions: Map< + number, + { repositoryPathProvided: boolean; source?: { type: 'working-tree' }; walkthrough: boolean } + >; + repositories: Map; + }, + ) => { key: string; repositoryRoot: string; sourceKey: string } | null; +}; const execFileAsync = promisify(execFile); @@ -282,3 +298,49 @@ test('window identity matching requires exact identity matches', () => { null, ); }); + +test('retargeting one viewport allows duplicate working-tree identities', () => { + const existingWorkingTreeIdentity = { + key: '/repo\0working-tree', + repositoryRoot: '/repo', + sourceKey: 'working-tree', + }; + const identities = new Map([ + [ + 1, + { + key: '/repo\0pull-request:example/repo#12', + repositoryRoot: '/repo', + sourceKey: 'pull-request:example/repo#12', + }, + ], + [2, existingWorkingTreeIdentity], + ]); + const launchOptions = new Map([ + [1, { repositoryPathProvided: true, walkthrough: false }], + [ + 2, + { + repositoryPathProvided: true, + source: { type: 'working-tree' as const }, + walkthrough: false, + }, + ], + ]); + const repositories = new Map([ + [1, '/repo'], + [2, '/repo'], + ]); + + const identity = storeResolvedWindowState( + 1, + { root: '/repo', source: { type: 'working-tree' } }, + { identities, launchOptions, repositories }, + ); + + expect(identity?.sourceKey).toBe('working-tree'); + expect(identities.get(1)?.sourceKey).toBe('working-tree'); + expect(identities.get(2)).toBe(existingWorkingTreeIdentity); + expect(launchOptions.get(1)?.source).toEqual({ type: 'working-tree' }); + expect(findMatchingWindowIdentity(identity, identities)).toBe(1); +}); diff --git a/electron/definition-search.cjs b/electron/definition-search.cjs index 648fbb23..a2ce282a 100644 --- a/electron/definition-search.cjs +++ b/electron/definition-search.cjs @@ -301,21 +301,18 @@ const resolveSearchRevision = async (request, repoPath) => { let base = null; let mergeBase = false; if (source.type === 'commit') { - head = source.ref; - base = `${source.ref}^`; + head = source.sha; + base = `${source.sha}^`; } else if (source.type === 'range') { head = source.head; base = source.base; mergeBase = source.symmetric; } else if (source.type === 'branch-diff') { - head = source.headRef; - base = source.baseRef; - } else if (source.type === 'branch-working-tree' && source.headRef) { - head = source.headRef; - base = source.baseRef || `${source.headRef}^`; - } else if (source.type === 'branch') { - head = source.ref; - base = `${source.ref}^`; + head = source.headSha; + base = source.baseSha; + } else if (source.type === 'branch-working-tree' && source.headSha) { + head = source.headSha; + base = source.baseSha || `${source.headSha}^`; } else if (source.type === 'pull-request' && source.number != null) { const namespace = source.provider === 'gitlab' ? 'merge-requests' : 'pull-requests'; head = `refs/codiff/${namespace}/${source.number}/head`; diff --git a/electron/git-state.cjs b/electron/git-state.cjs index 9153b9d2..8e1200eb 100644 --- a/electron/git-state.cjs +++ b/electron/git-state.cjs @@ -1,6 +1,12 @@ // @ts-check -const { git, gitOrEmpty, parseStatus, validateRepositoryPath } = require('./git-state/common.cjs'); +const { + git, + gitOrEmpty, + parseStatus, + runWithCommandSignal, + validateRepositoryPath, +} = require('./git-state/common.cjs'); const { listRepositoryHistory, readBranchImageContent, @@ -306,6 +312,7 @@ module.exports = { readWalkthroughRepositoryState, readWorkingTreeState, resolvePullRequestContentRefs, + runWithCommandSignal, submitPullRequestComment: (launchPath, request) => (isGitLabReviewSource(request.source) ? submitMergeRequestComment : submitPullRequestComment)( launchPath, diff --git a/electron/git-state/commit.cjs b/electron/git-state/commit.cjs index 48da1275..7172aeac 100644 --- a/electron/git-state/commit.cjs +++ b/electron/git-state/commit.cjs @@ -9,7 +9,7 @@ const { const { readCommitMetadataForCommit } = require('./commit-metadata.cjs'); const { applyGeneratedAttributeStates, - readGeneratedAttributeStates, + readRevisionGeneratedAttributeStates, } = require('../generated-files.cjs'); const { readDiffImageContent: readWorkingTreeDiffImageContent, @@ -349,10 +349,13 @@ const readResolvedComparisonState = (launchPath, comparison) => /** @param {ResolvedComparison} comparison */ const readComparisonGeneratedAttributeStates = (comparison) => - readGeneratedAttributeStates( + readRevisionGeneratedAttributeStates( comparison.repoRoot, comparison.status.map((file) => file.path), - comparison.newSha, + { + label: { kind: 'commit', text: comparison.newSha }, + sha: comparison.newSha, + }, ); /** @param {string} launchPath @param {ComparisonSource} source @returns {Promise} */ diff --git a/electron/git-state/common.cjs b/electron/git-state/common.cjs index a8007b1b..f2c2fa8d 100644 --- a/electron/git-state/common.cjs +++ b/electron/git-state/common.cjs @@ -1,5 +1,6 @@ // @ts-check +const { AsyncLocalStorage } = require('node:async_hooks'); const { execFile, spawn } = require('node:child_process'); const { promises: fs } = require('node:fs'); const { createHash } = require('node:crypto'); @@ -7,6 +8,12 @@ const { isAbsolute, join, normalize, sep } = require('node:path'); const { promisify } = require('node:util'); const execFileAsync = promisify(execFile); +const commandSignalStorage = new AsyncLocalStorage(); + +const getCurrentCommandSignal = () => commandSignalStorage.getStore(); + +/** @template Value @param {AbortSignal} signal @param {() => Value} callback */ +const runWithCommandSignal = (signal, callback) => commandSignalStorage.run(signal, callback); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile @@ -52,22 +59,24 @@ const getGravatarHash = (email) => /** * @param {string} repoPath * @param {ReadonlyArray} args - * @param {{encoding?: BufferEncoding}} [options] + * @param {{encoding?: BufferEncoding, signal?: AbortSignal}} [options] * @returns {Promise} */ const git = async (repoPath, args, options = {}) => { const { stdout } = await execFileAsync('git', ['-C', repoPath, ...args], { encoding: options.encoding || 'utf8', maxBuffer: 1024 * 1024 * 64, + signal: options.signal ?? getCurrentCommandSignal(), }); return stdout; }; -/** @param {string} repoPath @param {ReadonlyArray} args @returns {Promise} */ -const gitBuffer = async (repoPath, args) => { +/** @param {string} repoPath @param {ReadonlyArray} args @param {{signal?: AbortSignal}} [options] @returns {Promise} */ +const gitBuffer = async (repoPath, args, options = {}) => { const { stdout } = await execFileAsync('git', ['-C', repoPath, ...args], { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, + signal: options.signal ?? getCurrentCommandSignal(), }); return stdout; }; @@ -76,13 +85,14 @@ const gitBuffer = async (repoPath, args) => { * @param {string} repoPath * @param {ReadonlyArray} args * @param {string | Buffer} input - * @param {{env?: NodeJS.ProcessEnv}} [options] + * @param {{env?: NodeJS.ProcessEnv, signal?: AbortSignal}} [options] * @returns {Promise} */ const gitBufferWithInput = (repoPath, args, input, options = {}) => new Promise((resolve, reject) => { const child = spawn('git', ['-C', repoPath, ...args], { env: options.env, + signal: options.signal ?? getCurrentCommandSignal(), stdio: ['pipe', 'pipe', 'pipe'], }); /** @type {Array} */ @@ -850,7 +860,13 @@ const normalizeStatus = (statusCode) => const gitOrEmpty = async (repoRoot, args) => { try { return await git(repoRoot, args); - } catch { + } catch (error) { + if ( + getCurrentCommandSignal()?.aborted || + (error instanceof Error && error.name === 'AbortError') + ) { + throw error; + } return ''; } }; @@ -868,6 +884,7 @@ module.exports = { formatBytes, generatedDirectoryPathspecExcludes, generatedDirectoryPathspecs, + getCurrentCommandSignal, getFingerprint, getGravatarHash, getImageMimeType, @@ -882,6 +899,7 @@ module.exports = { readGitImageFile, readIndexImageFile, readWorkingTreeImageFile, + runWithCommandSignal, summarizeContent, validateRepositoryPath, }; diff --git a/electron/git-state/github-history/gh-github-transport.cjs b/electron/git-state/github-history/gh-github-transport.cjs index 03ea7af1..7361caf3 100644 --- a/electron/git-state/github-history/gh-github-transport.cjs +++ b/electron/git-state/github-history/gh-github-transport.cjs @@ -11,6 +11,7 @@ const { homedir } = require('node:os'); const { join } = require('node:path'); const { findExecutableOnPath, isExecutableFile } = require('../../agent-shared.cjs'); const { getCommandEnvironment } = require('../../login-shell-environment.cjs'); +const { getCurrentCommandSignal } = require('../common.cjs'); const GH_NOT_FOUND_CODE = 'GH_NOT_FOUND'; const GH_NOT_FOUND_MESSAGE = @@ -185,7 +186,7 @@ const runGhApiBuffer = async (repoRoot, args, input, options = {}) => { const child = spawn(command, ['api', ...args], { cwd: repoRoot, env: environment, - signal: options.signal, + signal: options.signal ?? getCurrentCommandSignal(), stdio: ['pipe', 'pipe', 'pipe'], }); /** @type {Array} */ @@ -353,17 +354,18 @@ const createGhGitHubTransport = ({ repoRoot }) => { */ const readApiBuffer = async (args, input, options) => { const maxBytes = normalizeMaxBytes(options.maxBytes); + const signal = options.signal ?? getCurrentCommandSignal(); const bytes = options.sharedKey ? await readSharedGet(options.sharedKey, maxBytes, ({ getMaxBytes, onOutputLimit }) => runGhApiBuffer(repoRoot, args, input, { getMaxBytes, onOutputLimit, - signal: options.signal, + signal, }), ) : await runGhApiBuffer(repoRoot, args, input, { maxBytes, - signal: options.signal, + signal, }); return enforceOutputLimit(bytes, maxBytes); }; @@ -402,6 +404,7 @@ const createGhGitHubTransport = ({ repoRoot }) => { * }} request */ const requestText = async (request) => { + const signal = request.signal ?? getCurrentCommandSignal(); /** @type {Array} */ const args = []; if (request.paginate) { @@ -418,14 +421,14 @@ const createGhGitHubTransport = ({ repoRoot }) => { args.push('--input', '-'); } const sharedKey = - request.body == null && (!request.method || request.method === 'GET') && !request.signal + request.body == null && (!request.method || request.method === 'GET') && !signal ? `${repositoryIdentity}\0text\0${request.paginate ? 'paginate' : 'single'}\0${request.accept || ''}\0${appendQuery(request.path, request.paginate ? withoutPageSize(request.query) : request.query)}` : undefined; return ( await readApiBuffer(args, request.body, { maxBytes: request.maxBytes, sharedKey, - signal: request.signal, + signal, }) ).toString('utf8'); }; @@ -459,6 +462,7 @@ const createGhGitHubTransport = ({ repoRoot }) => { return /** @type {T} */ (JSON.parse(text)); }, async requestBuffer(request) { + const signal = request.signal ?? getCurrentCommandSignal(); /** @type {Array} */ const args = []; if (request.accept) { @@ -467,10 +471,10 @@ const createGhGitHubTransport = ({ repoRoot }) => { args.push(appendQuery(request.path, request.query)); return readApiBuffer(args, undefined, { maxBytes: request.maxBytes, - sharedKey: !request.signal + sharedKey: !signal ? `${repositoryIdentity}\0buffer\0single\0${request.accept || ''}\0${appendQuery(request.path, request.query)}` : undefined, - signal: request.signal, + signal, }); }, requestText, diff --git a/electron/git-state/glab-gitlab-transport.cjs b/electron/git-state/glab-gitlab-transport.cjs index b337e1b6..a1e5bddb 100644 --- a/electron/git-state/glab-gitlab-transport.cjs +++ b/electron/git-state/glab-gitlab-transport.cjs @@ -10,6 +10,7 @@ const { homedir } = require('node:os'); const { join } = require('node:path'); const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); const { getCommandEnvironment } = require('../login-shell-environment.cjs'); +const { getCurrentCommandSignal } = require('./common.cjs'); const DEFAULT_PROVIDER_OUTPUT_BYTES = 8 * 1024 * 1024; const GLAB_NOT_FOUND_CODE = 'GLAB_NOT_FOUND'; @@ -212,7 +213,7 @@ const runGlabApiBuffer = async (repoRoot, hostname, args, input, options = {}) = const child = spawn(command, args, { cwd: repoRoot, env: environment, - signal: options.signal, + signal: options.signal ?? getCurrentCommandSignal(), stdio: ['pipe', 'pipe', 'pipe'], }); const stdout = []; @@ -284,17 +285,18 @@ const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal } */ const readApiBuffer = async (args, input, options) => { const maxBytes = normalizeMaxBytes(options.maxBytes); + const signal = options.signal ?? getCurrentCommandSignal(); const bytes = options.sharedKey ? await readSharedGet(options.sharedKey, maxBytes, ({ getMaxBytes, onOutputLimit }) => runGlabApiBuffer(repoRoot, hostname, args, input, { getMaxBytes, onOutputLimit, - signal: options.signal, + signal, }), ) : await runGlabApiBuffer(repoRoot, hostname, args, input, { maxBytes, - signal: options.signal, + signal, }); return enforceOutputLimit(bytes, maxBytes); }; @@ -317,7 +319,7 @@ const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal } request.method, request.body, ); - const signal = request.signal || defaultSignal; + const signal = request.signal || defaultSignal || getCurrentCommandSignal(); const sharedKey = request.body == null && (!request.method || request.method === 'GET') && !signal ? `${repoRoot}\0${hostname}\0text\0${args.join('\0')}` @@ -411,7 +413,7 @@ const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal } }, async requestBuffer(request) { const args = createGlabApiArgs(hostname, request.path, request.query, undefined, undefined); - const signal = request.signal || defaultSignal; + const signal = request.signal || defaultSignal || getCurrentCommandSignal(); return readApiBuffer(args, undefined, { maxBytes: request.maxBytes, sharedKey: !signal ? `${repoRoot}\0${hostname}\0buffer\0${args.join('\0')}` : undefined, @@ -421,7 +423,7 @@ const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal } async requestPages(request) { const args = createGlabApiArgs(hostname, request.path, request.query, undefined, undefined); args.splice(-1, 0, '--paginate'); - const signal = request.signal || defaultSignal; + const signal = request.signal || defaultSignal || getCurrentCommandSignal(); const pages = parseJsonPages( ( await readApiBuffer(args, undefined, { diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index a4a84aeb..915a9e5a 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -1,7 +1,13 @@ // @ts-check const { createHash } = require('node:crypto'); -const { git, gitOrEmpty, readGitImageFile, validateRepositoryPath } = require('./common.cjs'); +const { + getCurrentCommandSignal, + git, + gitOrEmpty, + readGitImageFile, + validateRepositoryPath, +} = require('./common.cjs'); const { readGitFiles } = require('./git-files.cjs'); const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); @@ -124,7 +130,7 @@ const rememberMergeRequestHydrationSnapshot = (repoRoot, mergeRequest, snapshot) /** * @param {string} repoRoot * @param {ReturnType} mergeRequest - * @param {{expectedHeadSha?: string, forceRefresh?: boolean}} [options] + * @param {{expectedHeadSha?: string, forceRefresh?: boolean, signal?: AbortSignal}} [options] */ const readMergeRequestHydrationSnapshot = async (repoRoot, mergeRequest, options = {}) => { const key = mergeRequestHydrationSnapshotKey(repoRoot, mergeRequest); @@ -161,7 +167,7 @@ const readMergeRequestHydrationSnapshot = async (repoRoot, mergeRequest, options }); const { range } = await artifactSource.readStackAndRange( { requestedBaseSha: baseSha, headSha }, - new AbortController().signal, + options.signal ?? getCurrentCommandSignal() ?? new AbortController().signal, ); const snapshot = { headSha, metadata, range }; rememberMergeRequestHydrationSnapshot(repoRoot, mergeRequest, snapshot); diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index 6c39bc6e..43746934 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -7,6 +7,7 @@ const { getImageMimeType, git, gitOrEmpty, + getCurrentCommandSignal, validateRepositoryPath, } = require('./common.cjs'); const { readGitFiles } = require('./git-files.cjs'); @@ -319,7 +320,7 @@ const rememberPullRequestHydrationSnapshot = (repoRoot, pullRequest, snapshot) = * * @param {string} repoRoot * @param {PullRequestReference} pullRequest - * @param {{expectedHeadSha?: string, forceRefresh?: boolean}} [options] + * @param {{expectedHeadSha?: string, forceRefresh?: boolean, signal?: AbortSignal}} [options] */ const readPullRequestHydrationSnapshot = async (repoRoot, pullRequest, options = {}) => { const key = pullRequestHydrationSnapshotKey(repoRoot, pullRequest); @@ -359,7 +360,7 @@ const readPullRequestHydrationSnapshot = async (repoRoot, pullRequest, options = requestedBaseSha: /** @type {GitSha} */ (baseSha), headSha: /** @type {GitSha} */ (headSha), }, - new AbortController().signal, + options.signal ?? getCurrentCommandSignal() ?? new AbortController().signal, ); const snapshot = { headSha, metadata, range }; rememberPullRequestHydrationSnapshot(repoRoot, pullRequest, snapshot); diff --git a/electron/git-state/review-range-sections.cjs b/electron/git-state/review-range-sections.cjs index f576a629..4268acab 100644 --- a/electron/git-state/review-range-sections.cjs +++ b/electron/git-state/review-range-sections.cjs @@ -90,6 +90,10 @@ const rangeArtifactToPullRequestFiles = (artifact, number, options = {}) => { const patchUnavailable = !patch && !canHydrateArtifactFile(file); const deferContents = options.deferContents === true && !patch && canHydrateArtifactFile(file) && !binary; + const contentFingerprint = + file.oldObjectId || file.newObjectId + ? getFingerprint(`${file.oldObjectId || ''}\0${file.newObjectId || ''}`) + : undefined; return { fingerprint: getFingerprint( `${artifact.baseSha}:${artifact.headSha}:${index}:${file.path}:${file.status}:${patch}`, @@ -130,7 +134,10 @@ const rangeArtifactToPullRequestFiles = (artifact, number, options = {}) => { : deferContents ? 'Showing the provider patch while exact file contents load on demand.' : 'Showing the provider patch for this file.', - { canLoad: !binary && canHydrateArtifactFile(file) }, + { + canLoad: !binary && canHydrateArtifactFile(file), + ...(contentFingerprint ? { fingerprint: contentFingerprint } : {}), + }, ), }, ], diff --git a/electron/main.cjs b/electron/main.cjs index 270ab67e..3b46ea1f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -25,6 +25,7 @@ const { readRepositoryState, readReviewComments, readWalkthroughRepositoryState, + runWithCommandSignal, submitPullRequestComment, submitPullRequestReview, validateRepositoryPath, @@ -70,7 +71,7 @@ const { const { findMatchingWindowIdentity, getWindowIdentity, - getWindowIdentityForRepositoryState, + storeResolvedWindowState, } = require('./window-identity.cjs'); const { createPendingCommentsClipboardController } = require('./pending-comments.cjs'); const { @@ -135,6 +136,8 @@ const root = dirname(__dirname); const windowIdentities = new Map(); /** @type {Map} */ const windowRepositories = new Map(); +/** @type {Map>} */ +const diffContentRequests = new Map(); /** @type {Map} */ const windowLaunchOptions = new Map(); /** @type {Map>} */ @@ -185,6 +188,41 @@ const refreshInstalledAgentFiles = () => { }; const getActiveAgent = () => getAgent(config.settings.agentBackend); +const abortDiffContentRequests = (webContentsId) => { + const requests = diffContentRequests.get(webContentsId); + if (!requests) { + return; + } + diffContentRequests.delete(webContentsId); + for (const controller of requests.values()) { + controller.abort(new DOMException('Diff content request canceled.', 'AbortError')); + } +}; + +const runDiffContentRequest = async (event, request, read) => { + const requestId = + typeof request?.requestId === 'string' && request.requestId + ? request.requestId + : `legacy:${Date.now()}:${Math.random()}`; + const webContentsId = event.sender.id; + const requests = diffContentRequests.get(webContentsId) ?? new Map(); + diffContentRequests.set(webContentsId, requests); + requests + .get(requestId) + ?.abort(new DOMException('Diff content request superseded.', 'AbortError')); + const controller = new AbortController(); + requests.set(requestId, controller); + try { + return await runWithCommandSignal(controller.signal, read); + } finally { + if (requests.get(requestId) === controller) { + requests.delete(requestId); + } + if (requests.size === 0) { + diffContentRequests.delete(webContentsId); + } + } +}; /** @param {string} repositoryPath @param {ReviewSource} [source] */ const readRepositoryStateWithConfig = (repositoryPath, source) => @@ -242,24 +280,17 @@ const getMarkdownDocumentContext = (webContentsId) => ({ /** @param {number} webContentsId @param {RepositoryState} state */ const storeResolvedRepositoryState = (webContentsId, state) => { - windowRepositories.set(webContentsId, state.root); + storeResolvedWindowState(webContentsId, state, { + identities: windowIdentities, + launchOptions: windowLaunchOptions, + repositories: windowRepositories, + }); const browserWindow = BrowserWindow.getAllWindows().find( (window) => window.webContents.id === webContentsId, ); if (browserWindow && !browserWindow.isDestroyed()) { browserWindow.setTitle(getRepositoryWindowTitle(state)); } - const launchOptions = windowLaunchOptions.get(webContentsId); - if (launchOptions) { - windowLaunchOptions.set(webContentsId, { - ...launchOptions, - source: state.source, - }); - } - const identity = getWindowIdentityForRepositoryState(state); - if (identity) { - windowIdentities.set(webContentsId, identity); - } }; /** @param {number} webContentsId */ @@ -1025,6 +1056,7 @@ const createWindow = ( completedPlanWindows.delete(webContentsId); planInitialVersions.delete(webContentsId); readyPlanWindows.delete(webContentsId); + abortDiffContentRequests(webContentsId); windowIdentities.delete(webContentsId); windowInitialRepositoryStates.delete(webContentsId); walkthroughProgressGenerations.delete(webContentsId); @@ -1033,6 +1065,7 @@ const createWindow = ( }); window.webContents.on('render-process-gone', () => { definitionSearchCoordinator.cancel(webContentsId); + abortDiffContentRequests(webContentsId); writePlanResult(webContentsId, 'canceled'); }); window.webContents.on( @@ -1799,10 +1832,12 @@ ipcMain.handle('codiff:submitPullRequestReview', async (event, request) => { ipcMain.handle('codiff:getDiffSectionContent', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return readDiffSectionContent(repositoryPath, { - ...request, - showWhitespace: request?.showWhitespace ?? config.settings.showWhitespace, - }); + return runDiffContentRequest(event, request, () => + readDiffSectionContent(repositoryPath, { + ...request, + showWhitespace: request?.showWhitespace ?? config.settings.showWhitespace, + }), + ); }); ipcMain.handle('codiff:getDiffSectionsContent', async (event, request) => { @@ -1812,7 +1847,16 @@ ipcMain.handle('codiff:getDiffSectionsContent', async (event, request) => { ipcMain.handle('codiff:getDiffImageContent', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return readDiffImageContent(repositoryPath, request); + return runDiffContentRequest(event, request, () => readDiffImageContent(repositoryPath, request)); +}); + +ipcMain.on('codiff:cancelDiffContentRequest', (event, requestId) => { + if (typeof requestId !== 'string') { + return; + } + const requests = diffContentRequests.get(event.sender.id); + const controller = requests?.get(requestId); + controller?.abort(new DOMException('Diff content request canceled.', 'AbortError')); }); ipcMain.handle('codiff:getRepositoryHistory', async (event, limit, source) => { diff --git a/electron/preload.cjs b/electron/preload.cjs index 02031f2c..b00d5e40 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -17,6 +17,8 @@ const codiff = { applyUpdate: () => ipcRenderer.invoke('codiff:applyUpdate'), askReviewAssistant: (request) => ipcRenderer.invoke('codiff:askReviewAssistant', request), dismissUpdate: () => ipcRenderer.invoke('codiff:dismissUpdate'), + cancelDiffContentRequest: (requestId) => + ipcRenderer.send('codiff:cancelDiffContentRequest', requestId), createWalkthroughCommit: (request) => ipcRenderer.invoke('codiff:createWalkthroughCommit', request), completePlan: (review, status) => ipcRenderer.invoke('codiff:completePlan', review, status), diff --git a/electron/window-identity.cjs b/electron/window-identity.cjs index 49c9a469..8f03043e 100644 --- a/electron/window-identity.cjs +++ b/electron/window-identity.cjs @@ -181,7 +181,6 @@ const getWindowIdentity = (repositoryPath, launchOptions = {}) => { : null; }; - /** @param {{root: string; source: import('../core/types.ts').ResolvedReviewSource}} state */ const getWindowIdentityForRepositoryState = (state) => { const repositoryRoot = getRealPath(state.root); @@ -195,6 +194,31 @@ const getWindowIdentityForRepositoryState = (state) => { : null; }; +/** + * Retarget one independent viewport after it resolves a new review source. + * Existing viewports are intentionally left untouched, even when this creates + * multiple viewports with the same working-tree identity. + * + * @param {number} webContentsId + * @param {{root: string; source: import('../core/types.ts').ResolvedReviewSource}} state + * @param {{identities: Map, launchOptions: Map, repositories: Map}} stores + */ +const storeResolvedWindowState = (webContentsId, state, stores) => { + stores.repositories.set(webContentsId, state.root); + const launchOptions = stores.launchOptions.get(webContentsId); + if (launchOptions) { + stores.launchOptions.set(webContentsId, { + ...launchOptions, + source: state.source, + }); + } + const identity = getWindowIdentityForRepositoryState(state); + if (identity) { + stores.identities.set(webContentsId, identity); + } + return identity; +}; + /** * @param {WindowIdentity | null} identity * @param {ReadonlyMap} existingIdentities @@ -217,4 +241,5 @@ module.exports = { findMatchingWindowIdentity, getWindowIdentity, getWindowIdentityForRepositoryState, + storeResolvedWindowState, }; diff --git a/github/__tests__/current-review.test.ts b/github/__tests__/current-review.test.ts index 25d9fb31..77362afa 100644 --- a/github/__tests__/current-review.test.ts +++ b/github/__tests__/current-review.test.ts @@ -106,12 +106,12 @@ test('one GitHub source populates stack, range, commit, and blob caches', async const run = createReviewArtifactRun(createGitHubArtifactSource({ project, pull, transport })); const firstRange = await run.readStackAndRange( - { headSha: headSha, requestedBaseSha: baseSha }, + { headSha, requestedBaseSha: baseSha }, run.signal, ); - expect( - await run.readStackAndRange({ headSha: headSha, requestedBaseSha: baseSha }, run.signal), - ).toBe(firstRange); + expect(await run.readStackAndRange({ headSha, requestedBaseSha: baseSha }, run.signal)).toBe( + firstRange, + ); const artifacts = await run.readCommitArtifacts( [{ commitSha: headSha, parentSha: baseSha }], run.signal, @@ -228,7 +228,7 @@ test('caps current GitHub commit stacks at forty', async () => { ]); const result = await createGitHubArtifactSource({ project, pull, transport }).readStackAndRange( - { headSha: headSha, requestedBaseSha: baseSha }, + { headSha, requestedBaseSha: baseSha }, new AbortController().signal, ); @@ -266,7 +266,7 @@ test('retains commits 81 through 120 from a paginated GitHub comparison', async ]); const result = await createGitHubArtifactSource({ project, pull, transport }).readStackAndRange( - { headSha: headSha, requestedBaseSha: baseSha }, + { headSha, requestedBaseSha: baseSha }, new AbortController().signal, ); diff --git a/gitlab/__tests__/current-review.test.ts b/gitlab/__tests__/current-review.test.ts index 5374aacf..ecea7923 100644 --- a/gitlab/__tests__/current-review.test.ts +++ b/gitlab/__tests__/current-review.test.ts @@ -83,12 +83,12 @@ test('one GitLab source populates stack, range, commit, and blob caches', async ); const firstRange = await run.readStackAndRange( - { headSha: headSha, requestedBaseSha: baseSha }, + { headSha, requestedBaseSha: baseSha }, run.signal, ); - expect( - await run.readStackAndRange({ headSha: headSha, requestedBaseSha: baseSha }, run.signal), - ).toBe(firstRange); + expect(await run.readStackAndRange({ headSha, requestedBaseSha: baseSha }, run.signal)).toBe( + firstRange, + ); const artifacts = await run.readCommitArtifacts( [{ commitSha: headSha, parentSha: baseSha }], run.signal, diff --git a/package.json b/package.json index 4fa50845..b93260fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codiff", - "version": "1.10.1", + "version": "1.12.1", "private": true, "description": "A fast local diff viewer.", "license": "MIT", @@ -58,26 +58,23 @@ "proper-lockfile": "^4.1.2" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.18.8", + "@cloudflare/vitest-pool-workers": "^0.22.0", "@electron-forge/cli": "^7.11.2", "@electron-forge/maker-deb": "^7.11.2", "@electron-forge/maker-rpm": "^7.11.2", "@electron-forge/maker-squirrel": "^7.11.2", "@electron-forge/maker-zip": "^7.11.2", "@nkzw/eslint-plugin": "^2.0.0", - "@nkzw/oxlint-config": "^1.2.1", + "@nkzw/oxlint-config": "^2.0.1", "@rolldown/plugin-babel": "^0.2.3", - "@types/node": "^26.1.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.4", + "@types/node": "^26.4.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.1", "babel-plugin-react-compiler": "^1.0.0", - "electron": "^43.2.0", - "eslint-plugin-no-only-tests": "^3.4.0", - "eslint-plugin-perfectionist": "^5.10.0", - "eslint-plugin-react-hooks": "^7.1.1", + "electron": "^44.1.1", "ghostty-web": "^0.4.0", - "jsdom": "^29.1.1", + "jsdom": "^30.0.1", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "^7.0.2", diff --git a/web/src/sharing/WalkthroughPage.tsx b/web/src/sharing/WalkthroughPage.tsx index b4cfc979..4129d454 100644 --- a/web/src/sharing/WalkthroughPage.tsx +++ b/web/src/sharing/WalkthroughPage.tsx @@ -152,7 +152,7 @@ const Viewer = ({ walkthrough: walkthroughRef }: { walkthrough: ViewRef<'Walkthr const fate = useFateClient(); const walkthrough = useLiveView(WalkthroughPageView, walkthroughRef); const snapshot = use(getManifest(walkthrough.slug)); - usePageTitle(snapshot.walkthrough.title); + usePageTitle(snapshot.repository.title); const [preferences, setPreferences] = useOnlineCodiffPreferences(snapshot.preferences ?? {}); const { data: session } = auth.useSession(); const username = sessionUsername(session?.user); From ff1db21d6665915d7effd6c872337ad81671941f Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Fri, 14 Aug 2026 14:29:48 -0500 Subject: [PATCH 07/17] Seed repository watchers from initial Git status Hand the first `git status` result that painted the working tree to the repository watcher. Parse porcelain-v2 `u` records as `conflicted` with their stage information instead of applying single-letter status normalization, and cover the unmerged combinations directly. --- core/__tests__/git-state.test.ts | 10 +- electron/__tests__/repository-watcher.test.ts | 94 +++++++++ electron/git-state.cjs | 38 ++-- electron/git-state/working-tree.cjs | 134 +++++++++++-- electron/main.cjs | 31 ++- electron/repository-watcher.cjs | 182 +++++++++++++++--- 6 files changed, 418 insertions(+), 71 deletions(-) diff --git a/core/__tests__/git-state.test.ts b/core/__tests__/git-state.test.ts index 1398dc16..69bb60ca 100644 --- a/core/__tests__/git-state.test.ts +++ b/core/__tests__/git-state.test.ts @@ -106,9 +106,6 @@ type GitStateModule = { launchPath: string, request: DiffSectionContentRequest, ) => Promise; - readRepositoryChangeSignature: ( - launchPath: string, - ) => Promise<{ root: string; signature: string }>; readRepositoryState: ( launchPath: string, source?: ReviewSource, @@ -168,7 +165,6 @@ const { parseStatus, PENDING_REVIEW_COMMENT_ERROR, readDiffSectionContent, - readRepositoryChangeSignature, readRepositoryState, readWalkthroughRepositoryState, readWorkingTreeState, @@ -177,6 +173,12 @@ const { submitPullRequestComment, validateRepositoryPath, } = require('../../electron/git-state.cjs') as GitStateModule; +const { readRepositoryWatcherSnapshot: readRepositoryChangeSignature } = + require('../../electron/repository-watcher.cjs') as { + readRepositoryWatcherSnapshot: ( + repoRoot: string, + ) => Promise<{ root: string; signature: string }>; + }; const git = async (repo: string, args: ReadonlyArray) => { const { stdout } = await execFileAsync('git', ['-C', repo, ...args], { diff --git a/electron/__tests__/repository-watcher.test.ts b/electron/__tests__/repository-watcher.test.ts index 9e94e864..eacc6cbe 100644 --- a/electron/__tests__/repository-watcher.test.ts +++ b/electron/__tests__/repository-watcher.test.ts @@ -27,6 +27,7 @@ type Coordinator = { attach: (subscriber: { getState: () => SubscriberState; id: number; + initialSnapshot?: Snapshot | Promise; notify: (root: string) => void; root: string; }) => Promise; @@ -50,6 +51,7 @@ type Coordinator = { const require = createRequire(import.meta.url); const { createRepositoryWatcherCoordinator, + getRepositoryWatcherInitialSnapshot, getRepositoryWatcherPollInterval, normalizeRepositoryWatcherPath, parseRepositoryWatcherStatus, @@ -65,6 +67,7 @@ const { ) => Promise; setTimeoutImpl?: (callback: () => void, delay: number) => unknown; }) => Coordinator; + getRepositoryWatcherInitialSnapshot: (state: object) => Promise | undefined; getRepositoryWatcherPollInterval: (states: ReadonlyArray) => number; normalizeRepositoryWatcherPath: (path: string, pathSeparator?: string) => string; parseRepositoryWatcherStatus: (raw: string) => { head: string; paths: Array }; @@ -79,6 +82,9 @@ const { expectedPathVersions: ReadonlyMap, ) => boolean; }; +const { readRepositoryState } = require('../git-state.cjs') as { + readRepositoryState: (launchPath: string) => Promise; +}; const execFileAsync = promisify(execFile); const largeTestFileSize = 8 * 1024 * 1024; @@ -127,6 +133,44 @@ test('parses porcelain-v2 branch information and dirty paths', () => { }); }); +test('parses porcelain-v2 unmerged records as conflicted', () => { + const hash = '1'.repeat(40); + const { parsePorcelainV2Status } = require('../git-state/working-tree.cjs') as { + parsePorcelainV2Status: (raw: string) => Array<{ + conflictStage?: 1 | 2 | 3; + path: string; + staged: boolean; + status: string; + unstaged: boolean; + untracked: boolean; + }>; + }; + const unmerged = [ + ['UU', 'both-modified.ts', 2], + ['AA', 'both-added.ts', 2], + ['DD', 'both-deleted.ts', 1], + ['AU', 'added-by-us.ts', 2], + ['UA', 'added-by-them.ts'], + ['DU', 'deleted-by-us.ts'], + ['UD', 'deleted-by-them.ts', 2], + ] as const; + const raw = unmerged + .map(([xy, path]) => `u ${xy} N... 100644 100644 100644 100644 ${hash} ${hash} ${hash} ${path}`) + .concat('') + .join('\0'); + + expect(parsePorcelainV2Status(raw)).toEqual( + unmerged.map(([, path, conflictStage]) => ({ + ...(conflictStage ? { conflictStage } : {}), + path, + staged: false, + status: 'conflicted', + unstaged: true, + untracked: false, + })), + ); +}); + test('ignores only expected app-written paths', () => { expect( repositoryWatcherSnapshotsMatchExpectedWrites( @@ -202,6 +246,56 @@ test('never ignores repository HEAD changes', () => { ).toBe(false); }); +test('compares an adopted startup snapshot immediately after watcher attachment', async () => { + await using directory = await createTemporaryDirectory('codiff-initial-watcher-'); + const repository = await realpath(directory.path); + const timers: Array<{ callback: () => void; cleared: boolean; delay: number }> = []; + let reads = 0; + const coordinator = createRepositoryWatcherCoordinator({ + clearTimeoutImpl: (timer) => { + (timer as (typeof timers)[number]).cleared = true; + }, + readSnapshot: async (root, exactPaths, knownDirtyPaths) => { + reads += 1; + return readRepositoryWatcherSnapshot(root, exactPaths, knownDirtyPaths); + }, + setTimeoutImpl: (callback, delay) => { + const timer = { callback, cleared: false, delay }; + timers.push(timer); + return timer; + }, + }); + + try { + await git(repository, ['init']); + await writeFile(join(repository, 'file.txt'), 'before\n'); + await git(repository, ['add', 'file.txt']); + await git(repository, ['commit', '-m', 'initial']); + + const state = await readRepositoryState(repository); + const initialSnapshot = getRepositoryWatcherInitialSnapshot(state); + if (!initialSnapshot) { + throw new Error('Expected the initial repository state to retain a watcher snapshot.'); + } + + await writeFile(join(repository, 'file.txt'), 'after\n'); + const notifications: Array = []; + await coordinator.attach({ + getState: () => ({ focused: true, visible: true }), + id: 1, + initialSnapshot, + notify: (root) => notifications.push(root), + root: repository, + }); + + await initialSnapshot; + expect(reads).toBe(1); + expect(notifications).toEqual([repository]); + } finally { + coordinator.detach(1); + } +}, 30_000); + test('shares one watcher per repository and adapts polling to window state', async () => { const timers: Array<{ callback: () => void; cleared: boolean; delay: number }> = []; const states = new Map([ diff --git a/electron/git-state.cjs b/electron/git-state.cjs index 8e1200eb..c6c57e5c 100644 --- a/electron/git-state.cjs +++ b/electron/git-state.cjs @@ -23,7 +23,12 @@ const { readRangeSectionContent, readRangeState, } = require('./git-state/commit.cjs'); -const { parseRepositoryWatcherStatus } = require('./repository-watcher.cjs'); +const { + createRepositoryWatcherSnapshot, + parseRepositoryWatcherStatus, + setRepositoryWatcherInitialSnapshot, + transferRepositoryWatcherInitialSnapshot, +} = require('./repository-watcher.cjs'); const { PENDING_REVIEW_COMMENT_ERROR, collectResolvedReviewCommentIds, @@ -65,7 +70,6 @@ const { readDiffSectionContent: readWorkingTreeDiffSectionContent, readDiffImageContent: readWorkingTreeDiffImageContent, readGitIdentity, - readRepositoryChangeSignature, readWorkingTreeState, } = require('./git-state/working-tree.cjs'); const { annotateGeneratedFiles } = require('./generated-files.cjs'); @@ -80,7 +84,7 @@ const { annotateGeneratedFiles } = require('./generated-files.cjs'); * @typedef {import('../core/types.ts').ReviewSource} ReviewSource */ -/** @param {string} launchPath @param {ReviewSource} [source] @param {{showWhitespace?: boolean}} [options] @returns {Promise} */ +/** @param {string} launchPath @param {ReviewSource} [source] @param {{repositoryRoot?: string; showWhitespace?: boolean}} [options] @returns {Promise} */ const readRepositoryState = async (launchPath, source = { type: 'working-tree' }, options = {}) => { const state = source.type === 'pull-request' @@ -100,6 +104,7 @@ const readRepositoryState = async (launchPath, source = { type: 'working-tree' } }) : await readWorkingTreeState(launchPath, { eagerContents: false, + repositoryRoot: options.repositoryRoot, showWhitespace: options.showWhitespace, }); const comparisonState = @@ -123,7 +128,10 @@ const readRepositoryState = async (launchPath, source = { type: 'working-tree' } : gitOrEmpty(state.root, ['symbolic-ref', '--short', 'HEAD']), comparisonState ? state : annotateGeneratedFiles(state, generatedRevision), ]); - return { ...annotatedState, branch: branch.trim() || null }; + return transferRepositoryWatcherInitialSnapshot(state, { + ...annotatedState, + branch: branch.trim() || null, + }); }; /** @@ -162,16 +170,19 @@ const readWalkthroughRepositoryState = async (launchPath, source, options = {}) return { ...state, branch }; } - return { - branch, - files: [], - generatedAt: Date.now(), - launchPath, - root: repoRoot, - source: { - type: 'working-tree', + return setRepositoryWatcherInitialSnapshot( + { + branch, + files: [], + generatedAt: Date.now(), + launchPath, + root: repoRoot, + source: { + type: 'working-tree', + }, }, - }; + createRepositoryWatcherSnapshot(repoRoot, status), + ); }; /** @param {Extract} source */ @@ -304,7 +315,6 @@ module.exports = { readDiffSectionsContent, readDiffImageContent, readGitIdentity, - readRepositoryChangeSignature, readReviewComments, readCommitState, readPullRequestState, diff --git a/electron/git-state/working-tree.cjs b/electron/git-state/working-tree.cjs index cc9f9a58..08081b20 100644 --- a/electron/git-state/working-tree.cjs +++ b/electron/git-state/working-tree.cjs @@ -1,6 +1,10 @@ // @ts-check -const { readRepositoryChangeSignature } = require('../repository-watcher.cjs'); +const { + createRepositoryWatcherSnapshot, + parseRepositoryWatcherStatus, + setRepositoryWatcherInitialSnapshot, +} = require('../repository-watcher.cjs'); const { createSection, createSummary, @@ -12,6 +16,7 @@ const { getWhitespaceDiffArgs, git, MAX_UNTRACKED_INITIAL_ITEMS, + normalizeStatus, parseStatus, readFileStat, readGitImageFile, @@ -33,6 +38,94 @@ const { const diffGitHeaderPattern = /^diff --git (.+)$/; +/** @param {string} record @param {number} count */ +const readPorcelainV2StatusPath = (record, count) => { + let index = 0; + for (let field = 0; field < count; field += 1) { + index = record.indexOf(' ', index); + if (index === -1) { + return ''; + } + index += 1; + } + return record.slice(index); +}; + +/** @param {string} x @param {string} y @param {string} path @param {string} [oldPath] */ +const createPorcelainV2StatusItem = (x, y, path, oldPath) => { + const conflictCode = `${x}${y}`; + const conflicted = ['AA', 'AU', 'DD', 'DU', 'UA', 'UD', 'UU'].includes(conflictCode); + if (conflicted) { + return { + ...(oldPath ? { oldPath } : {}), + ...(conflictCode === 'DD' + ? { conflictStage: 1 } + : conflictCode === 'DU' || conflictCode === 'UA' + ? {} + : { conflictStage: 2 }), + path, + staged: false, + status: 'conflicted', + unstaged: true, + untracked: false, + }; + } + const staged = x !== '.' && x !== ' '; + const unstaged = y !== '.' && y !== ' '; + return { + ...(oldPath ? { oldPath } : {}), + path, + staged, + status: normalizeStatus(staged ? x : y), + unstaged, + untracked: false, + }; +}; + +/** @param {string} raw @returns {Array} */ +const parsePorcelainV2Status = (raw) => { + const files = []; + const records = raw.split('\0').filter(Boolean); + + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (record.startsWith('? ')) { + files.push({ + path: record.slice(2), + staged: false, + status: 'untracked', + unstaged: true, + untracked: true, + }); + continue; + } + if (record.startsWith('1 ')) { + files.push( + createPorcelainV2StatusItem(record[2], record[3], readPorcelainV2StatusPath(record, 8)), + ); + continue; + } + if (record.startsWith('2 ')) { + files.push( + createPorcelainV2StatusItem( + record[2], + record[3], + readPorcelainV2StatusPath(record, 9), + records[++index], + ), + ); + continue; + } + if (record.startsWith('u ')) { + files.push( + createPorcelainV2StatusItem(record[2], record[3], readPorcelainV2StatusPath(record, 10)), + ); + } + } + + return files; +}; + /** @param {string} value */ const unquoteGitPath = (value) => { if (!value.startsWith('"')) { @@ -214,16 +307,24 @@ const listUntrackedItems = async (repoRoot) => { /** * @param {string} launchPath - * @param {{eagerContents?: boolean; showWhitespace?: boolean}} [options] + * @param {{eagerContents?: boolean; repositoryRoot?: string; showWhitespace?: boolean}} [options] * @returns {Promise} */ const readWorkingTreeState = async (launchPath, options = {}) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const [trackedStatus, untrackedItems] = await Promise.all([ - git(repoRoot, ['status', '--porcelain=v1', '-z', '-uno']), + const repoRoot = + options.repositoryRoot || (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const [watcherStatus, untrackedItems] = await Promise.all([ + git(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '-uall']), listUntrackedItems(repoRoot), ]); - const status = [...parseStatus(trackedStatus), ...untrackedItems].sort(fileSort); + const initialWatcherSnapshot = createRepositoryWatcherSnapshot( + repoRoot, + parseRepositoryWatcherStatus(watcherStatus), + ); + const status = [ + ...parsePorcelainV2Status(watcherStatus).filter(({ untracked }) => !untracked), + ...untrackedItems, + ].sort(fileSort); const shouldUsePatchOnly = options.eagerContents === false; const [stagedPatches, unstagedPatches] = shouldUsePatchOnly ? await Promise.all([ @@ -281,15 +382,18 @@ const readWorkingTreeState = async (launchPath, options = {}) => { }); } - return { - files, - generatedAt: Date.now(), - launchPath, - root: repoRoot, - source: { - type: 'working-tree', + return setRepositoryWatcherInitialSnapshot( + { + files, + generatedAt: Date.now(), + launchPath, + root: repoRoot, + source: { + type: 'working-tree', + }, }, - }; + initialWatcherSnapshot, + ); }; /** @param {string} repoRoot @param {string} path @returns {Promise} */ @@ -404,9 +508,9 @@ const readGitIdentity = async (launchPath) => { }; module.exports = { + parsePorcelainV2Status, readDiffSectionContent, readDiffImageContent, readGitIdentity, - readRepositoryChangeSignature, readWorkingTreeState, }; diff --git a/electron/main.cjs b/electron/main.cjs index 3b46ea1f..7fc4ee93 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -113,6 +113,7 @@ const { } = require('./markdown-document.cjs'); const { createRepositoryWatcherCoordinator, + getRepositoryWatcherInitialSnapshot, readRepositoryWatcherSnapshot, } = require('./repository-watcher.cjs'); const { getPlanReviewPath, readPlanReview, writePlanReview } = require('./plan-review.cjs'); @@ -224,19 +225,20 @@ const runDiffContentRequest = async (event, request, read) => { } }; -/** @param {string} repositoryPath @param {ReviewSource} [source] */ -const readRepositoryStateWithConfig = (repositoryPath, source) => +/** @param {string} repositoryPath @param {ReviewSource} [source] @param {string} [repositoryRoot] */ +const readRepositoryStateWithConfig = (repositoryPath, source, repositoryRoot) => readRepositoryState(repositoryPath, source, { + repositoryRoot, showWhitespace: config.settings.showWhitespace, }); -/** @param {string} repositoryPath @param {CodiffLaunchOptions} launchOptions */ -const readInitialRepositoryStateWithConfig = (repositoryPath, launchOptions) => +/** @param {string} repositoryPath @param {CodiffLaunchOptions} launchOptions @param {string} [repositoryRoot] */ +const readInitialRepositoryStateWithConfig = (repositoryPath, launchOptions, repositoryRoot) => launchOptions.walkthrough && !launchOptions.walkthroughFile ? readWalkthroughRepositoryState(repositoryPath, launchOptions.source, { showWhitespace: config.settings.showWhitespace, }) - : readRepositoryStateWithConfig(repositoryPath, launchOptions.source); + : readRepositoryStateWithConfig(repositoryPath, launchOptions.source, repositoryRoot); /** @param {number} webContentsId */ const resolveWindowAgent = (webContentsId) => { @@ -538,8 +540,12 @@ const beginRepositorySelfWrite = (webContentsId, path) => const finishRepositorySelfWrite = (token, version) => repositoryWatcherCoordinator.finishWrite(token, version); -/** @param {import('electron').BrowserWindow} browserWindow @param {string} repositoryPath */ -const startRepositoryWatcher = (browserWindow, repositoryPath) => { +/** + * @param {import('electron').BrowserWindow} browserWindow + * @param {string} repositoryPath + * @param {Promise<{head: string; pathSignatures: Record; pathVersions: Record; root: string; signature: string}> | undefined} initialSnapshot + */ +const startRepositoryWatcher = (browserWindow, repositoryPath, initialSnapshot) => { const webContentsId = browserWindow.webContents.id; void repositoryWatcherCoordinator.attach({ getState: () => ({ @@ -548,6 +554,7 @@ const startRepositoryWatcher = (browserWindow, repositoryPath) => { !browserWindow.isDestroyed() && browserWindow.isVisible() && !browserWindow.isMinimized(), }), id: webContentsId, + initialSnapshot, notify: (root) => { if (!browserWindow.isDestroyed()) { browserWindow.webContents.send('codiff:repositoryChanged', { root }); @@ -960,7 +967,7 @@ const createWindow = ( windowLaunchOptions.set(webContentsId, launchOptions); const initialRepositoryStatePromise = launchOptions.planFile ? null - : readInitialRepositoryStateWithConfig(repositoryPath, launchOptions); + : readInitialRepositoryStateWithConfig(repositoryPath, launchOptions, identity?.repositoryRoot); const initialRepositoryState = initialRepositoryStatePromise?.then((state) => { if (!window.isDestroyed()) { storeResolvedRepositoryState(webContentsId, state); @@ -983,7 +990,7 @@ const createWindow = ( (state.source.type === 'working-tree' || state.source.type === 'branch-working-tree') && !window.isDestroyed() ) { - startRepositoryWatcher(window, state.root); + startRepositoryWatcher(window, state.root, getRepositoryWatcherInitialSnapshot(state)); } }) .catch(() => {}); @@ -1225,7 +1232,11 @@ const focusOrCreateWindow = ( } else { windowInitialRepositoryStates.set( matchingWebContentsId, - readInitialRepositoryStateWithConfig(repositoryPath, launchOptions), + readInitialRepositoryStateWithConfig( + repositoryPath, + launchOptions, + identity?.repositoryRoot, + ), ); } if (identity) { diff --git a/electron/repository-watcher.cjs b/electron/repository-watcher.cjs index 7162ec4d..5b6e52b9 100644 --- a/electron/repository-watcher.cjs +++ b/electron/repository-watcher.cjs @@ -9,6 +9,19 @@ const HIDDEN_POLL_INTERVAL = 30_000; const SELF_WRITE_CHECK_DELAY = 250; const VISIBLE_POLL_INTERVAL = 10_000; +/** + * @typedef {{ + * head: string; + * pathSignatures: Record; + * pathVersions: Record; + * root: string; + * signature: string; + * }} RepositoryWatcherSnapshot + */ + +/** @type {WeakMap>} */ +const initialRepositoryWatcherSnapshots = new WeakMap(); + /** @param {string} path @param {string} [pathSeparator] */ const normalizeRepositoryWatcherPath = (path, pathSeparator = sep) => pathSeparator === '\\' ? path.replaceAll('\\', '/') : path; @@ -105,43 +118,39 @@ const readRepositoryWatcherPathState = async (repoRoot, path, exact) => { }; /** - * Read a repository watcher snapshot with one Git process. `repoRoot` must - * already be the repository root. + * Build a repository watcher snapshot from a parsed porcelain-v2 status. + * `repoRoot` must already be the repository root. * * @param {string} repoRoot + * @param {{head: string; paths: Iterable}} status * @param {Iterable} [exactPaths] * @param {Iterable} [knownDirtyPaths] + * @returns {Promise} */ -const readRepositoryWatcherSnapshot = async (repoRoot, exactPaths = [], knownDirtyPaths = []) => { +const createRepositoryWatcherSnapshot = async ( + repoRoot, + status, + exactPaths = [], + knownDirtyPaths = [], +) => { const knownDirtyPathSet = new Set(knownDirtyPaths); - const statusArgs = ['status', '--porcelain=v2', '--branch', '-z', '-uall']; - // Git status hashes modified tracked files. Known dirty paths are monitored - // through metadata instead, while this command discovers all new changes. - if (knownDirtyPathSet.size > 0) { - statusArgs.push( - '--', - '.', - ...[...knownDirtyPathSet].map((path) => `:(exclude,literal)${path}`), - ); - } - const status = parseRepositoryWatcherStatus(await git(repoRoot, statusArgs)); const normalizedExactPaths = new Set( [...exactPaths].map((path) => normalizeRepositoryWatcherPath(path)), ); const statusPaths = new Set([...status.paths, ...knownDirtyPathSet]); const paths = new Set([...statusPaths, ...normalizedExactPaths]); const states = await Promise.all( - [...paths].map(async (path) => [ + [...paths].map(async (path) => ({ path, - await readRepositoryWatcherPathState(repoRoot, path, normalizedExactPaths.has(path)), - ]), + state: await readRepositoryWatcherPathState(repoRoot, path, normalizedExactPaths.has(path)), + })), ); /** @type {Record} */ const pathSignatures = {}; /** @type {Record} */ const pathVersions = {}; - for (const [path, state] of states) { + for (const { path, state } of states) { if (statusPaths.has(path)) { pathSignatures[path] = state.metadata; } @@ -164,10 +173,64 @@ const readRepositoryWatcherSnapshot = async (repoRoot, exactPaths = [], knownDir }; }; -/** @param {string} launchPath @param {Iterable} [exactPaths] */ -const readRepositoryChangeSignature = async (launchPath, exactPaths = []) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - return readRepositoryWatcherSnapshot(repoRoot, exactPaths); +/** + * Read a repository watcher snapshot with one Git process. `repoRoot` must + * already be the repository root. + * + * @param {string} repoRoot + * @param {Iterable} [exactPaths] + * @param {Iterable} [knownDirtyPaths] + * @returns {Promise} + */ +const readRepositoryWatcherSnapshot = async (repoRoot, exactPaths = [], knownDirtyPaths = []) => { + const knownDirtyPathSet = new Set(knownDirtyPaths); + const statusArgs = ['status', '--porcelain=v2', '--branch', '-z', '-uall']; + // Git status hashes modified tracked files. Known dirty paths are monitored + // through metadata instead, while this command discovers all new changes. + if (knownDirtyPathSet.size > 0) { + statusArgs.push( + '--', + '.', + ...[...knownDirtyPathSet].map((path) => `:(exclude,literal)${path}`), + ); + } + return createRepositoryWatcherSnapshot( + repoRoot, + parseRepositoryWatcherStatus(await git(repoRoot, statusArgs)), + exactPaths, + knownDirtyPathSet, + ); +}; + +/** + * Keep the startup snapshot out of the serialized repository state while it + * moves through state composition. Attach a rejection handler immediately so + * callers that do not start a watcher do not create an unhandled rejection. + * + * @template {object} State + * @param {State} state + * @param {Promise | RepositoryWatcherSnapshot} snapshot + * @returns {State} + */ +const setRepositoryWatcherInitialSnapshot = (state, snapshot) => { + const promise = Promise.resolve(snapshot); + promise.catch(() => {}); + initialRepositoryWatcherSnapshots.set(state, promise); + return state; +}; + +/** @param {object} state */ +const getRepositoryWatcherInitialSnapshot = (state) => initialRepositoryWatcherSnapshots.get(state); + +/** + * @template {object} State + * @param {object} previousState + * @param {State} nextState + * @returns {State} + */ +const transferRepositoryWatcherInitialSnapshot = (previousState, nextState) => { + const snapshot = getRepositoryWatcherInitialSnapshot(previousState); + return snapshot ? setRepositoryWatcherInitialSnapshot(nextState, snapshot) : nextState; }; /** @@ -216,7 +279,7 @@ const getRepositoryWatcherPollInterval = (states) => { /** * @param {{ * clearTimeoutImpl?: typeof clearTimeout; - * readSnapshot: (root: string, exactPaths: Iterable, knownDirtyPaths: Iterable) => Promise<{head: string; pathSignatures: Record; pathVersions?: Record; root: string; signature: string}>; + * readSnapshot: (root: string, exactPaths: Iterable, knownDirtyPaths: Iterable) => Promise; * setTimeoutImpl?: typeof setTimeout; * }} options */ @@ -228,12 +291,14 @@ const createRepositoryWatcherCoordinator = ({ /** * @typedef {{ * checking: boolean; + * initialSnapshotFresh: boolean; * pendingSelfWrites: Map; + * initialSnapshot?: Promise; * recheckRequested: boolean; * resetRequested: Set; * root: string; - * snapshot?: Awaited>; - * subscribers: Map {focused: boolean; visible: boolean}; notify: (root: string) => void; snapshot?: Awaited>}>; + * snapshot?: RepositoryWatcherSnapshot; + * subscribers: Map {focused: boolean; visible: boolean}; notify: (root: string) => void; snapshot?: RepositoryWatcherSnapshot}>; * timer?: ReturnType; * }} RepositoryWatcher */ @@ -283,6 +348,42 @@ const createRepositoryWatcherCoordinator = ({ } }; + /** @param {RepositoryWatcher} watcher */ + const adoptInitialSnapshot = async (watcher) => { + const initialSnapshot = watcher.initialSnapshot; + if (!initialSnapshot) { + return false; + } + + try { + const snapshot = await initialSnapshot; + if ( + watchers.get(watcher.root) !== watcher || + watcher.subscribers.size === 0 || + watcher.snapshot != null || + snapshot.root !== watcher.root + ) { + return watcher.snapshot != null; + } + + watcher.snapshot = snapshot; + watcher.initialSnapshotFresh = true; + for (const subscriber of watcher.subscribers.values()) { + if (subscriber.snapshot == null) { + subscriber.changed = false; + subscriber.snapshot = snapshot; + } + } + return true; + } catch { + return false; + } finally { + if (watcher.initialSnapshot === initialSnapshot) { + watcher.initialSnapshot = undefined; + } + } + }; + /** * @param {RepositoryWatcher} watcher * @param {Iterable} [resetIds] @@ -292,6 +393,11 @@ const createRepositoryWatcherCoordinator = ({ return; } clearTimer(watcher); + const adoptedInitialSnapshot = + watcher.snapshot == null && (await adoptInitialSnapshot(watcher)); + if (watchers.get(watcher.root) !== watcher || watcher.subscribers.size === 0) { + return; + } if (watcher.checking) { watcher.recheckRequested = true; for (const id of resetIds) { @@ -301,7 +407,11 @@ const createRepositoryWatcherCoordinator = ({ } watcher.checking = true; - const requestedResetIds = new Set([...watcher.resetRequested, ...resetIds]); + watcher.initialSnapshotFresh = false; + const requestedResetIds = new Set([ + ...watcher.resetRequested, + ...(adoptedInitialSnapshot ? [] : resetIds), + ]); watcher.resetRequested.clear(); const pendingSelfWrites = new Map(watcher.pendingSelfWrites); try { @@ -411,6 +521,7 @@ const createRepositoryWatcherCoordinator = ({ * @param {{ * getState: () => {focused: boolean; visible: boolean}; * id: number; + * initialSnapshot?: Promise | RepositoryWatcherSnapshot; * notify: (root: string) => void; * root: string; * }} subscriber @@ -425,6 +536,7 @@ const createRepositoryWatcherCoordinator = ({ if (!watcher) { watcher = { checking: false, + initialSnapshotFresh: false, pendingSelfWrites: new Map(), recheckRequested: false, resetRequested: new Set(), @@ -433,6 +545,13 @@ const createRepositoryWatcherCoordinator = ({ }; watchers.set(subscriber.root, watcher); } + if ( + watcher.snapshot == null && + watcher.initialSnapshot == null && + subscriber.initialSnapshot + ) { + watcher.initialSnapshot = Promise.resolve(subscriber.initialSnapshot); + } watcher.subscribers.set(subscriber.id, { changed: false, getState: subscriber.getState, @@ -506,7 +625,11 @@ const createRepositoryWatcherCoordinator = ({ const root = subscriberRoots.get(id); const watcher = root ? watchers.get(root) : undefined; if (watcher?.subscribers.get(id)?.changed === false) { - schedule(watcher, 0); + if (watcher.initialSnapshotFresh) { + schedulePoll(watcher); + } else { + schedule(watcher, 0); + } } }, @@ -540,11 +663,14 @@ const createRepositoryWatcherCoordinator = ({ }; module.exports = { + createRepositoryWatcherSnapshot, createRepositoryWatcherCoordinator, + getRepositoryWatcherInitialSnapshot, getRepositoryWatcherPollInterval, normalizeRepositoryWatcherPath, parseRepositoryWatcherStatus, - readRepositoryChangeSignature, readRepositoryWatcherSnapshot, repositoryWatcherSnapshotsMatchExpectedWrites, + setRepositoryWatcherInitialSnapshot, + transferRepositoryWatcherInitialSnapshot, }; From 3e5e7d0e46b1b7de8d045e146c9cc878ccc3de74 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Wed, 22 Jul 2026 19:08:46 -0500 Subject: [PATCH 08/17] Show the first repository review before History and comments finish Paint provider files first, then defer History, comments, walkthrough work, Git identity, and automatic exact-content hydration. Run hydration as one bounded batch with one state merge and terminal retry behavior, and include the observed provider head in exact identity so force-pushed results cannot land. --- core/App.tsx | 210 ++++++---- core/__tests__/App-plan.test.tsx | 1 + core/__tests__/App-shell.test.tsx | 5 +- core/__tests__/App-startup.test.tsx | 379 ++++++++++++++++++ ...RepositoryReviewHost-capabilities.test.tsx | 288 ++++++++++++- core/__tests__/RepositoryReviewHost.test.tsx | 192 ++++++++- core/__tests__/git-state.test.ts | 71 +++- core/app/RepositoryReviewHost.tsx | 280 ++++++++++--- core/app/components/Panels.tsx | 24 ++ core/app/components/PlanEditorView.tsx | 83 ++-- core/app/hooks/useAppReviewComments.ts | 5 +- core/app/hooks/useAppWalkthrough.ts | 72 +++- core/global.d.ts | 4 + core/types/review-identity.ts | 1 + electron/git-state/commit.cjs | 5 +- electron/git-state/merge-request.cjs | 11 +- electron/git-state/pull-request.cjs | 7 +- electron/git-state/working-tree.cjs | 38 +- electron/main.cjs | 14 +- electron/preload.cjs | 4 +- 20 files changed, 1451 insertions(+), 243 deletions(-) create mode 100644 core/__tests__/App-startup.test.tsx diff --git a/core/App.tsx b/core/App.tsx index c18edb47..878b67a0 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -11,7 +11,6 @@ import { defaultLaunchOptions, defaultTerminalHelperStatus, getAgentLabel, - HISTORY_PAGE_SIZE, } from './lib/app-constants.ts'; import type { RepositoryLoadError } from './lib/app-types.ts'; import { sortFiles } from './lib/files.ts'; @@ -28,7 +27,6 @@ import type { CodiffLaunchOptions, CodiffMarkdownDocument, GitIdentity, - HistoryEntry, NarrativeWalkthroughResult, TerminalHelperStatus, } from './types.ts'; @@ -46,8 +44,9 @@ export default function App() { const [config, setConfig] = useState(defaultConfig); const [features, setFeatures] = useState(defaultFeatures); const [gitIdentity, setGitIdentity] = useState(null); - const [history, setHistory] = useState>([]); + const [gitIdentityReady, setGitIdentityReady] = useState(false); const [launchOptions, setLaunchOptions] = useState(defaultLaunchOptions); + const [launchOptionsLoaded, setLaunchOptionsLoaded] = useState(false); const [loadError, setLoadError] = useState(null); const [planDocument, setPlanDocument] = useState(null); const [planLoadError, setPlanLoadError] = useState(null); @@ -58,103 +57,144 @@ export default function App() { const [terminalHelperStatus, setTerminalHelperStatus] = useState( defaultTerminalHelperStatus, ); + const [walkthroughLoading, setWalkthroughLoading] = useState(false); const [walkthroughResult, setWalkthroughResult] = useState(); const [walkthroughFileError, setWalkthroughFileError] = useState( null, ); - const loadRepository = useCallback(async (options: CodiffLaunchOptions) => { - const reloadSelection = consumeReloadSelection(); - const loadedState = await window.codiff.getRepositoryState( - resolveReloadSourceForLaunch(reloadSelection, options), - ); - const nextState = { ...loadedState, files: sortFiles(loadedState.files) }; - const bootstrap = resolveRepositoryReviewBootstrap({ - launchOptions: options, - reloadSelection, - state: nextState, - }); - const nextHistory = await window.codiff.getRepositoryHistory( - HISTORY_PAGE_SIZE, - bootstrap.historySource ?? undefined, - ); - const shouldLoadWalkthrough = Boolean(options.walkthrough || options.walkthroughFile); - const result = shouldLoadWalkthrough - ? await window.codiff.getNarrativeWalkthrough( - nextState.source, - bootstrap.forceInitialWalkthrough ? { force: true } : undefined, - ) - : undefined; - - setHistory(nextHistory.entries); - setWalkthroughResult(result); - if (options.walkthroughFile && result?.status === 'unavailable') { - setRepositoryBootstrap({ ...bootstrap, sidebarMode: 'history' }); - setWalkthroughFileError({ path: options.walkthroughFile, reason: result.reason }); - } else { - setRepositoryBootstrap(bootstrap); - setWalkthroughFileError(null); - } - setLoadError(null); - }, []); - useEffect(() => { let canceled = false; - let loadingPlan = false; - const load = async () => { - const options = await window.codiff.getLaunchOptions(); - if (canceled) { - return; - } - setLaunchOptions(options); + const loadOptionalBootstrap = () => { + void window.codiff.getConfig().then( + (nextConfig) => { + if (!canceled) { + setConfig(nextConfig); + } + }, + () => {}, + ); + void window.codiff.getFeatureFlags().then( + (nextFeatures) => { + if (!canceled) { + setFeatures(nextFeatures); + } + }, + () => {}, + ); + void window.codiff.getAgentSkillStatus().then( + (nextStatus) => { + if (!canceled) { + setAgentSkillStatus(nextStatus); + } + }, + () => {}, + ); + void window.codiff.getTerminalHelperStatus().then( + (nextStatus) => { + if (!canceled) { + setTerminalHelperStatus(nextStatus); + } + }, + () => {}, + ); + }; - const [nextConfig, nextFeatures, nextAgentSkillStatus, nextTerminalHelperStatus] = - await Promise.all([ - window.codiff.getConfig(), - window.codiff.getFeatureFlags(), - window.codiff.getAgentSkillStatus().catch(() => defaultAgentSkillStatus), - window.codiff.getTerminalHelperStatus().catch(() => defaultTerminalHelperStatus), - ]); - if (canceled) { - return; - } - setConfig(nextConfig); - setFeatures(nextFeatures); - setAgentSkillStatus(nextAgentSkillStatus); - setTerminalHelperStatus(nextTerminalHelperStatus); + void window.codiff.getLaunchOptions().then( + (options) => { + if (canceled) { + return; + } + setLaunchOptions(options); + setLaunchOptionsLoaded(true); + loadOptionalBootstrap(); - if (options.planFile) { - loadingPlan = true; - const document = await window.codiff.getMarkdownDocument({ - kind: 'plan', - path: options.planFile, - }); - if (!canceled) { - setPlanDocument(document); - setPlanLoadError(null); + if (options.planFile) { + void window.codiff + .getMarkdownDocument({ kind: 'plan', path: options.planFile }) + .then((document) => { + if (!canceled) { + setPlanDocument(document); + setPlanLoadError(null); + } + }) + .catch((error: unknown) => { + if (!canceled) { + setPlanLoadError(error instanceof Error ? error.message : String(error)); + } + }); + return; } - return; - } - await loadRepository(options); - }; - load().catch((error: unknown) => { - if (!canceled) { - if (loadingPlan) { - setPlanLoadError(error instanceof Error ? error.message : String(error)); - } else { + const reloadSelection = consumeReloadSelection(); + void window.codiff + .getRepositoryState(resolveReloadSourceForLaunch(reloadSelection, options)) + .then((loadedState) => { + if (canceled) { + return; + } + const state = { ...loadedState, files: sortFiles(loadedState.files) }; + const bootstrap = resolveRepositoryReviewBootstrap({ + launchOptions: options, + reloadSelection, + state, + }); + setRepositoryBootstrap(bootstrap); + setLoadError(null); + + if (!options.walkthrough && !options.walkthroughFile) { + setWalkthroughLoading(false); + setWalkthroughResult(undefined); + setWalkthroughFileError(null); + return; + } + + setWalkthroughLoading(true); + void window.codiff + .getNarrativeWalkthrough( + state.source, + bootstrap.forceInitialWalkthrough ? { force: true } : undefined, + ) + .catch( + (error: unknown): NarrativeWalkthroughResult => ({ + reason: error instanceof Error ? error.message : String(error), + status: 'unavailable', + }), + ) + .then((result) => { + if (canceled) { + return; + } + setWalkthroughLoading(false); + setWalkthroughResult(result); + setWalkthroughFileError( + options.walkthroughFile && result.status === 'unavailable' + ? { path: options.walkthroughFile, reason: result.reason } + : null, + ); + }); + }) + .catch((error: unknown) => { + if (!canceled) { + setLoadError(getRepositoryLoadError(error)); + } + }); + }, + (error: unknown) => { + if (!canceled) { + setLaunchOptionsLoaded(true); setLoadError(getRepositoryLoadError(error)); } - } - }); + }, + ); const unsubscribe = window.codiff.onConfigChanged(setConfig); return () => { canceled = true; unsubscribe(); }; - }, [loadRepository]); + }, []); useEffect(() => { let canceled = false; @@ -162,11 +202,13 @@ export default function App() { (identity) => { if (!canceled) { setGitIdentity(identity); + setGitIdentityReady(true); } }, () => { if (!canceled) { setGitIdentity(null); + setGitIdentityReady(true); } }, ); @@ -193,6 +235,10 @@ export default function App() { .finally(() => setAgentSkillInstalling(false)); }, []); + if (!launchOptionsLoaded) { + return
Loadingโ€ฆ
; + } + if (launchOptions.planFile) { if (planLoadError) { return ( @@ -258,8 +304,10 @@ export default function App() { bootstrap={repositoryBootstrap} config={config} gitIdentity={gitIdentity} - initialHistory={history} + gitIdentityReady={gitIdentityReady} + initialHistoryLoading initialWalkthroughFileError={walkthroughFileError} + initialWalkthroughLoading={walkthroughLoading} initialWalkthroughResult={walkthroughResult} key={ repositoryBootstrap.source.type === 'working-tree' diff --git a/core/__tests__/App-plan.test.tsx b/core/__tests__/App-plan.test.tsx index 8ae858eb..425b43ac 100644 --- a/core/__tests__/App-plan.test.tsx +++ b/core/__tests__/App-plan.test.tsx @@ -152,6 +152,7 @@ const createCodiffMock = (overrides: Partial = {}): Window['co openFile: vi.fn(async () => {}), openReleasePage: vi.fn(async () => {}), openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone: vi.fn(), resetCodeFontSize: vi.fn(async () => {}), resolvePullRequestUrl: vi.fn(async (value) => value), saveMarkdownDocument: vi.fn(async (request) => ({ diff --git a/core/__tests__/App-shell.test.tsx b/core/__tests__/App-shell.test.tsx index 91ad0e52..34adaa0e 100644 --- a/core/__tests__/App-shell.test.tsx +++ b/core/__tests__/App-shell.test.tsx @@ -88,7 +88,7 @@ test('App bootstraps the desktop shell before mounting the repository host', asy state, }), config, - initialHistory: [], + initialHistoryLoading: true, launchOptions: expect.objectContaining({ walkthrough: false }), walkthroughSharingEnabled: true, }), @@ -124,12 +124,13 @@ test('App restores branch History scope and reload deltas as one bootstrap value ref: 'feature', type: 'branch-working-tree', }); - expect(api.getRepositoryHistory).toHaveBeenCalledWith(30, branchSource); + expect(api.getRepositoryHistory).not.toHaveBeenCalled(); expect(hostProps.mock.lastCall?.[0]).toMatchObject({ bootstrap: { historySource: branchSource, reloadDeltaPaths: new Set(['src/branch.ts']), selectedPath: 'src/branch.ts', }, + initialHistoryLoading: true, }); }); diff --git a/core/__tests__/App-startup.test.tsx b/core/__tests__/App-startup.test.tsx new file mode 100644 index 00000000..a5de8a07 --- /dev/null +++ b/core/__tests__/App-startup.test.tsx @@ -0,0 +1,379 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { expect, test, vi } from 'vite-plus/test'; +import App from '../App.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; +import type { + GitIdentity, + GitSha, + NarrativeWalkthroughResult, + RepositoryHistory, + RepositoryState, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; + Worker?: typeof Worker; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; +class StubWorker extends EventTarget { + constructor(_scriptURL: string | URL, _options?: WorkerOptions) { + super(); + } + onerror = null; + onmessage = null; + postMessage() {} + terminate() {} +} +reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; + +const deferred = () => { + let reject!: (reason?: unknown) => void; + let resolve!: (value: Value) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, reject, resolve }; +}; +const unsubscribe = () => {}; +const gitSha = (character: string) => character.repeat(40) as GitSha; + +const repositoryState = { + branch: 'main', + files: [createChangedFile('src/startup.ts')], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' }, +} satisfies RepositoryState; + +const createAppApi = (overrides: Record = {}) => ({ + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + askReviewAssistant: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' as const })), + completePlan: vi.fn(async () => {}), + createWalkthroughCommit: vi.fn(async () => ({ + sha: gitSha('a'), + status: 'committed' as const, + })), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/skill' })), + getConfig: vi.fn(async () => createDefaultConfig()), + getDiffImageContent: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' as const })), + getDiffSectionContent: vi.fn(async () => { + throw new Error('Unexpected diff section load.'); + }), + getDiffSectionsContent: vi.fn(async () => ({ sections: [] })), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => ({ email: 'reviewer@example.com', name: 'Reviewer' })), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false })), + getMarkdownDocument: vi.fn(async () => ({ + content: '# Plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getNarrativeWalkthrough: vi.fn(async () => ({ + reason: 'Not used.', + status: 'unavailable' as const, + })), + getPlanReview: vi.fn(async () => null), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState: vi.fn(async () => repositoryState), + getReviewComments: vi.fn(async () => []), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + markPlanReady: vi.fn(async () => {}), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onMarkdownDocumentChanged: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onPlanCloseRequested: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughCommitOutput: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openConfigFile: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + saveMarkdownDocument: vi.fn(async (request: { content: string; kind: 'plan'; path: string }) => ({ + document: { + content: request.content, + id: `${request.kind}:${request.path}`, + kind: request.kind, + path: request.path, + version: 'next-version', + }, + status: 'saved' as const, + })), + savePlanReview: vi.fn(async (review) => review), + setDiffStyle: vi.fn(async () => {}), + setShowOutdated: vi.fn(async () => {}), + setWordWrap: vi.fn(async () => {}), + sharePlan: vi.fn(async () => ({ status: 'uploaded' as const, url: 'https://example.test/p' })), + shareWalkthrough: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://example.test/w', + })), + submitPullRequestComment: vi.fn(async () => { + throw new Error('Not used.'); + }), + submitPullRequestReview: vi.fn(async () => {}), + updateWalkthroughCommitMessage: vi.fn(async () => ({ + reason: 'Not used.', + status: 'unavailable' as const, + })), + ...overrides, +}); + +test('renders repository state before configuration and history finish loading', async () => { + const config = deferred>(); + const history = deferred(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getAgentSkillStatus: vi.fn(async () => ({ installed: true })), + getConfig: vi.fn(() => config.promise), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => null), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false })), + getRepositoryHistory: vi.fn(() => history.promise), + getRepositoryState: vi.fn(async () => ({ + branch: 'main', + files: [], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' as const }, + })), + getTerminalHelperStatus: vi.fn(async () => ({ installed: true })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const view = await renderReact(); + try { + await waitFor(() => expect(view.container.querySelector('main.review')).not.toBeNull()); + expect(window.codiff.getRepositoryHistory).toHaveBeenCalledOnce(); + expect(view.container.textContent).toContain('Loading history'); + + config.resolve(createDefaultConfig()); + history.resolve({ entries: [], root: '/repo' }); + } finally { + await view.cleanup(); + } +}); + +test('keeps a loaded repository usable when config or feature flags fail', async () => { + for (const failingCall of ['config', 'features'] as const) { + const api = createAppApi( + failingCall === 'config' + ? { + getConfig: vi.fn(async () => { + throw new Error('Config unavailable.'); + }), + } + : { + getFeatureFlags: vi.fn(async () => { + throw new Error('Features unavailable.'); + }), + }, + ); + window.codiff = api as unknown as Window['codiff']; + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('main.review')).not.toBeNull()); + expect(view.container.querySelector('.repository-change-banner.visible')).toBeNull(); + } +}); + +test('plan startup opens without repository or ancillary bootstrap completion', async () => { + const pending = new Promise(() => {}); + const api = createAppApi({ + getAgentSkillStatus: vi.fn(() => pending), + getConfig: vi.fn(() => pending), + getFeatureFlags: vi.fn(() => pending), + getGitIdentity: vi.fn(() => pending), + getLaunchOptions: vi.fn(async () => ({ + planFile: '/tmp/plan.md', + planResultFile: '/tmp/result.json', + repositoryPathProvided: true, + walkthrough: false, + })), + getRepositoryState: vi.fn(async () => repositoryState), + getTerminalHelperStatus: vi.fn(() => pending), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('.plan-shell')).not.toBeNull()); + expect(api.getRepositoryState).not.toHaveBeenCalled(); +}); + +test('plan startup stays usable when ancillary bootstrap calls reject', async () => { + const api = createAppApi({ + getAgentSkillStatus: vi.fn(async () => { + throw new Error('Skill unavailable.'); + }), + getConfig: vi.fn(async () => { + throw new Error('Config unavailable.'); + }), + getFeatureFlags: vi.fn(async () => { + throw new Error('Features unavailable.'); + }), + getLaunchOptions: vi.fn(async () => ({ + planFile: '/tmp/plan.md', + repositoryPathProvided: true, + walkthrough: false, + })), + getTerminalHelperStatus: vi.fn(async () => { + throw new Error('Helper unavailable.'); + }), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('.plan-shell')).not.toBeNull()); + expect(view.container.querySelector('.plan-share-button')).toBeNull(); +}); + +test('first-run classification does not wait for or get replaced by ancillary failures', async () => { + const pending = new Promise(() => {}); + const api = createAppApi({ + getConfig: vi.fn(async () => { + throw new Error('Config unavailable.'); + }), + getFeatureFlags: vi.fn(async () => { + throw new Error('Features unavailable.'); + }), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: false, walkthrough: false })), + getRepositoryState: vi.fn(async () => { + throw new Error('not a git repository'); + }), + getTerminalHelperStatus: vi.fn(() => pending), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.textContent).toContain('Open a Git repository')); + expect(view.container.textContent).not.toContain('Config unavailable.'); + expect(view.container.textContent).not.toContain('Features unavailable.'); +}); + +test('an asynchronous walkthrough-file failure switches the controlled mode to History', async () => { + const walkthrough = deferred(); + const api = createAppApi({ + getLaunchOptions: vi.fn(async () => ({ + repositoryPathProvided: true, + walkthrough: false, + walkthroughFile: '/tmp/walkthrough.json', + })), + getNarrativeWalkthrough: vi.fn(() => walkthrough.promise), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => + expect( + Array.from(view.container.querySelectorAll('[role="tab"]')) + .find((button) => button.textContent?.includes('Walkthrough')) + ?.getAttribute('aria-selected'), + ).toBe('true'), + ); + walkthrough.resolve({ reason: 'The walkthrough no longer matches.', status: 'unavailable' }); + await waitFor(() => expect(view.container.textContent).toContain('Showing history instead.')); + const historyTab = Array.from( + view.container.querySelectorAll('[role="tab"]'), + ).find((button) => button.textContent?.includes('History')); + await waitFor(() => expect(historyTab?.getAttribute('aria-selected')).toBe('true')); + expect(view.container.querySelector('.sidebar-walkthrough-status')).toBeNull(); +}); + +test('waits for Git identity resolution before reporting deferred completion', async () => { + const identity = deferred(); + const api = createAppApi({ + getGitIdentity: vi.fn(() => identity.promise), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('main.review')).not.toBeNull()); + await waitFor(() => + expect(api.reportInitialLoadMilestone).toHaveBeenCalledWith('first-usable-review-rendered'), + ); + await waitFor(() => expect(api.getRepositoryHistory).toHaveBeenCalledOnce()); + expect(api.reportInitialLoadMilestone).not.toHaveBeenCalledWith('deferred-review-data-complete'); + + await act(async () => { + identity.resolve({ email: 'reviewer@example.com', name: 'Reviewer' }); + }); + await waitFor(() => + expect(api.reportInitialLoadMilestone).toHaveBeenCalledWith('deferred-review-data-complete'), + ); + expect( + api.reportInitialLoadMilestone.mock.calls.filter( + ([milestone]) => milestone === 'deferred-review-data-complete', + ), + ).toHaveLength(1); +}); + +test('Git identity failure settles deferred completion without blocking first usable', async () => { + const identity = deferred(); + const api = createAppApi({ + getGitIdentity: vi.fn(() => identity.promise), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('main.review')).not.toBeNull()); + await waitFor(() => + expect(api.reportInitialLoadMilestone).toHaveBeenCalledWith('first-usable-review-rendered'), + ); + await waitFor(() => expect(api.getRepositoryHistory).toHaveBeenCalledOnce()); + expect(api.reportInitialLoadMilestone).not.toHaveBeenCalledWith('deferred-review-data-complete'); + + await act(async () => { + identity.reject(new Error('Identity unavailable.')); + }); + await waitFor(() => + expect(api.reportInitialLoadMilestone).toHaveBeenCalledWith('deferred-review-data-complete'), + ); + expect( + api.reportInitialLoadMilestone.mock.calls.filter( + ([milestone]) => milestone === 'deferred-review-data-complete', + ), + ).toHaveLength(1); +}); diff --git a/core/__tests__/RepositoryReviewHost-capabilities.test.tsx b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx index 4fa9c3b7..b6502c2a 100644 --- a/core/__tests__/RepositoryReviewHost-capabilities.test.tsx +++ b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx @@ -3,7 +3,7 @@ */ import { act } from 'react'; -import { expect, test, vi } from 'vite-plus/test'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; import type { CodiffConfig } from '../config/types.ts'; import { resolveRepositoryReviewBootstrap, @@ -64,8 +64,21 @@ Object.defineProperty(globalThis, 'localStorage', { value: createMemoryStorage(), }); +beforeEach(() => { + window.localStorage.clear(); +}); + const unsubscribe = () => {}; const gitSha = (character: string) => character.repeat(40) as GitSha; +const deferred = () => { + let resolve!: (value: Value) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, reject, resolve }; +}; const stateFor = ( source: ResolvedReviewSource, @@ -79,7 +92,7 @@ const stateFor = ( source, }); -const installWindowApi = () => { +const installWindowApi = (overrides: Record = {}) => { let findInDiffs: (() => void) | null = null; let copyPendingComments: (() => string | Promise) | null = null; let refreshRequest: (() => void) | null = null; @@ -99,6 +112,7 @@ const installWindowApi = () => { loadState: 'ready', patch: '@@ -1 +1 @@\n-old\n+new\n', })), + getDiffSectionsContent: vi.fn(async () => ({ sections: [] })), getNarrativeWalkthrough: vi.fn(async (): Promise => ({ reason: 'Not used.', status: 'unavailable', @@ -148,6 +162,7 @@ const installWindowApi = () => { submitPullRequestComment: vi.fn(async () => ({})), submitPullRequestReview: vi.fn(async () => ({ status: 'submitted', submittedDraftIds: [] })), updateWalkthroughCommitMessage: vi.fn(async () => ({ status: 'unavailable' })), + ...overrides, }; window.codiff = api as unknown as Window['codiff']; return { @@ -162,6 +177,9 @@ const installWindowApi = () => { type RenderHostOptions = { bootstrap?: Partial; + initialHistoryLoading?: boolean; + initialWalkthroughFileError?: { path: string; reason: string } | null; + initialWalkthroughLoading?: boolean; initialWalkthroughResult?: NarrativeWalkthroughResult; launchOptions?: CodiffLaunchOptions; }; @@ -189,6 +207,10 @@ const renderHost = async ( config={nextConfig} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady + initialHistoryLoading={options.initialHistoryLoading} + initialWalkthroughFileError={options.initialWalkthroughFileError} + initialWalkthroughLoading={options.initialWalkthroughLoading} initialWalkthroughResult={options.initialWalkthroughResult} launchOptions={resolvedLaunchOptions} walkthroughSharingEnabled @@ -218,16 +240,6 @@ const walkthroughFor = ( version: 4, }); -const deferred = () => { - let resolve!: (value: Value) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, reject, resolve }; -}; - const getSurfaceProps = () => { const props = surfaceProps.mock.lastCall?.[0] as ReviewSurfaceProps | undefined; expect(props).toBeDefined(); @@ -484,6 +496,36 @@ test('asks the review assistant with the flushed note value supplied by the surf } }); +test('opening a provider review performs no automatic content reads', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const source = { + headSha: gitSha('d'), + number: 14, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/14', + } as const; + const file = createChangedFile('src/stale-provider.ts', { kind: 'pull-request' }); + const deferredFile = { + ...file, + sections: file.sections.map((section) => ({ + ...section, + loadState: 'deferred' as const, + summary: { canLoad: true, reason: 'Exact contents are queued.' }, + })), + }; + const view = await renderHost(stateFor(source, [deferredFile])); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(api.getDiffSectionsContent).not.toHaveBeenCalled(); + expect(api.getDiffSectionContent).not.toHaveBeenCalled(); + } finally { + await view.cleanup(); + } +}); + test('loads deferred section content for supported Electron sources', async () => { surfaceProps.mockClear(); const { api } = installWindowApi(); @@ -1008,6 +1050,74 @@ test('ignores a walkthrough result after History switches sources', async () => } }); +test('ignores a deferred History result after the review source changes', async () => { + surfaceProps.mockClear(); + const firstHistory = deferred(); + const secondHistory = deferred(); + const { api } = installWindowApi({ + getRepositoryHistory: vi + .fn() + .mockImplementationOnce(() => firstHistory.promise) + .mockImplementationOnce(() => secondHistory.promise), + }); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/initial.ts')]); + const nextSource = { + headSha: gitSha('d'), + number: 42, + owner: 'example', + provider: 'github', + repo: 'repo', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/42', + } as const; + const nextSourceRequest = nextSource; + const nextState = stateFor(nextSource, [createChangedFile('src/next.ts')]); + api.getRepositoryState.mockResolvedValueOnce(nextState); + const view = await renderHost(initialState, undefined, { initialHistoryLoading: true }); + + try { + await waitFor(() => expect(api.getRepositoryHistory).toHaveBeenCalledTimes(1)); + await act(async () => + getSurfaceProps().capabilities?.history?.onSelectSource(nextSourceRequest), + ); + await waitFor(() => expect(api.getRepositoryHistory).toHaveBeenCalledTimes(2)); + + await act(async () => { + firstHistory.resolve({ + entries: [ + { + author: 'Stale', + committedAt: Date.now(), + parentShas: [], + sha: gitSha('a'), + subject: 'Stale history', + }, + ], + root: '/repo', + }); + await firstHistory.promise; + }); + expect(getSurfaceProps().capabilities?.history?.entries).toEqual([]); + + const currentEntry = { + author: 'Current', + committedAt: Date.now(), + parentShas: [], + sha: gitSha('b'), + subject: 'Current history', + }; + await act(async () => { + secondHistory.resolve({ entries: [currentEntry], root: '/repo' }); + await secondHistory.promise; + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.history?.entries).toEqual([currentEntry]), + ); + } finally { + await view.cleanup(); + } +}); + test('keeps provider and local drafts in their own History source sessions', async () => { surfaceProps.mockClear(); const { api } = installWindowApi(); @@ -1274,7 +1384,6 @@ test('uses the configured agent for placeholder walkthroughs and honors launch o await configuredView.cleanup(); } }); - test('applies non-whitespace config updates without reloading repository state', async () => { surfaceProps.mockClear(); const { api } = installWindowApi(); @@ -1386,3 +1495,156 @@ test('keeps failed whitespace reloads recoverable without applying stale state', await view.cleanup(); } }); + +test('hydrates provider comments after first usable and ignores a superseded source result', async () => { + surfaceProps.mockClear(); + const firstComments = deferred< + ReadonlyArray<{ + author: { login: string }; + body: string; + filePath: string; + id: string; + lineNumber: number; + side: 'additions'; + }> + >(); + const secondComments = deferred< + ReadonlyArray<{ + author: { login: string }; + body: string; + filePath: string; + id: string; + lineNumber: number; + side: 'additions'; + }> + >(); + const firstSource = { + headSha: gitSha('a'), + number: 12, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + } as const; + const secondSource = { + headSha: gitSha('b'), + number: 23, + projectPath: 'example/repo', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/example/repo/-/merge_requests/23', + } as const; + const getReviewComments = vi.fn((source: ResolvedReviewSource, _requestId?: string) => + source.type === 'pull-request' && source.headSha === firstSource.headSha + ? firstComments.promise + : secondComments.promise, + ); + const getRepositoryState = vi.fn(async () => ({ + ...stateFor(secondSource, [createChangedFile('src/second.ts')]), + reviewComments: [], + reviewCommentsLoadState: 'not-loaded' as const, + })); + installWindowApi({ getRepositoryState, getReviewComments }); + const view = await renderHost({ + ...stateFor(firstSource, [createChangedFile('src/first.ts')]), + reviewComments: [], + reviewCommentsLoadState: 'not-loaded', + }); + + try { + await waitFor(() => + expect(getReviewComments).toHaveBeenCalledWith( + firstSource, + expect.stringMatching(/^review-comments:/), + ), + ); + expect(surfaceProps).toHaveBeenCalled(); + const initialProps = getSurfaceProps(); + expect(initialProps.snapshot.repository.source).toEqual(firstSource); + expect(initialProps.snapshot.reviewComments ?? []).toEqual([]); + + await act(async () => initialProps.capabilities?.history?.onSelectSource(secondSource)); + await waitFor(() => expect(getRepositoryState).toHaveBeenCalledWith(secondSource)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(secondSource)); + await waitFor(() => + expect(getReviewComments).toHaveBeenCalledWith( + secondSource, + expect.stringMatching(/^review-comments:/), + ), + ); + + await act(async () => { + firstComments.resolve([ + { + author: { login: 'stale-reviewer' }, + body: 'Stale comment', + filePath: 'src/first.ts', + id: 'stale', + lineNumber: 1, + side: 'additions', + }, + ]); + await firstComments.promise; + }); + expect(getSurfaceProps().snapshot.reviewComments ?? []).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'stale' })]), + ); + + await act(async () => { + secondComments.resolve([ + { + author: { login: 'current-reviewer' }, + body: 'Current comment', + filePath: 'src/second.ts', + id: 'current', + lineNumber: 1, + side: 'additions', + }, + ]); + await secondComments.promise; + }); + await waitFor(() => + expect(getSurfaceProps().snapshot.reviewComments).toEqual([ + expect.objectContaining({ id: 'current' }), + ]), + ); + } finally { + await view.cleanup(); + } +}); + +test('keeps provider comment hydration failures visible and retryable', async () => { + surfaceProps.mockClear(); + const getReviewComments = vi + .fn() + .mockRejectedValueOnce(new Error('Provider comments are unavailable.')) + .mockResolvedValueOnce([]); + installWindowApi({ getReviewComments }); + const source = { + headSha: gitSha('c'), + number: 31, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/31', + } as const; + const view = await renderHost({ + ...stateFor(source), + reviewComments: [], + reviewCommentsLoadState: 'not-loaded', + }); + + try { + await waitFor(() => + expect(view.container.textContent).toContain('Provider comments are unavailable.'), + ); + const retry = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Retry', + ); + expect(retry).toBeDefined(); + await act(async () => retry?.click()); + await waitFor(() => expect(getReviewComments).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(source)); + expect(view.container.textContent).not.toContain('Provider comments are unavailable.'); + } finally { + await view.cleanup(); + } +}); diff --git a/core/__tests__/RepositoryReviewHost.test.tsx b/core/__tests__/RepositoryReviewHost.test.tsx index 5ef21575..6d1dff48 100644 --- a/core/__tests__/RepositoryReviewHost.test.tsx +++ b/core/__tests__/RepositoryReviewHost.test.tsx @@ -9,10 +9,10 @@ import { createDefaultConfig } from '../config/defaults.ts'; import { resolveRepositoryReviewBootstrap } from '../lib/repository-review-bootstrap.ts'; import type { DiffImageContentRequest, - DiffSectionContentRequest, GitSha, NarrativeWalkthrough, NarrativeWalkthroughResult, + RepositoryHistory, RepositoryState, } from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; @@ -37,6 +37,13 @@ const bootstrapFor = ( reloadSelection: null, state: repositoryState, }); +const deferred = () => { + let resolve!: (value: Value) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +}; const installCommitWindowApi = () => { window.codiff = { @@ -88,6 +95,7 @@ test('RepositoryReviewHost renders local reviews through the shared surface', as config={createDefaultConfig()} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady launchOptions={{ repositoryPathProvided: true, walkthrough: false }} />, ); @@ -139,6 +147,7 @@ test('RepositoryReviewHost renders provider reviews through the shared surface', config={createDefaultConfig()} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady initialHistory={[ { author: 'Ada Lovelace', @@ -199,6 +208,7 @@ test('working-tree reviews open the standalone commit view with generated seed t config={createDefaultConfig()} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady initialWalkthroughResult={{ status: 'ready', walkthrough }} launchOptions={{ repositoryPathProvided: true, walkthrough: false }} />, @@ -245,6 +255,7 @@ test('working-tree reviews restore the standalone commit view from bootstrap sta config={createDefaultConfig()} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady launchOptions={{ repositoryPathProvided: true, walkthrough: false }} />, ); @@ -270,6 +281,7 @@ test('standalone commit preparation remains available without a ready walkthroug config={createDefaultConfig()} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady initialWalkthroughResult={initialWalkthroughResult} launchOptions={{ repositoryPathProvided: true, walkthrough: false }} />, @@ -288,6 +300,65 @@ test('standalone commit preparation remains available without a ready walkthroug } }); +test('reports first usable before initial history and deferred completion after it', async () => { + const history = deferred(); + const getRepositoryHistory = vi.fn(() => history.promise); + const reportInitialLoadMilestone = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getRepositoryHistory, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone, + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const render = (gitIdentityReady: boolean) => ( + + ); + const view = await renderReact(render(false)); + try { + await waitFor(() => + expect(reportInitialLoadMilestone).toHaveBeenCalledWith('first-usable-review-rendered'), + ); + await waitFor(() => expect(getRepositoryHistory).toHaveBeenCalledOnce()); + expect(reportInitialLoadMilestone.mock.invocationCallOrder[0]!).toBeLessThan( + getRepositoryHistory.mock.invocationCallOrder[0]!, + ); + expect(reportInitialLoadMilestone).not.toHaveBeenCalledWith('deferred-review-data-complete'); + + await act(async () => history.resolve({ entries: [], root: '/repo' })); + expect(reportInitialLoadMilestone).not.toHaveBeenCalledWith('deferred-review-data-complete'); + + await view.rerender(render(true)); + await waitFor(() => + expect(reportInitialLoadMilestone).toHaveBeenCalledWith('deferred-review-data-complete'), + ); + } finally { + await view.cleanup(); + } +}); + test('RepositoryReviewHost hydrates deferred provider comments', async () => { const file = createChangedFile('src/review.ts'); const source = { @@ -305,7 +376,7 @@ test('RepositoryReviewHost hydrates deferred provider comments', async () => { reviewCommentsLoadState: 'not-loaded' as const, source, } satisfies RepositoryState; - const getReviewComments = vi.fn(async () => [ + const getReviewComments = vi.fn(async (_source: typeof source, _requestId?: string) => [ { author: { login: 'reviewer' }, body: 'Loaded through the R04 review-comments capability.', @@ -333,6 +404,7 @@ test('RepositoryReviewHost hydrates deferred provider comments', async () => { onWalkthroughProgress: vi.fn(() => unsubscribe), onWindowFullScreenChanged: vi.fn(() => unsubscribe), openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone: vi.fn(), resolvePullRequestUrl: vi.fn(async (value: string) => value), } as unknown as Window['codiff']; @@ -342,19 +414,23 @@ test('RepositoryReviewHost hydrates deferred provider comments', async () => { config={createDefaultConfig()} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady launchOptions={{ repositoryPathProvided: true, walkthrough: false }} />, ); await waitFor(() => { - expect(getReviewComments).toHaveBeenCalledWith(source); + expect(getReviewComments).toHaveBeenCalledWith( + source, + expect.stringMatching(/^review-comments:/), + ); expect(view.container.textContent).toContain( 'Loaded through the R04 review-comments capability.', ); }); }); -test('RepositoryReviewHost cancels an active lazy section request on unmount', async () => { +test('RepositoryReviewHost cancels active bulk provider hydration on unmount', async () => { const file = { ...createChangedFile('src/lazy.ts'), sections: [ @@ -382,15 +458,13 @@ test('RepositoryReviewHost cancels an active lazy section request on unmount', a url: 'https://github.com/example/review/pull/42', }, } satisfies RepositoryState; - const getDiffSectionContent = vi.fn( - (_request: DiffSectionContentRequest) => new Promise(() => {}), - ); + const getDiffSectionsContent = vi.fn((_request: { requestId?: string }) => new Promise(() => {})); const cancelDiffContentRequest = vi.fn(); window.codiff = { applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), cancelDiffContentRequest, dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), - getDiffSectionContent, + getDiffSectionsContent, getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), isWindowFullScreen: vi.fn(async () => false), onConfigChanged: vi.fn(() => unsubscribe), @@ -412,12 +486,13 @@ test('RepositoryReviewHost cancels an active lazy section request on unmount', a config={createDefaultConfig()} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady launchOptions={{ repositoryPathProvided: true, walkthrough: false }} />, ); - await waitFor(() => expect(getDiffSectionContent).toHaveBeenCalledOnce()); - const requestId = getDiffSectionContent.mock.calls[0]![0].requestId; - expect(requestId).toMatch(/^section:/); + await waitFor(() => expect(getDiffSectionsContent).toHaveBeenCalledOnce()); + const requestId = getDiffSectionsContent.mock.calls[0]![0].requestId; + expect(requestId).toMatch(/^bulk-sections:/); await view.cleanup(); expect(cancelDiffContentRequest).toHaveBeenCalledWith(requestId); }); @@ -477,6 +552,7 @@ test('RepositoryReviewHost cancels an active image request on unmount', async () config={createDefaultConfig()} disableCodeViewWorkerPool gitIdentity={null} + gitIdentityReady launchOptions={{ repositoryPathProvided: true, walkthrough: false }} />, ); @@ -486,3 +562,97 @@ test('RepositoryReviewHost cancels an active image request on unmount', async () await view.cleanup(); expect(cancelDiffContentRequest).toHaveBeenCalledWith(requestId); }); + +test('RepositoryReviewHost cancels provider comment enrichment on source replacement', async () => { + const source = { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github' as const, + targetBranch: 'main', + title: 'Cancelable comments', + type: 'pull-request' as const, + url: 'https://github.com/example/review/pull/42', + }; + const firstState = { + ...state, + files: [createChangedFile('src/review.ts')], + reviewCommentsLoadState: 'not-loaded' as const, + source, + } satisfies RepositoryState; + const commitSha = 'd'.repeat(40) as GitSha; + const secondState = { + ...state, + files: [], + source: { sha: commitSha, type: 'commit' as const }, + } satisfies RepositoryState; + const getReviewComments = vi.fn( + (_source: typeof source, _requestId?: string) => new Promise(() => {}), + ); + const getRepositoryState = vi.fn(async () => secondState); + const cancelDiffContentRequest = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest, + cancelNarrativeWalkthrough: vi.fn(async () => {}), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState, + getReviewComments, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughCommitOutput: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + await using view = await renderReact( + , + ); + + await waitFor(() => expect(getReviewComments).toHaveBeenCalledOnce()); + const requestId = getReviewComments.mock.calls[0]![1]; + expect(requestId).toMatch(/^review-comments:/); + const historyButton = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'History', + ); + await act(async () => historyButton?.click()); + let commitButton: HTMLButtonElement | undefined; + await waitFor(() => { + commitButton = Array.from( + view.container.querySelectorAll('.history-entry'), + ).find((candidate) => candidate.title === 'Commit source'); + expect(commitButton).not.toBeUndefined(); + }); + await act(async () => commitButton!.click()); + await waitFor(() => { + expect(getRepositoryState).toHaveBeenCalledWith({ ref: commitSha, type: 'commit' }); + expect(cancelDiffContentRequest).toHaveBeenCalledWith(requestId); + }); +}); diff --git a/core/__tests__/git-state.test.ts b/core/__tests__/git-state.test.ts index 69bb60ca..3e5ce470 100644 --- a/core/__tests__/git-state.test.ts +++ b/core/__tests__/git-state.test.ts @@ -109,7 +109,7 @@ type GitStateModule = { readRepositoryState: ( launchPath: string, source?: ReviewSource, - options?: { showWhitespace?: boolean }, + options?: { repositoryRoot?: string; showWhitespace?: boolean }, ) => Promise; readWalkthroughRepositoryState: ( launchPath: string, @@ -118,7 +118,7 @@ type GitStateModule = { ) => Promise; readWorkingTreeState: ( launchPath: string, - options?: { eagerContents?: boolean; showWhitespace?: boolean }, + options?: { eagerContents?: boolean; repositoryRoot?: string; showWhitespace?: boolean }, ) => Promise; resolvePullRequestContentRefs: ( repoRoot: string, @@ -173,12 +173,15 @@ const { submitPullRequestComment, validateRepositoryPath, } = require('../../electron/git-state.cjs') as GitStateModule; -const { readRepositoryWatcherSnapshot: readRepositoryChangeSignature } = - require('../../electron/repository-watcher.cjs') as { - readRepositoryWatcherSnapshot: ( - repoRoot: string, - ) => Promise<{ root: string; signature: string }>; - }; +const { + getRepositoryWatcherInitialSnapshot, + readRepositoryWatcherSnapshot: readRepositoryChangeSignature, +} = require('../../electron/repository-watcher.cjs') as { + getRepositoryWatcherInitialSnapshot: ( + state: object, + ) => Promise<{ pathSignatures: Record; root: string }> | undefined; + readRepositoryWatcherSnapshot: (repoRoot: string) => Promise<{ root: string; signature: string }>; +}; const git = async (repo: string, args: ReadonlyArray) => { const { stdout } = await execFileAsync('git', ['-C', repo, ...args], { @@ -1270,6 +1273,19 @@ test('readRepositoryState and history handle fresh repositories', async () => { }); }); +test('readRepositoryState reuses an already resolved startup root', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'notes/todo.txt', 'write tests\n'); + + const state = await readRepositoryState(join(repo, 'missing'), undefined, { + repositoryRoot: repo, + }); + + expect(state.root).toBe(repo); + expect(state.files.map((file) => file.path)).toEqual(['notes/todo.txt']); + }); +}); + test( 'readWalkthroughRepositoryState falls back to HEAD only for a clean implicit source', () => @@ -1299,8 +1315,17 @@ test( test('readWalkthroughRepositoryState keeps a fresh repository on the working tree', () => withRepo(async (repo) => { const state = await readWalkthroughRepositoryState(repo); + const initialSnapshot = getRepositoryWatcherInitialSnapshot(state); + expect(state.source).toEqual({ type: 'working-tree' }); expect(state.files).toEqual([]); + if (!initialSnapshot) { + throw new Error('Expected a fresh working tree to retain a watcher snapshot.'); + } + await expect(initialSnapshot).resolves.toMatchObject({ + pathSignatures: {}, + root: repo, + }); })); test('readWalkthroughRepositoryState preserves nested launch paths', () => @@ -1513,6 +1538,36 @@ test('readRepositoryState opens branch refs as current branch diffs against the }); }, 15_000); +test('readRepositoryState retains watcher snapshots through branch working-tree composition', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'file.txt', 'base\n'); + await commitAll(repo, 'initial commit'); + const baseBranch = (await git(repo, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); + + await git(repo, ['checkout', '-b', 'feature']); + await writeRepoFile(repo, 'file.txt', 'feature\n'); + await commitAll(repo, 'feature change'); + await writeRepoFile(repo, 'file.txt', 'local change\n'); + + const state = await readRepositoryState(repo, { + ref: baseBranch, + type: 'branch-working-tree', + }); + const initialSnapshot = getRepositoryWatcherInitialSnapshot(state); + + expect(state.source.type).toBe('branch-working-tree'); + if (!initialSnapshot) { + throw new Error('Expected the composed repository state to retain a watcher snapshot.'); + } + await expect(initialSnapshot).resolves.toMatchObject({ + pathSignatures: { + 'file.txt': expect.any(String), + }, + root: repo, + }); + }); +}, 30_000); + test('readRepositoryState reports missing branch refs clearly', async () => { await withRepo(async (repo) => { await writeRepoFile(repo, 'file.txt', 'base\n'); diff --git a/core/app/RepositoryReviewHost.tsx b/core/app/RepositoryReviewHost.tsx index 674fd6d8..df8370ba 100644 --- a/core/app/RepositoryReviewHost.tsx +++ b/core/app/RepositoryReviewHost.tsx @@ -4,6 +4,7 @@ import type { CodiffConfig } from '../config/types.ts'; import { HISTORY_PAGE_SIZE } from '../lib/app-constants.ts'; import { type RepositoryLoadError, + type ReviewComment, type ReviewIdentity, type SourceSession, } from '../lib/app-types.ts'; @@ -25,6 +26,7 @@ import { getRepositoryLoadError, getSourceKey, getSourceLabel, + getSourceRevisionKey, supportsLazyDiffContent, usesViewedFileState, } from '../lib/source.ts'; @@ -62,6 +64,7 @@ import { RepositoryLoadErrorPanel, RepositoryRefreshBanner, type RepositoryRefreshStatus, + ReviewCommentsLoadBanner, ReviewSourceLoading, UpdatePill, WalkthroughOutdatedBanner, @@ -84,10 +87,6 @@ const portableSurfaceCommandIds = new Set([ 'toggle-word-wrap', ]); const defaultReviewCommentsPrefix = '# Address these Review Comments'; -const getReviewCommentsSourceKey = (state: RepositoryState) => { - const headSha = state.source.type === 'pull-request' ? (state.source.headSha ?? '') : ''; - return `${state.root}:${getSourceKey(state.source)}:${headSha}`; -}; type ReviewAuthoringMode = 'local-notes' | 'provider-comments' | 'read-only'; @@ -175,12 +174,23 @@ const getCollapsedViewedPaths = ( files.filter((file) => viewedFiles[file.path] === file.fingerprint).map((file) => file.path), ); +const mergeStateReviewComments = ( + state: RepositoryState, + currentComments: ReadonlyArray, +) => + mergeReviewComments( + getReviewCommentsFromState(state), + currentComments.filter((comment) => !comment.isReadOnly), + ); + export type RepositoryReviewHostProps = { bootstrap: RepositoryReviewBootstrap; config: CodiffConfig; disableCodeViewWorkerPool?: boolean; gitIdentity: GitIdentity | null; + gitIdentityReady: boolean; initialHistory?: ReadonlyArray; + initialHistoryLoading?: boolean; initialWalkthroughFileError?: WalkthroughFileError | null; initialWalkthroughLoading?: boolean; initialWalkthroughResult?: NarrativeWalkthroughResult; @@ -193,7 +203,9 @@ export function RepositoryReviewHost({ config, disableCodeViewWorkerPool = false, gitIdentity, + gitIdentityReady, initialHistory = [], + initialHistoryLoading = false, initialWalkthroughFileError, initialWalkthroughLoading = false, initialWalkthroughResult, @@ -213,7 +225,8 @@ export function RepositoryReviewHost({ const [historyEntries, setHistoryEntries] = useState>(initialHistory); const [historyHasMore, setHistoryHasMore] = useState(initialHistory.length >= HISTORY_PAGE_SIZE); const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE_SIZE); - const [historyLoading, setHistoryLoading] = useState(false); + const [historyLoading, setHistoryLoading] = useState(initialHistoryLoading); + const [initialHistoryComplete, setInitialHistoryComplete] = useState(!initialHistoryLoading); const [historySource, setHistorySource] = useState(() => initialHistorySource === undefined ? (getHistorySource(initialState.source) ?? null) @@ -245,12 +258,20 @@ export function RepositoryReviewHost({ const diffContentRequestCounterRef = useRef(0); const diffContentRequestIdsRef = useRef>(new Set()); const loadingSectionKeysRef = useRef>(new Set()); - const reviewCommentsInFlightRef = useRef(null); const reviewCommentsRequestRef = useRef(0); const repositoryRefreshRequestRef = useRef(0); + const reviewCommentsInFlightRef = useRef<{ + generation: number; + request: number; + requestId: string; + sourceKey: string; + } | null>(null); const surfaceCommandBridgeRef = useRef(null); const sourceSessionsRef = useRef>(new Map()); const stateRef = useRef(state); + const firstUsableMilestoneReportedRef = useRef(false); + const deferredMilestoneReportedRef = useRef(false); + const walkthroughFileFallbackAppliedRef = useRef(false); const initialViewed = usesViewedFileState(initialState.source) ? readViewed(initialState.root) : {}; @@ -303,45 +324,76 @@ export function RepositoryReviewHost({ [toggleReviewViewed], ); + useEffect(() => { + if (!state || firstUsableMilestoneReportedRef.current) { + return; + } + firstUsableMilestoneReportedRef.current = true; + window.codiff.reportInitialLoadMilestone?.('first-usable-review-rendered'); + }, [state]); + const { askCodex, resetCommentFocus, reviewComments, reviewCommentsRef, setReviewComments } = useAppReviewComments({ + initialReviewComments: getReviewCommentsFromState(initialState), onCommentFileChange: bumpItemVersion, stateRef, }); + const hydrateReviewComments = useCallback( - (requestedState: RepositoryState) => { + (requestedState: RepositoryState | null = stateRef.current) => { if ( - requestedState.source.type !== 'pull-request' || - requestedState.reviewCommentsLoadState !== 'not-loaded' + requestedState?.source.type !== 'pull-request' || + requestedState.reviewCommentsLoadState === 'loaded' ) { return; } - const sourceKey = getReviewCommentsSourceKey(requestedState); + + const sourceKey = `${requestedState.root}:${getSourceRevisionKey(requestedState.source)}`; const generation = stateGenerationRef.current; - const inFlightKey = `${generation}:${sourceKey}`; - if (reviewCommentsInFlightRef.current === inFlightKey) { + const inFlight = reviewCommentsInFlightRef.current; + if (inFlight?.sourceKey === sourceKey && inFlight.generation === generation) { return; } - reviewCommentsInFlightRef.current = inFlightKey; + + if (inFlight) { + window.codiff.cancelDiffContentRequest(inFlight.requestId); + diffContentRequestIdsRef.current.delete(inFlight.requestId); + } const request = reviewCommentsRequestRef.current + 1; + const requestId = `review-comments:${request}`; reviewCommentsRequestRef.current = request; - const isCurrent = () => { + diffContentRequestIdsRef.current.add(requestId); + reviewCommentsInFlightRef.current = { generation, request, requestId, sourceKey }; + const isCurrentState = () => { const current = stateRef.current; return ( reviewCommentsRequestRef.current === request && stateGenerationRef.current === generation && - current?.source.type === 'pull-request' && - getReviewCommentsSourceKey(current) === sourceKey + current != null && + `${current.root}:${getSourceRevisionKey(current.source)}` === sourceKey ); }; + if (requestedState.reviewCommentsLoadState === 'failed') { + const retryingState = { + ...requestedState, + reviewCommentsError: undefined, + reviewCommentsLoadState: 'not-loaded' as const, + }; + stateRef.current = retryingState; + setState(retryingState); + } + void window.codiff - .getReviewComments(requestedState.source) + .getReviewComments(requestedState.source, requestId) .then((loadedComments) => { - if (!isCurrent()) { + if (!isCurrentState()) { + return; + } + const current = stateRef.current; + if (!current) { return; } - const current = stateRef.current!; const hydratedState = { ...current, reviewComments: loadedComments, @@ -350,18 +402,16 @@ export function RepositoryReviewHost({ }; stateRef.current = hydratedState; setState(hydratedState); - setReviewComments((comments) => - mergeReviewComments( - getReviewCommentsFromState(hydratedState), - comments.filter((comment) => !comment.isReadOnly), - ), - ); + setReviewComments((comments) => mergeStateReviewComments(hydratedState, comments)); }) .catch((error: unknown) => { - if (!isCurrent()) { + if (!isCurrentState()) { + return; + } + const current = stateRef.current; + if (!current) { return; } - const current = stateRef.current!; const failedState = { ...current, reviewCommentsError: error instanceof Error ? error.message : String(error), @@ -371,20 +421,14 @@ export function RepositoryReviewHost({ setState(failedState); }) .finally(() => { - if (reviewCommentsInFlightRef.current === inFlightKey) { + diffContentRequestIdsRef.current.delete(requestId); + if (reviewCommentsInFlightRef.current?.request === request) { reviewCommentsInFlightRef.current = null; } }); }, [setReviewComments], ); - - useEffect(() => { - if (state?.source.type === 'pull-request' && state.reviewCommentsLoadState === 'not-loaded') { - hydrateReviewComments(state); - } - }, [hydrateReviewComments, state]); - const { activeReviewCommandTargetRef, cancelWalkthroughRequest, @@ -431,6 +475,61 @@ export function RepositoryReviewHost({ stateGenerationRef, stateRef, }); + + useEffect(() => { + if ( + walkthroughFileFallbackAppliedRef.current || + !initialWalkthroughFileError || + initialWalkthroughResult?.status !== 'unavailable' || + !state || + getSourceRevisionKey(state.source) !== getSourceRevisionKey(bootstrap.source) + ) { + return; + } + walkthroughFileFallbackAppliedRef.current = true; + changeSidebarMode('history'); + }, [ + bootstrap.source, + changeSidebarMode, + initialWalkthroughFileError, + initialWalkthroughResult, + state, + ]); + + useEffect(() => { + if ( + !state || + state.source.type !== 'pull-request' || + state.reviewCommentsLoadState !== 'not-loaded' + ) { + return; + } + hydrateReviewComments(state); + }, [hydrateReviewComments, state]); + + useEffect(() => { + const reviewCommentsComplete = + !state || + state.source.type !== 'pull-request' || + state.reviewCommentsLoadState !== 'not-loaded'; + if ( + !firstUsableMilestoneReportedRef.current || + !initialHistoryComplete || + !gitIdentityReady || + !reviewCommentsComplete || + walkthroughLoading || + deferredMilestoneReportedRef.current + ) { + return; + } + deferredMilestoneReportedRef.current = true; + window.codiff.reportInitialLoadMilestone?.('deferred-review-data-complete'); + }, [ + gitIdentityReady, + initialHistoryComplete, + state, + walkthroughLoading, + ]); const [commentsMode, setCommentsMode] = useState(false); const activeSurfaceMode: ReviewMode = commentsMode ? 'comments' : sidebarMode; const changeSurfaceMode = useCallback( @@ -492,7 +591,7 @@ export function RepositoryReviewHost({ return; } - const sourceKey = getSourceKey(currentState.source); + const sourceKey = getSourceRevisionKey(currentState.source); const stateGeneration = stateGenerationRef.current; const reviewKey = getFileReviewIdentity(file).key; const key = `${currentState.root}:${sourceKey}:${section.id}`; @@ -514,7 +613,7 @@ export function RepositoryReviewHost({ if ( stateGenerationRef.current !== stateGeneration || stateRef.current?.root !== currentState.root || - getSourceKey(stateRef.current.source) !== sourceKey + getSourceRevisionKey(stateRef.current.source) !== sourceKey ) { return; } @@ -523,7 +622,7 @@ export function RepositoryReviewHost({ stateGenerationRef.current !== stateGeneration || !current || current.root !== currentState.root || - getSourceKey(current.source) !== sourceKey + getSourceRevisionKey(current.source) !== sourceKey ) { return current; } @@ -548,7 +647,7 @@ export function RepositoryReviewHost({ if ( stateGenerationRef.current !== stateGeneration || stateRef.current?.root !== currentState.root || - getSourceKey(stateRef.current.source) !== sourceKey + getSourceRevisionKey(stateRef.current.source) !== sourceKey ) { return; } @@ -557,7 +656,7 @@ export function RepositoryReviewHost({ stateGenerationRef.current !== stateGeneration || !current || current.root !== currentState.root || - getSourceKey(current.source) !== sourceKey + getSourceRevisionKey(current.source) !== sourceKey ) { return current; } @@ -635,7 +734,7 @@ export function RepositoryReviewHost({ } const sourceRequest = sourceRequestRef.current; const stateGeneration = stateGenerationRef.current; - const sourceKey = getSourceKey(currentState.source); + const sourceKey = getSourceRevisionKey(currentState.source); try { const nextState = await window.codiff.getRepositoryState( @@ -649,7 +748,7 @@ export function RepositoryReviewHost({ sourceRequestRef.current !== sourceRequest || stateGenerationRef.current !== stateGeneration || stateRef.current?.root !== currentState.root || - getSourceKey(stateRef.current.source) !== sourceKey + getSourceRevisionKey(stateRef.current.source) !== sourceKey ) { return false; } @@ -660,7 +759,7 @@ export function RepositoryReviewHost({ stateRef.current = orderedState; setState(orderedState); setLocalChangesDetected(false); - setReviewComments(getReviewCommentsFromState(orderedState)); + setReviewComments((comments) => mergeStateReviewComments(orderedState, comments)); if (walkthroughNeedsRefresh) { refreshWalkthroughForState(orderedState); } @@ -706,7 +805,7 @@ export function RepositoryReviewHost({ return; } - sourceSessionsRef.current.set(getSourceKey(currentState.source), { + sourceSessionsRef.current.set(getSourceRevisionKey(currentState.source), { collapsed: new Set(collapsedRef.current), expandedGenerated: new Set(expandedGeneratedRef.current), narrativeWalkthrough: narrativeWalkthroughRef.current, @@ -740,7 +839,12 @@ export function RepositoryReviewHost({ }, [setShareWalkthroughEnabled, walkthroughSharingEnabled]); useEffect(() => { - if (!state || !supportsLazyDiffContent(state.source) || !selectedPath) { + if ( + !state || + state.source.type === 'pull-request' || + !supportsLazyDiffContent(state.source) || + !selectedPath + ) { return; } @@ -809,7 +913,7 @@ export function RepositoryReviewHost({ setSelectedPath(nextSelectedPath); setReloadDeltaPaths(new Set()); setItemVersionByKey({}); - setReviewComments(getReviewCommentsFromState(orderedState)); + setReviewComments((comments) => mergeStateReviewComments(orderedState, comments)); setViewed(nextViewed); setCollapsed(getCollapsedViewedPaths(orderedState.files, nextViewed)); setExpandedGenerated(new Set()); @@ -866,6 +970,49 @@ export function RepositoryReviewHost({ historySourceRef.current = historySource; }, [historySource]); + useEffect(() => { + if (!initialHistoryLoading) { + return; + } + const request = historyRequestRef.current + 1; + historyRequestRef.current = request; + const requestedSource = historySource; + const requestedSourceKey = requestedSource ? getSourceRevisionKey(requestedSource) : ''; + const stateGeneration = stateGenerationRef.current; + queueMicrotask(() => { + if (historyRequestRef.current !== request) { + return; + } + setHistoryLoading(true); + void window.codiff + .getRepositoryHistory(HISTORY_PAGE_SIZE, requestedSource ?? undefined) + .then((nextHistory) => { + const currentSource = historySourceRef.current; + const currentSourceKey = currentSource ? getSourceRevisionKey(currentSource) : ''; + if ( + historyRequestRef.current !== request || + stateGenerationRef.current !== stateGeneration || + currentSourceKey !== requestedSourceKey + ) { + return; + } + setHistoryEntries(nextHistory.entries); + setHistoryHasMore(nextHistory.entries.length >= HISTORY_PAGE_SIZE); + }) + .catch(() => { + if (historyRequestRef.current === request) { + setHistoryHasMore(false); + } + }) + .finally(() => { + if (historyRequestRef.current === request) { + setHistoryLoading(false); + setInitialHistoryComplete(true); + } + }); + }); + }, [historySource, initialHistoryLoading]); + useEffect(() => { collapsedRef.current = collapsed; }, [collapsed]); @@ -969,11 +1116,19 @@ export function RepositoryReviewHost({ const nextLimit = historyLimit + HISTORY_PAGE_SIZE; const request = historyRequestRef.current + 1; historyRequestRef.current = request; + const requestedSourceKey = historySource ? getSourceRevisionKey(historySource) : ''; + const stateGeneration = stateGenerationRef.current; setHistoryLoading(true); window.codiff .getRepositoryHistory(nextLimit, historySource ?? undefined) .then((history) => { - if (historyRequestRef.current !== request) { + const currentSource = historySourceRef.current; + const currentSourceKey = currentSource ? getSourceRevisionKey(currentSource) : ''; + if ( + historyRequestRef.current !== request || + stateGenerationRef.current !== stateGeneration || + currentSourceKey !== requestedSourceKey + ) { return; } @@ -1015,6 +1170,10 @@ export function RepositoryReviewHost({ const request = repositoryRefreshRequestRef.current + 1; repositoryRefreshRequestRef.current = request; const sourceRequest = sourceRequestRef.current; + reviewCommentsRequestRef.current += 1; + reviewCommentsInFlightRef.current = null; + const historyRequest = historyRequestRef.current + 1; + historyRequestRef.current = historyRequest; const refreshSource = getRefreshSource(previousState.source); const refreshHistorySource = historySourceRef.current ? getRefreshSource(historySourceRef.current) @@ -1028,7 +1187,8 @@ export function RepositoryReviewHost({ .then(([nextState, history]) => { if ( repositoryRefreshRequestRef.current !== request || - sourceRequestRef.current !== sourceRequest + sourceRequestRef.current !== sourceRequest || + historyRequestRef.current !== historyRequest ) { return; } @@ -1070,6 +1230,8 @@ export function RepositoryReviewHost({ setCollapsed(reconciliation.collapsed); setHistoryEntries(history.entries); setHistoryHasMore(history.entries.length >= historyLimit); + setHistoryLoading(false); + setInitialHistoryComplete(true); setHistorySource(reconciliation.historySource); setReviewComments( mergeReviewComments(getReviewCommentsFromState(orderedState), pendingReviewComments), @@ -1084,8 +1246,11 @@ export function RepositoryReviewHost({ .catch((error: unknown) => { if ( repositoryRefreshRequestRef.current === request && - sourceRequestRef.current === sourceRequest + sourceRequestRef.current === sourceRequest && + historyRequestRef.current === historyRequest ) { + setHistoryLoading(false); + setInitialHistoryComplete(true); setRepositoryRefreshStatus({ phase: 'failed', reason: error instanceof Error ? error.message : String(error), @@ -1143,6 +1308,11 @@ export function RepositoryReviewHost({ repositoryRefreshRequestRef.current += 1; const request = sourceRequestRef.current + 1; sourceRequestRef.current = request; + reviewCommentsRequestRef.current += 1; + reviewCommentsInFlightRef.current = null; + historyRequestRef.current += 1; + setHistoryLoading(false); + setInitialHistoryComplete(true); setPendingSource(source); setRepositoryRefreshStatus(null); setWalkthroughStale(false); @@ -1164,7 +1334,7 @@ export function RepositoryReviewHost({ ...nextState, files: sortFiles(nextState.files), }; - const session = sourceSessionsRef.current.get(getSourceKey(orderedState.source)); + const session = sourceSessionsRef.current.get(getSourceRevisionKey(orderedState.source)); const nextViewed = session?.viewed ?? (usesViewedFileState(orderedState.source) ? readViewed(orderedState.root) : {}); @@ -1431,6 +1601,14 @@ export function RepositoryReviewHost({ status={repositoryRefreshStatus} walkthroughStale={walkthroughStale} /> + hydrateReviewComments(stateRef.current)} + reason={ + state.reviewCommentsLoadState === 'failed' + ? state.reviewCommentsError || 'Could not load review comments.' + : null + } + /> setWalkthroughFileError(null)} reason={walkthroughFileError?.reason ?? null} @@ -1555,7 +1733,7 @@ export function RepositoryReviewHost({ }} externalUrl={source.type === 'pull-request' ? source.url : undefined} gitIdentity={gitIdentity} - key={getSourceKey(source)} + key={getSourceRevisionKey(source)} keymap={config.keymap} onCommandBridgeChange={updateSurfaceCommandBridge} providerLabel={ diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 6180bca3..1349d4cc 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -218,6 +218,30 @@ export function UpdatePill({ ); } +export function ReviewCommentsLoadBanner({ + onRetry, + reason, +}: { + onRetry: () => void; + reason: string | null; +}) { + return ( +
+ + Review comments unavailable. + {reason ?? ''} + + +
+ ); +} + export function WalkthroughOutdatedBanner({ onDismiss, reason, diff --git a/core/app/components/PlanEditorView.tsx b/core/app/components/PlanEditorView.tsx index 509e0f83..0b97c26a 100644 --- a/core/app/components/PlanEditorView.tsx +++ b/core/app/components/PlanEditorView.tsx @@ -456,43 +456,52 @@ export function PlanEditorView({ useEffect(() => { let canceled = false; - void Promise.all([ - window.codiff - .getPlanReview() - .then((storedReview) => ({ storedReview })) - .catch((error: unknown) => ({ - loadError: error instanceof Error ? error.message : String(error), - storedReview: null, - })), - window.codiff.getGitIdentity().catch(() => null), - ]).then(([reviewResult, nextIdentity]) => { - if (canceled) { - return; - } - const { storedReview } = reviewResult; - const nextReview = storedReview - ? { - ...storedReview, - document: { - id: initialDocument.id, - path: initialDocument.path, - version: initialDocument.version, - }, - } - : createEmptyReview(initialDocument); - initialOpenThreadIdsRef.current = new Set( - storedReview?.threads - .filter((thread) => thread.status === 'open') - .map((thread) => thread.id) ?? [], - ); - reviewRef.current = nextReview; - setReview(nextReview); - setIdentity(nextIdentity); - if ('loadError' in reviewResult) { - setSaveError(reviewResult.loadError); - } - void window.codiff.markPlanReady(); - }); + void window.codiff + .getPlanReview() + .then((storedReview) => ({ storedReview })) + .catch((error: unknown) => ({ + loadError: error instanceof Error ? error.message : String(error), + storedReview: null, + })) + .then((reviewResult) => { + if (canceled) { + return; + } + const { storedReview } = reviewResult; + const nextReview = storedReview + ? { + ...storedReview, + document: { + id: initialDocument.id, + path: initialDocument.path, + version: initialDocument.version, + }, + } + : createEmptyReview(initialDocument); + initialOpenThreadIdsRef.current = new Set( + storedReview?.threads + .filter((thread) => thread.status === 'open') + .map((thread) => thread.id) ?? [], + ); + reviewRef.current = nextReview; + setReview(nextReview); + if ('loadError' in reviewResult) { + setSaveError(reviewResult.loadError); + } + void window.codiff.markPlanReady(); + }); + void window.codiff.getGitIdentity().then( + (nextIdentity) => { + if (!canceled) { + setIdentity(nextIdentity); + } + }, + () => { + if (!canceled) { + setIdentity(null); + } + }, + ); return () => { canceled = true; if (saveTimerRef.current) { diff --git a/core/app/hooks/useAppReviewComments.ts b/core/app/hooks/useAppReviewComments.ts index 70da3abd..d95ade13 100644 --- a/core/app/hooks/useAppReviewComments.ts +++ b/core/app/hooks/useAppReviewComments.ts @@ -5,15 +5,18 @@ import type { RepositoryState, ReviewAssistantRequest } from '../../types.ts'; import { useReviewCommentDrafts } from './useReviewCommentDrafts.ts'; type UseAppReviewCommentsOptions = { + initialReviewComments?: ReadonlyArray; onCommentFileChange: (filePath: string) => void; stateRef: RefObject; }; export function useAppReviewComments({ + initialReviewComments = [], onCommentFileChange, stateRef, }: UseAppReviewCommentsOptions) { - const [reviewComments, setReviewComments] = useState>([]); + const [reviewComments, setReviewComments] = + useState>(initialReviewComments); const commentDrafts = useReviewCommentDrafts({ comments: reviewComments, onCommentFileChange, diff --git a/core/app/hooks/useAppWalkthrough.ts b/core/app/hooks/useAppWalkthrough.ts index 62e905ca..828b70c6 100644 --- a/core/app/hooks/useAppWalkthrough.ts +++ b/core/app/hooks/useAppWalkthrough.ts @@ -6,7 +6,7 @@ import { createReviewCommandTarget, type ReviewCommandTarget, } from '../../lib/review-command-target.ts'; -import { getSourceKey } from '../../lib/source.ts'; +import { getSourceRevisionKey } from '../../lib/source.ts'; import type { ChangedFile, CodiffPreferences, @@ -78,12 +78,25 @@ export function useAppWalkthrough({ const walkthroughErrorRef = useRef(walkthroughError); const walkthroughLoadingRef = useRef(initialWalkthroughLoading); const walkthroughRequestRef = useRef(0); - const navigationResetKey = state ? `${state.root}:${getSourceKey(state.source)}` : ''; + const initialSourceKeyRef = useRef(state ? getSourceRevisionKey(state.source) : null); + const initialStateGenerationRef = useRef(0); + const navigationResetKey = state ? `${state.root}:${getSourceRevisionKey(state.source)}` : ''; const narrativeNavigation = useNarrativeNavigation( narrativeWalkthrough, state?.files ?? emptyFiles, navigationResetKey, ); + const setWalkthroughLoading = useCallback((loading: boolean) => { + walkthroughLoadingRef.current = loading; + setWalkthroughLoadingState(loading); + if (!loading) { + setWalkthroughProgress((current) => + current.phase == null + ? current + : { ...current, phase: null, stageRevision: current.stageRevision + 1 }, + ); + } + }, []); useEffect(() => { mainModeRef.current = mainMode; @@ -101,6 +114,45 @@ export function useAppWalkthrough({ walkthroughErrorRef.current = walkthroughError; }, [walkthroughError]); + useEffect(() => { + const currentSource = stateRef.current?.source ?? state?.source; + if ( + !initialWalkthroughResult || + !initialSourceKeyRef.current || + !currentSource || + stateGenerationRef.current !== initialStateGenerationRef.current || + getSourceRevisionKey(currentSource) !== initialSourceKeyRef.current + ) { + return; + } + const sourceKey = initialSourceKeyRef.current; + queueMicrotask(() => { + const latestSource = stateRef.current?.source; + if ( + stateGenerationRef.current !== initialStateGenerationRef.current || + !latestSource || + getSourceRevisionKey(latestSource) !== sourceKey + ) { + return; + } + setWalkthroughLoading(false); + if (initialWalkthroughResult.status === 'ready') { + setNarrativeWalkthrough(initialWalkthroughResult.walkthrough); + setWalkthroughError(null); + } else { + setWalkthroughError(initialWalkthroughResult); + } + setWalkthroughFileError(initialWalkthroughFileError); + }); + }, [ + initialWalkthroughFileError, + initialWalkthroughResult, + setWalkthroughLoading, + state?.source, + stateGenerationRef, + stateRef, + ]); + useEffect(() => { activeReviewCommandTargetRef.current = null; }, [navigationResetKey]); @@ -131,18 +183,6 @@ export function useAppWalkthrough({ setWalkthroughLoadingState(true); }, []); - const setWalkthroughLoading = useCallback((loading: boolean) => { - walkthroughLoadingRef.current = loading; - setWalkthroughLoadingState(loading); - if (!loading) { - setWalkthroughProgress((current) => - current.phase == null - ? current - : { ...current, phase: null, stageRevision: current.stageRevision + 1 }, - ); - } - }, []); - const cancelWalkthroughRequest = useCallback(() => { walkthroughRequestRef.current += 1; setWalkthroughLoading(false); @@ -175,12 +215,12 @@ export function useAppWalkthrough({ (source: RepositoryState['source'], options?: NarrativeWalkthroughRequestOptions) => { const request = walkthroughRequestRef.current + 1; walkthroughRequestRef.current = request; - const sourceKey = getSourceKey(source); + const sourceKey = getSourceRevisionKey(source); const stateGeneration = stateGenerationRef.current; const isCurrentState = () => walkthroughRequestRef.current === request && stateGenerationRef.current === stateGeneration && - getSourceKey(stateRef.current?.source ?? source) === sourceKey; + getSourceRevisionKey(stateRef.current?.source ?? source) === sourceKey; startWalkthroughLoading(); setWalkthroughError(null); return window.codiff diff --git a/core/global.d.ts b/core/global.d.ts index d72a6e5b..dcb7dca7 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -83,6 +83,7 @@ declare global { getRepositoryState: (source?: ReviewSource) => Promise; getReviewComments: ( source: Extract, + requestId?: string, ) => Promise>; getTerminalHelperStatus: () => Promise; getUpdateStatus: () => Promise; @@ -114,6 +115,9 @@ declare global { openFile: (path: string, lineNumber?: number) => Promise; openReleasePage: () => Promise; openRepositoryFolder: () => Promise; + reportInitialLoadMilestone: ( + name: 'deferred-review-data-complete' | 'first-usable-review-rendered', + ) => void; resetCodeFontSize: () => Promise; resolvePullRequestUrl: (value: string) => Promise; saveMarkdownDocument: ( diff --git a/core/types/review-identity.ts b/core/types/review-identity.ts index 7c2c6f15..6968637f 100644 --- a/core/types/review-identity.ts +++ b/core/types/review-identity.ts @@ -147,6 +147,7 @@ export type DiffSectionContentRequest = { source?: ResolvedReviewSource; }; export type DiffSectionsContentRequest = { + requestId?: string; source: Extract; }; diff --git a/electron/git-state/commit.cjs b/electron/git-state/commit.cjs index 7172aeac..f199efb1 100644 --- a/electron/git-state/commit.cjs +++ b/electron/git-state/commit.cjs @@ -16,6 +16,7 @@ const { readDiffSectionContent: readWorkingTreeDiffSectionContent, readWorkingTreeState, } = require('./working-tree.cjs'); +const { transferRepositoryWatcherInitialSnapshot } = require('../repository-watcher.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile @@ -621,7 +622,7 @@ const mergeBranchAndWorkingTreeState = (branchState, workingTreeState) => { .map((path) => mergeChangedFile(branchFilesByPath.get(path), workingTreeFilesByPath.get(path))) .sort(fileSort); - return { + return transferRepositoryWatcherInitialSnapshot(workingTreeState, { ...branchState, files, generatedAt: Date.now(), @@ -631,7 +632,7 @@ const mergeBranchAndWorkingTreeState = (branchState, workingTreeState) => { ref: branchSource.ref, type: 'branch-working-tree', }, - }; + }); }; /** diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index 915a9e5a..eab58d67 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -273,7 +273,7 @@ const createMergeRequestSource = (mergeRequest, metadata) => ({ ...(typeof metadata.description === 'string' && metadata.description.trim() ? { description: metadata.description.trim() } : {}), - headSha: metadata.sha, + headSha: metadata.diff_refs?.head_sha || metadata.sha, host: mergeRequest.host, number: mergeRequest.number, projectPath: mergeRequest.projectPath, @@ -495,7 +495,14 @@ const readMergeRequestReviewComments = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const mergeRequest = parseGitLabMergeRequestUrl(source.url); selectMergeRequestRemote(repoRoot, mergeRequest); - return readMergeRequestComments(repoRoot, mergeRequest); + const transport = createMergeRequestTransport(repoRoot, mergeRequest); + const comments = await readMergeRequestComments(repoRoot, mergeRequest, transport); + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const headSha = metadata.diff_refs?.head_sha || metadata.sha; + if (source.headSha && headSha !== source.headSha) { + throw new Error('The merge request head changed. Refresh before loading review comments.'); + } + return comments; }; /** diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index 43746934..d7e7f4f5 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -918,7 +918,12 @@ const readPullRequestReviewComments = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const pullRequest = parseGitHubPullRequestUrl(source.url); await assertPullRequestMatchesRepository(repoRoot, pullRequest); - return readPullRequestComments(repoRoot, pullRequest); + const comments = await readPullRequestComments(repoRoot, pullRequest); + const metadata = await readPullRequestMetadata(repoRoot, pullRequest); + if (source.headSha && metadata.head?.sha !== source.headSha) { + throw new Error('The pull request head changed. Refresh before loading review comments.'); + } + return comments; }; /** diff --git a/electron/git-state/working-tree.cjs b/electron/git-state/working-tree.cjs index 08081b20..e9e0ab15 100644 --- a/electron/git-state/working-tree.cjs +++ b/electron/git-state/working-tree.cjs @@ -489,22 +489,34 @@ const gitOrEmpty = async (repoRoot, args) => { } }; +const gitIdentityReads = new Map(); + /** @param {string} launchPath */ -const readGitIdentity = async (launchPath) => { - const [configuredName, configuredEmail] = await Promise.all([ +const readGitIdentity = (launchPath) => { + const existing = gitIdentityReads.get(launchPath); + if (existing) { + return existing; + } + const read = Promise.all([ gitOrEmpty(launchPath, ['config', '--get', 'user.name']), gitOrEmpty(launchPath, ['config', '--get', 'user.email']), - ]); - const email = configuredEmail.trim(); - const name = configuredName.trim(); - - return { - email, - gravatarUrl: email - ? `https://www.gravatar.com/avatar/${getGravatarHash(email)}?s=80&d=identicon` - : undefined, - name, - }; + ]) + .then(([configuredName, configuredEmail]) => { + const email = configuredEmail.trim(); + const name = configuredName.trim(); + return { + email, + gravatarUrl: email + ? `https://www.gravatar.com/avatar/${getGravatarHash(email)}?s=80&d=identicon` + : undefined, + name, + }; + }) + .finally(() => { + gitIdentityReads.delete(launchPath); + }); + gitIdentityReads.set(launchPath, read); + return read; }; module.exports = { diff --git a/electron/main.cjs b/electron/main.cjs index 7fc4ee93..da9a0242 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1489,7 +1489,9 @@ ipcMain.handle('codiff:getRepositoryState', async (event, source) => { : await readRepositoryStateWithConfig(repositoryPath, source || launchOptions?.source); storeResolvedRepositoryState(event.sender.id, state); rememberLastRepositoryPath(state.root); - void resetRepositoryWatcher(event.sender.id, state.root); + if (!initialState) { + void resetRepositoryWatcher(event.sender.id, state.root); + } return state; }); @@ -1853,7 +1855,9 @@ ipcMain.handle('codiff:getDiffSectionContent', async (event, request) => { ipcMain.handle('codiff:getDiffSectionsContent', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return readDiffSectionsContent(repositoryPath, request); + return runDiffContentRequest(event, request, () => + readDiffSectionsContent(repositoryPath, request), + ); }); ipcMain.handle('codiff:getDiffImageContent', async (event, request) => { @@ -1875,12 +1879,14 @@ ipcMain.handle('codiff:getRepositoryHistory', async (event, limit, source) => { return listRepositoryHistory(repositoryPath, limit, source); }); -ipcMain.handle('codiff:getReviewComments', async (event, source) => { +ipcMain.handle('codiff:getReviewComments', async (event, source, requestId) => { if (source?.type !== 'pull-request') { throw new Error('Review comments require a pull-request source.'); } const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return readReviewComments(repositoryPath, source); + return runDiffContentRequest(event, { requestId }, () => + readReviewComments(repositoryPath, source), + ); }); ipcMain.handle('codiff:getGitIdentity', async (event) => { diff --git a/electron/preload.cjs b/electron/preload.cjs index b00d5e40..89a9beb6 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -40,7 +40,8 @@ const codiff = { getRepositoryHistory: (limit, source) => ipcRenderer.invoke('codiff:getRepositoryHistory', limit, source), getRepositoryState: (source) => ipcRenderer.invoke('codiff:getRepositoryState', source), - getReviewComments: (source) => ipcRenderer.invoke('codiff:getReviewComments', source), + getReviewComments: (source, requestId) => + ipcRenderer.invoke('codiff:getReviewComments', source, requestId), getTerminalHelperStatus: () => ipcRenderer.invoke('codiff:getTerminalHelperStatus'), getUpdateStatus: () => ipcRenderer.invoke('codiff:getUpdateStatus'), getNarrativeWalkthrough: (source, options) => @@ -143,6 +144,7 @@ const codiff = { openFile: (path, lineNumber) => ipcRenderer.invoke('codiff:openFile', path, lineNumber), openRepositoryFolder: () => ipcRenderer.invoke('codiff:openRepositoryFolder'), resolvePullRequestUrl: (value) => ipcRenderer.invoke('codiff:resolvePullRequestUrl', value), + reportInitialLoadMilestone: (name) => ipcRenderer.send('codiff:initialLoadMilestone', name), setDiffStyle: (value) => ipcRenderer.invoke('codiff:setDiffStyle', value), setShowOutdated: (value) => ipcRenderer.invoke('codiff:setShowOutdated', value), setWordWrap: (value) => ipcRenderer.invoke('codiff:setWordWrap', value), From 83f60660994c5cf389cd8798cefec5bd31fe94b6 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 12:02:11 -0500 Subject: [PATCH 09/17] Introduce a walkthrough generation scheduler for batches of model work Queue generation units, run up to three at once by default, report progress for each unit, preserve successful units when another unit fails, and reuse a unit only when its reviewed diff, prompt, and model settings still match. Phase 1 schedules the current narrative as one unit; later phases can schedule per-commit and comparison units. Switching History sources cancels work for the previous source, and Core exports the scheduler and progress APIs for other hosts. --- core/App.css | 76 ++++- core/ReviewSurface.tsx | 69 ++++- core/__tests__/App-plan.test.tsx | 1 + core/__tests__/App-startup.test.tsx | 1 + ...RepositoryReviewHost-capabilities.test.tsx | 91 +++++- .../ReviewSurface-capabilities.test.tsx | 93 ++++++ core/__tests__/WalkthroughProgress.test.tsx | 76 +++++ core/__tests__/codiff-share-cli.test.ts | 6 +- core/__tests__/useAppWalkthrough.test.tsx | 21 ++ .../walkthrough-generation-tasks.test.ts | 204 +++++++++++++ core/app/RepositoryReviewHost.tsx | 1 + core/app/components/Sidebar.tsx | 2 + .../walkthrough/WalkthroughProgress.tsx | 76 ++++- core/app/hooks/useAppWalkthrough.ts | 35 ++- core/global.d.ts | 1 + core/index.ts | 16 ++ core/lib/walkthrough-generation-tasks.ts | 271 ++++++++++++++++++ core/package.json | 7 +- core/tsconfig.build.json | 2 + core/types/generation.ts | 39 ++- core/walkthrough-generation.ts | 9 + electron/__tests__/agent.test.ts | 20 ++ electron/__tests__/forge-package.test.ts | 3 +- .../__tests__/reviewed-diff-signature.test.ts | 40 +++ .../shared-walkthrough-upload.test.ts | 54 +++- .../walkthrough-generation-cache-key.test.ts | 92 ++++++ ...walkthrough-generation-coordinator.test.ts | 67 +++++ .../walkthrough-model-invocation.test.ts | 130 +++++++++ .../__tests__/walkthrough-progress.test.ts | 20 +- electron/agent.cjs | 19 ++ electron/claude.cjs | 2 + electron/codex.cjs | 4 + electron/main.cjs | 215 +++++++++++--- electron/narrative-walkthrough.cjs | 47 ++- electron/opencode.cjs | 11 + electron/pi.cjs | 2 + electron/preload.cjs | 1 + electron/reviewed-diff-signature.cjs | 38 +++ electron/shared-walkthrough-upload.cjs | 28 +- electron/walkthrough-generation-bridge.cjs | 27 ++ electron/walkthrough-generation-cache-key.cjs | 47 +++ .../walkthrough-generation-coordinator.cjs | 67 +++++ electron/walkthrough-model-invocation.cjs | 98 +++++++ electron/walkthrough-progress.cjs | 8 +- forge.config.cjs | 1 + scripts/verify-package-runtime.mjs | 22 +- 46 files changed, 2050 insertions(+), 110 deletions(-) create mode 100644 core/__tests__/walkthrough-generation-tasks.test.ts create mode 100644 core/lib/walkthrough-generation-tasks.ts create mode 100644 core/walkthrough-generation.ts create mode 100644 electron/__tests__/reviewed-diff-signature.test.ts create mode 100644 electron/__tests__/walkthrough-generation-cache-key.test.ts create mode 100644 electron/__tests__/walkthrough-generation-coordinator.test.ts create mode 100644 electron/__tests__/walkthrough-model-invocation.test.ts create mode 100644 electron/reviewed-diff-signature.cjs create mode 100644 electron/walkthrough-generation-bridge.cjs create mode 100644 electron/walkthrough-generation-cache-key.cjs create mode 100644 electron/walkthrough-generation-coordinator.cjs create mode 100644 electron/walkthrough-model-invocation.cjs diff --git a/core/App.css b/core/App.css index 89011ef2..dab90a47 100644 --- a/core/App.css +++ b/core/App.css @@ -1569,7 +1569,6 @@ html[data-codiff-platform='darwin'] .sidebar { } .sidebar-walkthrough-status.codex .walkthrough-progress { - animation: codex-loading-pulse 1600ms ease-in-out infinite; color: var(--sidebar-text); user-select: none; } @@ -1583,16 +1582,89 @@ html[data-codiff-platform='darwin'] .sidebar { width: 100%; } +.walkthrough-progress-copy { + display: grid; + gap: 6px; + min-width: 0; + text-align: left; + width: 100%; +} + +.walkthrough-progress-heading { + align-items: baseline; + display: inline-flex; + gap: 0.5ch; + justify-content: center; + max-width: 100%; + min-width: 0; +} + .walkthrough-progress-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.walkthrough-progress-units { + display: grid; + gap: 3px; + list-style: none; + margin: 0; + max-height: 12rem; + overflow: auto; + padding: 0; +} + +.walkthrough-progress-unit { + align-items: baseline; + color: var(--muted, inherit); + display: grid; + font-size: 12px; + font-weight: 400; + gap: 8px; + grid-template-columns: 5.5rem minmax(0, 1fr); + opacity: 0.9; +} + +.walkthrough-progress-unit.is-generating { + color: inherit; + opacity: 1; +} + +.walkthrough-progress-unit.is-generating .walkthrough-progress-unit-status { + animation: codex-loading-pulse 1600ms ease-in-out infinite; +} + +.walkthrough-progress-unit.is-ready { + opacity: 0.75; +} + +.walkthrough-progress-unit.is-failed { + color: var(--danger, #a33a3a); +} + +.walkthrough-progress-unit-status { + font-variant-numeric: tabular-nums; + opacity: 0.8; + text-transform: lowercase; +} + +.walkthrough-progress-unit-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.walkthrough-progress-unit-detail { + grid-column: 2; + overflow-wrap: anywhere; + white-space: normal; +} + .walkthrough-progress-timer { flex: 0 0 2ch; font-variant-numeric: tabular-nums; - margin-left: 0.5ch; text-align: left; visibility: hidden; width: 2ch; diff --git a/core/ReviewSurface.tsx b/core/ReviewSurface.tsx index 6e6c595a..e612e0e5 100644 --- a/core/ReviewSurface.tsx +++ b/core/ReviewSurface.tsx @@ -25,6 +25,7 @@ import { SidebarGeneralCommentList, } from './app/components/merge-request/GeneralComments.tsx'; import { + AgentUnavailablePanel, isTerminalPullRequestMergeState, CopyCommentsButton, DiffSearchPanel, @@ -134,6 +135,7 @@ import type { SubmittedReviewComment, WalkthroughCommitMessageResult, WalkthroughCommitResult, + WalkthroughGenerationProgress, } from './types.ts'; export { ReadOnlyGeneralCommentCard } from './app/components/merge-request/GeneralComments.tsx'; @@ -163,6 +165,12 @@ const commentMatchesHashTarget = ( comment.id === target || comment.threadId === target || comment.url?.slice(comment.url.lastIndexOf('#') + 1) === target; +const agentUnavailableCodes = new Set>([ + 'CODEX_NOT_FOUND', + 'CLAUDE_NOT_FOUND', + 'OPENCODE_NOT_FOUND', + 'PI_NOT_FOUND', +]); const readSharedSidebarWidth = () => typeof localStorage === 'undefined' ? SIDEBAR_DEFAULT_WIDTH : readSidebarWidth(); @@ -356,6 +364,7 @@ export type ReviewWalkthroughCapabilities = { commit?: CommitHandler; commitOutput?: CommitOutputSubscriber; error?: Pick | null; + generationProgress?: WalkthroughGenerationProgress | null; onGenerate?: () => Promise | void; onShare?: () => Promise | void; progress?: ReactNode; @@ -1627,12 +1636,23 @@ export function ReviewSurface({ }, [walkthroughStatus]); const walkthroughReady = !walkthrough || walkthroughStatus === 'ready'; const walkthroughFailed = walkthroughStatus === 'failed'; + const walkthroughGenerationProgress = walkthrough?.generationProgress ?? null; + const failedGenerationUnits = + walkthroughGenerationProgress?.units?.filter((unit) => unit.status === 'failed') ?? []; + const agentUnavailable = + walkthroughFailed && + walkthrough?.error?.code != null && + agentUnavailableCodes.has(walkthrough.error.code); const walkthroughStatusTitle = walkthroughFailed ? 'Walkthrough unavailable' : 'Generating walkthroughโ€ฆ'; const walkthroughStatusDescription = walkthroughFailed - ? (walkthrough?.error?.reason ?? 'Fix the generation issue, then try again.') - : null; + ? agentUnavailable + ? (walkthrough?.error?.reason ?? 'Install the configured agent and try again.') + : (walkthroughGenerationProgress?.summary ?? + walkthrough?.error?.reason ?? + 'Fix the generation issue, then try again.') + : (walkthroughGenerationProgress?.summary ?? null); const shellTheme = snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; const requestWalkthrough = () => { @@ -1862,13 +1882,16 @@ export function ReviewSurface({ className={`sidebar-walkthrough-status${walkthroughFailed ? '' : ' codex'}`} title={walkthroughStatusDescription ?? undefined} > - {walkthrough?.progress ? ( - walkthrough.progress - ) : walkthroughFailed ? ( + {walkthroughFailed && failedGenerationUnits.length === 0 ? ( {walkthroughStatusTitle} + ) : !walkthroughFailed && walkthrough?.progress ? ( + walkthrough.progress ) : ( @@ -1996,20 +2019,42 @@ export function ReviewSurface({ ) : walkthroughFailed ? (
- {walkthroughStatusTitle} -

{walkthroughStatusDescription}

-
- -
+ {agentUnavailable ? ( + changeSidebarMode('tree')} + reason={walkthroughStatusDescription ?? undefined} + /> + ) : ( + <> + {walkthroughStatusTitle} +

{walkthroughStatusDescription}

+ {failedGenerationUnits.length > 0 ? ( + + ) : null} +
+ +
+ + )}
) : (
{walkthrough?.progress ?? ( diff --git a/core/__tests__/App-plan.test.tsx b/core/__tests__/App-plan.test.tsx index 425b43ac..32179a1f 100644 --- a/core/__tests__/App-plan.test.tsx +++ b/core/__tests__/App-plan.test.tsx @@ -76,6 +76,7 @@ const createCodiffMock = (overrides: Partial = {}): Window['co status: 'unavailable' as const, })), cancelDiffContentRequest: vi.fn(), + cancelNarrativeWalkthrough: vi.fn(async () => {}), completePlan: vi.fn(async () => {}), createWalkthroughCommit: vi.fn(async () => ({ sha: '0'.repeat(40) as GitSha, diff --git a/core/__tests__/App-startup.test.tsx b/core/__tests__/App-startup.test.tsx index a5de8a07..1ac7c7ce 100644 --- a/core/__tests__/App-startup.test.tsx +++ b/core/__tests__/App-startup.test.tsx @@ -63,6 +63,7 @@ const repositoryState = { const createAppApi = (overrides: Record = {}) => ({ applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), askReviewAssistant: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' as const })), + cancelNarrativeWalkthrough: vi.fn(async () => {}), completePlan: vi.fn(async () => {}), createWalkthroughCommit: vi.fn(async () => ({ sha: gitSha('a'), diff --git a/core/__tests__/RepositoryReviewHost-capabilities.test.tsx b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx index b6502c2a..eaf1edea 100644 --- a/core/__tests__/RepositoryReviewHost-capabilities.test.tsx +++ b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx @@ -18,6 +18,7 @@ import type { RepositoryHistory, RepositoryState, ResolvedReviewSource, + WalkthroughProgressEvent, } from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { renderReact, waitFor } from './helpers/react.tsx'; @@ -97,11 +98,13 @@ const installWindowApi = (overrides: Record = {}) => { let copyPendingComments: (() => string | Promise) | null = null; let refreshRequest: (() => void) | null = null; let repositoryChanged: (() => void) | null = null; + let walkthroughProgress: ((progress: WalkthroughProgressEvent) => void) | null = null; let windowFullScreenChanged: ((isFullScreen: boolean) => void) | null = null; const api = { applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), askReviewAssistant: vi.fn(async () => ({ reply: 'Checked.', status: 'ready' as const })), cancelDiffContentRequest: vi.fn(), + cancelNarrativeWalkthrough: vi.fn(async () => {}), createWalkthroughCommit: vi.fn(async () => ({ status: 'committed' as const })), dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), getDiffImageContent: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' })), @@ -144,7 +147,10 @@ const installWindowApi = (overrides: Record = {}) => { }), onUpdateStatusChanged: vi.fn(() => unsubscribe), onWalkthroughCommitOutput: vi.fn(() => unsubscribe), - onWalkthroughProgress: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn((callback: (progress: WalkthroughProgressEvent) => void) => { + walkthroughProgress = callback; + return unsubscribe; + }), onWindowFullScreenChanged: vi.fn((callback: (isFullScreen: boolean) => void) => { windowFullScreenChanged = callback; return unsubscribe; @@ -171,6 +177,7 @@ const installWindowApi = (overrides: Record = {}) => { findInDiffs: () => findInDiffs, refreshRequest: () => refreshRequest, repositoryChanged: () => repositoryChanged, + walkthroughProgress: () => walkthroughProgress, windowFullScreenChanged: () => windowFullScreenChanged, }; }; @@ -1009,9 +1016,9 @@ test('keeps desktop file state, fullscreen state, and active walkthrough targets } }); -test('ignores a walkthrough result after History switches sources', async () => { +test('cancels active walkthrough work when History switches sources', async () => { surfaceProps.mockClear(); - const { api } = installWindowApi(); + const { api, walkthroughProgress } = installWindowApi(); const sourceAState = stateFor({ type: 'working-tree' }, [createChangedFile('src/a.ts')]); const sourceB = { sha: gitSha('b'), type: 'commit' } as const; const sourceBRequest = { ref: gitSha('b'), type: 'commit' } as const; @@ -1028,10 +1035,29 @@ test('ignores a walkthrough result after History switches sources', async () => await Promise.resolve(); }); expect(api.getNarrativeWalkthrough).toHaveBeenCalledWith({ type: 'working-tree' }, undefined); + await act(async () => + walkthroughProgress()?.({ + generation: { phase: 'generating', summary: 'Source A progress.' }, + }), + ); + expect(getSurfaceProps().capabilities?.walkthrough?.generationProgress?.summary).toBe( + 'Source A progress.', + ); await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(sourceBRequest)); + expect(api.cancelNarrativeWalkthrough).toHaveBeenCalledOnce(); await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(sourceB)); + await act(async () => + walkthroughProgress()?.({ + generation: { phase: 'generating', summary: 'Stale source A progress.' }, + phase: 'response-received', + }), + ); + expect(getSurfaceProps().capabilities?.walkthrough?.generationProgress?.summary).toBe( + 'Source A progress.', + ); + await act(async () => { pending.resolve({ status: 'ready', @@ -1050,6 +1076,65 @@ test('ignores a walkthrough result after History switches sources', async () => } }); +test('source selection without active generation skips main-process cancellation', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/initial.ts')]); + const nextSource = { ref: gitSha('c'), type: 'commit' } as const; + api.getRepositoryState.mockResolvedValueOnce( + stateFor({ sha: gitSha('c'), type: 'commit' }, [createChangedFile('src/next.ts')]), + ); + const view = await renderHost(initialState); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(nextSource)); + await waitFor(() => + expect(getSurfaceProps().snapshot.repository.source).toEqual({ + sha: gitSha('c'), + type: 'commit', + }), + ); + expect(api.cancelNarrativeWalkthrough).not.toHaveBeenCalled(); + } finally { + await view.cleanup(); + } +}); + +test('same-source mode changes do not cancel active walkthrough generation', async () => { + surfaceProps.mockClear(); + const pending = deferred(); + const { api } = installWindowApi({ + getNarrativeWalkthrough: vi.fn(() => pending.promise), + }); + const view = await renderHost( + stateFor({ type: 'working-tree' }, [createChangedFile('src/current.ts')]), + ); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + await Promise.resolve(); + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.walkthrough?.status).toBe('generating'), + ); + + await act(async () => getSurfaceProps().activeMode?.onChange('walkthrough')); + await act(async () => getSurfaceProps().activeMode?.onChange('tree')); + await act(async () => getSurfaceProps().activeMode?.onChange('comments')); + expect(api.cancelNarrativeWalkthrough).not.toHaveBeenCalled(); + + await act(async () => { + pending.resolve({ reason: 'Not used.', status: 'unavailable' }); + await pending.promise; + }); + } finally { + await view.cleanup(); + } +}); + test('ignores a deferred History result after the review source changes', async () => { surfaceProps.mockClear(); const firstHistory = deferred(); diff --git a/core/__tests__/ReviewSurface-capabilities.test.tsx b/core/__tests__/ReviewSurface-capabilities.test.tsx index a29a287b..8a42bb0f 100644 --- a/core/__tests__/ReviewSurface-capabilities.test.tsx +++ b/core/__tests__/ReviewSurface-capabilities.test.tsx @@ -501,6 +501,99 @@ test('preserves structured walkthrough failure metadata', async () => { expect(view.container.textContent).toContain('Pi CLI was not found.'); }); +test('renders tailored recovery for every unavailable agent executable', async () => { + for (const { agent, code, displayLabel, reasonLabel } of [ + { agent: 'codex', code: 'CODEX_NOT_FOUND', displayLabel: 'Codex', reasonLabel: 'Codex' }, + { + agent: 'claude', + code: 'CLAUDE_NOT_FOUND', + displayLabel: 'Claude Code', + reasonLabel: 'Claude', + }, + { + agent: 'opencode', + code: 'OPENCODE_NOT_FOUND', + displayLabel: 'OpenCode', + reasonLabel: 'OpenCode', + }, + { agent: 'pi', code: 'PI_NOT_FOUND', displayLabel: 'Pi', reasonLabel: 'Pi' }, + ] as const) { + await using view = await renderSurface({ + capabilities: { + walkthrough: { + error: { code, reason: `${reasonLabel} CLI was not found.` }, + status: 'failed', + }, + }, + initialMode: 'walkthrough', + snapshot: { + ...snapshot, + walkthrough: { ...snapshot.walkthrough, agent }, + }, + }); + + expect(view.container.textContent).toContain(`${displayLabel} CLI not found`); + expect(view.container.textContent).toContain(`${reasonLabel} CLI was not found.`); + expect(findButton(view.container, 'Review Files')).not.toBeUndefined(); + expect(findButton(view.container, 'Try again')).toBeUndefined(); + await act(async () => findButton(view.container, 'Review Files')?.click()); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('true'); + } +}); + +test('keeps generic and task-level generation failures retryable', async () => { + const onGenerate = vi.fn(async () => {}); + await using generic = await renderSurface({ + capabilities: { + walkthrough: { + error: { reason: 'Generation stopped.' }, + onGenerate, + status: 'failed', + }, + }, + initialMode: 'walkthrough', + }); + expect(findButton(generic.container, 'Try again')).not.toBeUndefined(); + await act(async () => findButton(generic.container, 'Try again')?.click()); + expect(onGenerate).toHaveBeenCalledTimes(1); + + const retryFailedTasks = vi.fn(async () => {}); + await using partial = await renderSurface({ + capabilities: { + walkthrough: { + error: { reason: 'One task failed.' }, + generationProgress: { + completed: 1, + phase: 'generating-units', + summary: 'One walkthrough task failed.', + total: 2, + units: [ + { id: 'ready', label: 'Ready task', status: 'ready' }, + { + detail: 'Model request failed.', + id: 'failed', + label: 'Failed task', + status: 'failed', + }, + ], + }, + onGenerate: retryFailedTasks, + status: 'failed', + }, + }, + initialMode: 'walkthrough', + }); + expect(partial.container.textContent).toContain('Model request failed.'); + expect(findButton(partial.container, 'Retry failed tasks')).not.toBeUndefined(); + await act(async () => findButton(partial.container, 'Retry failed tasks')?.click()); + expect(retryFailedTasks).toHaveBeenCalledTimes(1); + await partial.render({ + capabilities: { walkthrough: { status: 'ready' } }, + initialMode: 'walkthrough', + }); + expect(findButton(partial.container, 'Retry failed tasks')).toBeUndefined(); +}); + test('renders the walkthrough unread indicator from host capability state', async () => { await using view = await renderSurface({ capabilities: { walkthrough: { unread: true } }, diff --git a/core/__tests__/WalkthroughProgress.test.tsx b/core/__tests__/WalkthroughProgress.test.tsx index 8d09a97d..2458e4b8 100644 --- a/core/__tests__/WalkthroughProgress.test.tsx +++ b/core/__tests__/WalkthroughProgress.test.tsx @@ -88,3 +88,79 @@ test('reserves timer space, reveals 3s without shifting, and resets for each sta expect(timer()?.textContent).toBe('3s'); expect(timer()?.classList.contains('visible')).toBe(true); }); + +test('renders format-neutral task progress and aggregate counts', async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('1/2'); + expect(container.textContent).toContain('Preparing the second task.'); + expect(container.textContent).toContain('done'); + expect(container.textContent).toContain('preparing'); + expect(container.textContent).toContain('Second task'); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +}); + +test('keeps failed task details visible after generation stops', async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('failed'); + expect(container.textContent).toContain('Model response was invalid.'); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +}); diff --git a/core/__tests__/codiff-share-cli.test.ts b/core/__tests__/codiff-share-cli.test.ts index 9f2af8f5..2ad44e16 100644 --- a/core/__tests__/codiff-share-cli.test.ts +++ b/core/__tests__/codiff-share-cli.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import { join, resolve } from 'node:path'; @@ -165,7 +165,7 @@ test('headless share uploads the canonical snapshot and prints its URL', async ( expect(body.snapshot).toMatchObject({ kind: 'codiff-walkthrough-share', repository: { - root: await realpath(repositoryPath), + root: '[redacted]', source: { type: 'working-tree' }, }, version: 1, @@ -450,7 +450,7 @@ exit 1 expect(stderr).toBe(''); expect(stdout).toBe(`${origin}/w/generated-walkthrough\n`); expect(body.snapshot.repository).toMatchObject({ - root: await realpath(repositoryPath), + root: '[redacted]', source: { sha: head, type: 'commit' }, title: 'Update example', }); diff --git a/core/__tests__/useAppWalkthrough.test.tsx b/core/__tests__/useAppWalkthrough.test.tsx index c9c66c66..50f174fd 100644 --- a/core/__tests__/useAppWalkthrough.test.tsx +++ b/core/__tests__/useAppWalkthrough.test.tsx @@ -113,6 +113,7 @@ test('walkthrough controller lazily generates, refreshes, and transitions modes' })); await using view = await renderWalkthroughController({ codiff: { + cancelNarrativeWalkthrough: vi.fn(async () => {}), getNarrativeWalkthrough, onWalkthroughProgress: vi.fn(() => () => {}), }, @@ -175,6 +176,7 @@ test('walkthrough controller routes progress, commit APIs, and sharing through c const state = createRepositoryState(); await using view = await renderWalkthroughController({ codiff: { + cancelNarrativeWalkthrough: vi.fn(async () => {}), createWalkthroughCommit, onWalkthroughProgress: vi.fn((callback) => { onProgress = callback; @@ -194,6 +196,25 @@ test('walkthrough controller routes progress, commit APIs, and sharing through c }); expect(getController().walkthroughProgress.phase).toBe('agent-generation'); expect(getController().walkthroughProgress.stageRevision).toBe(1); + await act(async () => { + onProgress?.({ + generation: { + completed: 1, + phase: 'generating-units', + summary: 'Completed the first task.', + total: 2, + units: [ + { id: 'first', label: 'First task', status: 'ready' }, + { id: 'second', label: 'Second task', status: 'pending' }, + ], + }, + }); + }); + expect(getController().walkthroughProgress).toMatchObject({ + generation: { completed: 1, phase: 'generating-units', total: 2 }, + phase: 'agent-generation', + stageRevision: 1, + }); await act(async () => { await getController().commitWalkthrough({ body: 'Body', diff --git a/core/__tests__/walkthrough-generation-tasks.test.ts b/core/__tests__/walkthrough-generation-tasks.test.ts new file mode 100644 index 00000000..9322ad71 --- /dev/null +++ b/core/__tests__/walkthrough-generation-tasks.test.ts @@ -0,0 +1,204 @@ +import { expect, test, vi } from 'vite-plus/test'; +import { + runWalkthroughGenerationTasks, + type WalkthroughGenerationTask, +} from '../lib/walkthrough-generation-tasks.ts'; +import type { GenerationMetadata, GenerationProfile } from '../types.ts'; + +const profile: GenerationProfile = { + agent: 'codex', + authoringVersion: 'test-v1', + modelCandidates: ['primary', 'fallback'], +}; + +const metadata = (model = 'primary'): GenerationMetadata => ({ + agent: profile.agent, + generatedAt: '2026-08-04T00:00:00.000Z', + model, + profile, +}); + +const task = ( + id: string, + run: WalkthroughGenerationTask['run'], +): WalkthroughGenerationTask => ({ + id, + identity: id, + label: `${id} task`, + profile, + run, + semanticInput: { prompt: `Explain ${id}.` }, +}); + +test('retains successes so retry invokes only failed tasks', async () => { + const firstRun = vi.fn(async ({ semanticInput }: { semanticInput: { prompt: string } }) => { + if (semanticInput.prompt.includes('second')) { + throw new Error('model unavailable'); + } + return { generationMetadata: metadata(), output: semanticInput.prompt }; + }); + const tasks = [task('first', firstRun), task('second', firstRun)]; + + const first = await runWalkthroughGenerationTasks({ tasks }); + + expect(first.status).toBe('failed'); + expect(first.components).toHaveLength(1); + if (first.status !== 'failed') { + return; + } + expect(first.failures).toEqual([ + { error: 'model unavailable', identity: 'second', label: 'second task' }, + ]); + + const retry = vi.fn(async ({ semanticInput }: { semanticInput: { prompt: string } }) => ({ + generationMetadata: metadata(), + output: semanticInput.prompt, + })); + const second = await runWalkthroughGenerationTasks({ + reusableComponents: first.components, + tasks: [task('first', retry), task('second', retry)], + }); + + expect(second.status).toBe('ready'); + expect(retry).toHaveBeenCalledTimes(1); + expect(retry.mock.calls[0]?.[0].semanticInput).toEqual({ prompt: 'Explain second.' }); +}); + +test('bounds concurrent model work while preserving task order', async () => { + let active = 0; + let maximumActive = 0; + const pending: Array<() => void> = []; + const run = vi.fn( + ({ semanticInput }: { semanticInput: { prompt: string } }) => + new Promise<{ generationMetadata: GenerationMetadata; output: string }>((resolve) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + pending.push(() => { + active -= 1; + resolve({ generationMetadata: metadata(), output: semanticInput.prompt }); + }); + }), + ); + const generation = runWalkthroughGenerationTasks({ + concurrency: 3, + tasks: ['first', 'second', 'third', 'fourth'].map((id) => task(id, run)), + }); + + await vi.waitFor(() => expect(pending).toHaveLength(3)); + pending[0]!(); + await vi.waitFor(() => expect(pending).toHaveLength(4)); + pending.slice(1).forEach((complete) => complete()); + const result = await generation; + + expect(maximumActive).toBe(3); + expect(result.status).toBe('ready'); + expect(result.components.map((component) => component.identity)).toEqual([ + 'first', + 'second', + 'third', + 'fourth', + ]); +}); + +test('cancellation prevents queued tasks from starting and suppresses readiness', async () => { + const controller = new AbortController(); + const started: Array = []; + const run = vi.fn( + ({ semanticInput }: { semanticInput: { prompt: string } }) => + new Promise<{ generationMetadata: GenerationMetadata; output: string }>(() => { + started.push(semanticInput.prompt); + }), + ); + const generation = runWalkthroughGenerationTasks({ + concurrency: 1, + signal: controller.signal, + tasks: [task('first', run), task('second', run)], + }); + await vi.waitFor(() => expect(started).toEqual(['Explain first.'])); + + controller.abort(new Error('The review changed.')); + const result = await generation; + + expect(result).toMatchObject({ reason: 'The review changed.', status: 'cancelled' }); + expect(started).toEqual(['Explain first.']); +}); + +test('reports generic unit progress without topology-specific fields', async () => { + const progress = vi.fn(); + + const result = await runWalkthroughGenerationTasks({ + onProgress: progress, + tasks: [ + task('narrative', async () => ({ + generationMetadata: metadata('fallback'), + output: 'Ready', + })), + ], + }); + + expect(result.status).toBe('ready'); + expect(progress.mock.calls.map(([event]) => event.phase)).toEqual([ + 'preparing', + 'generating', + 'generating', + 'combining', + ]); + expect(progress.mock.calls.at(-1)?.[0]).toMatchObject({ completed: 1, total: 1 }); +}); + +test('does not reuse a component with invalid successful-call metadata', async () => { + const run = vi.fn(async () => ({ generationMetadata: metadata(), output: 'fresh' })); + const reusableComponents = [ + { + generationMetadata: metadata('outside-policy'), + identity: 'narrative', + output: 'stale', + profile, + semanticInput: { prompt: 'Explain narrative.' }, + }, + ]; + + const result = await runWalkthroughGenerationTasks({ + reusableComponents, + tasks: [task('narrative', run)], + }); + + expect(result.status).toBe('ready'); + expect(run).toHaveBeenCalledTimes(1); + expect(result.components.at(-1)?.output).toBe('fresh'); +}); + +test('reuses only exact semantic inputs and preserves declared task order', async () => { + const run = vi.fn(async ({ semanticInput }: { semanticInput: { prompt: string } }) => ({ + generationMetadata: metadata(), + output: `fresh: ${semanticInput.prompt}`, + })); + const reusableComponents = [ + { + generationMetadata: metadata(), + identity: 'second', + output: 'cached second', + profile, + semanticInput: { prompt: 'Explain second.' }, + }, + { + generationMetadata: metadata(), + identity: 'first', + output: 'stale first', + profile, + semanticInput: { prompt: 'Explain an older first.' }, + }, + ]; + + const result = await runWalkthroughGenerationTasks({ + reusableComponents, + tasks: [task('first', run), task('second', run)], + }); + + expect(result.status).toBe('ready'); + expect(run).toHaveBeenCalledTimes(1); + expect(result.components.map(({ identity, output }) => ({ identity, output }))).toEqual([ + { identity: 'first', output: 'fresh: Explain first.' }, + { identity: 'second', output: 'cached second' }, + ]); +}); diff --git a/core/app/RepositoryReviewHost.tsx b/core/app/RepositoryReviewHost.tsx index df8370ba..f1fa9ecd 100644 --- a/core/app/RepositoryReviewHost.tsx +++ b/core/app/RepositoryReviewHost.tsx @@ -1718,6 +1718,7 @@ export function RepositoryReviewHost({ } : {}), error: walkthroughError, + generationProgress: walkthroughProgress.generation, onGenerate: () => loadNarrativeWalkthrough(source), onShare: enabledShareWalkthrough, progress: ( diff --git a/core/app/components/Sidebar.tsx b/core/app/components/Sidebar.tsx index 823e30f0..03fa9700 100644 --- a/core/app/components/Sidebar.tsx +++ b/core/app/components/Sidebar.tsx @@ -92,6 +92,7 @@ export function Sidebar({ walkthroughError: WalkthroughError | null; walkthroughLoading: boolean; walkthroughProgress: { + generation: import('../../types.ts').WalkthroughGenerationProgress | null; phase: import('../../types.ts').WalkthroughProgressPhase | null; responseLabelIndex: number; stageRevision: number; @@ -168,6 +169,7 @@ export function Sidebar({
diff --git a/core/app/components/walkthrough/WalkthroughProgress.tsx b/core/app/components/walkthrough/WalkthroughProgress.tsx index a116272b..64e24aca 100644 --- a/core/app/components/walkthrough/WalkthroughProgress.tsx +++ b/core/app/components/walkthrough/WalkthroughProgress.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { WalkthroughProgressPhase } from '../../../types.ts'; +import type { WalkthroughGenerationProgress, WalkthroughProgressPhase } from '../../../types.ts'; export const walkthroughResponseLabels = [ 'Building walkthroughโ€ฆ', @@ -15,12 +15,35 @@ export const nextWalkthroughResponseLabelIndex = (current: number) => const TIMER_THRESHOLD_SECONDS = 3; +const unitStatusLabel = ( + status: NonNullable[number]['status'], +) => { + switch (status) { + case 'ready': + return 'done'; + case 'generating': + return 'generating'; + case 'preparing': + return 'preparing'; + case 'failed': + return 'failed'; + default: + return 'pending'; + } +}; + export function WalkthroughProgress({ + detail, + label: labelOverride, phase, + progress, responseLabelIndex, stageRevision, }: { + detail?: string | null; + label?: string; phase: WalkthroughProgressPhase | null; + progress?: WalkthroughGenerationProgress | null; responseLabelIndex: number; stageRevision: number; }) { @@ -40,20 +63,55 @@ export function WalkthroughProgress({ const elapsedSeconds = timerState.stageRevision === stageRevision ? timerState.elapsedSeconds : 0; const showTimer = elapsedSeconds >= TIMER_THRESHOLD_SECONDS; const label = - phase === 'agent-generation' + labelOverride ?? + (phase === 'agent-generation' ? 'Analyzing changesโ€ฆ' : phase === 'response-received' ? walkthroughResponseLabels[Math.abs(responseLabelIndex) % walkthroughResponseLabels.length] - : 'Generating walkthroughโ€ฆ'; + : 'Generating walkthroughโ€ฆ'); + const summary = progress?.summary ?? detail ?? null; + const units = progress?.units ?? []; + const counts = + progress?.total != null + ? `${progress.completed ?? units.filter((unit) => unit.status === 'ready').length}/${progress.total}` + : null; return ( - {label} - - {showTimer ? `${elapsedSeconds}s` : '0s'} + + + + {label} + {counts ? ` ยท ${counts}` : ''} + {summary ? ` ยท ${summary}` : ''} + + + + {units.length > 0 ? ( +
    + {units.map((unit) => ( +
  • + + {unitStatusLabel(unit.status)} + + {unit.label} + {unit.status === 'failed' && unit.detail ? ( + {unit.detail} + ) : null} +
  • + ))} +
+ ) : null}
); diff --git a/core/app/hooks/useAppWalkthrough.ts b/core/app/hooks/useAppWalkthrough.ts index 828b70c6..b7e0fc86 100644 --- a/core/app/hooks/useAppWalkthrough.ts +++ b/core/app/hooks/useAppWalkthrough.ts @@ -65,10 +65,11 @@ export function useAppWalkthrough({ ); const [walkthroughLoading, setWalkthroughLoadingState] = useState(initialWalkthroughLoading); const [walkthroughProgress, setWalkthroughProgress] = useState<{ - phase: WalkthroughProgressEvent['phase'] | null; + generation: NonNullable | null; + phase: NonNullable | null; responseLabelIndex: number; stageRevision: number; - }>({ phase: null, responseLabelIndex: -1, stageRevision: 0 }); + }>({ generation: null, phase: null, responseLabelIndex: -1, stageRevision: 0 }); const [walkthroughSharing, setWalkthroughSharing] = useState(false); const [walkthroughUnread, setWalkthroughUnread] = useState(false); const activeReviewCommandTargetRef = useRef(null); @@ -77,6 +78,7 @@ export function useAppWalkthrough({ const sidebarModeRef = useRef(initialSidebarMode); const walkthroughErrorRef = useRef(walkthroughError); const walkthroughLoadingRef = useRef(initialWalkthroughLoading); + const walkthroughProgressEnabledRef = useRef(true); const walkthroughRequestRef = useRef(0); const initialSourceKeyRef = useRef(state ? getSourceRevisionKey(state.source) : null); const initialStateGenerationRef = useRef(0); @@ -160,21 +162,27 @@ export function useAppWalkthrough({ useEffect( () => window.codiff.onWalkthroughProgress((progress) => { - setWalkthroughProgress((current) => - current.phase === progress.phase - ? current - : { - phase: progress.phase, - responseLabelIndex: current.responseLabelIndex, - stageRevision: current.stageRevision + 1, - }, - ); + if (!walkthroughProgressEnabledRef.current) { + return; + } + setWalkthroughProgress((current) => { + const phase = progress.phase ?? current.phase; + return { + generation: progress.generation ?? current.generation, + phase, + responseLabelIndex: current.responseLabelIndex, + stageRevision: + current.phase === phase ? current.stageRevision : current.stageRevision + 1, + }; + }); }), [], ); const startWalkthroughLoading = useCallback(() => { + walkthroughProgressEnabledRef.current = true; setWalkthroughProgress((current) => ({ + generation: null, phase: null, responseLabelIndex: nextWalkthroughResponseLabelIndex(current.responseLabelIndex), stageRevision: current.stageRevision + 1, @@ -185,7 +193,12 @@ export function useAppWalkthrough({ const cancelWalkthroughRequest = useCallback(() => { walkthroughRequestRef.current += 1; + walkthroughProgressEnabledRef.current = false; + const cancelMainProcess = walkthroughLoadingRef.current; setWalkthroughLoading(false); + if (cancelMainProcess) { + void window.codiff.cancelNarrativeWalkthrough().catch(() => {}); + } }, [setWalkthroughLoading]); const commitWalkthrough = useCallback( diff --git a/core/global.d.ts b/core/global.d.ts index dcb7dca7..a235e7a6 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -51,6 +51,7 @@ declare global { applyUpdate: () => Promise; askReviewAssistant: (request: ReviewAssistantRequest) => Promise; cancelDiffContentRequest: (requestId: string) => void; + cancelNarrativeWalkthrough: () => Promise; completePlan: (review: PlanReview, status: PlanHandoffStatus) => Promise; createWalkthroughCommit: ( request: WalkthroughCommitRequest, diff --git a/core/index.ts b/core/index.ts index 94773e3d..f47bdc06 100644 --- a/core/index.ts +++ b/core/index.ts @@ -32,6 +32,15 @@ export { type ReviewArtifactSource, type StackSnapshot, } from './lib/review-artifacts.ts'; +export { + runWalkthroughGenerationTasks, + walkthroughGenerationConcurrency, + type ReusableWalkthroughGenerationComponent, + type RunWalkthroughGenerationTasksInput, + type RunWalkthroughGenerationTasksResult, + type WalkthroughGenerationFailure, + type WalkthroughGenerationTask, +} from './lib/walkthrough-generation-tasks.ts'; export { parsePlanShareManifest, parsePlanShareUpload, @@ -44,6 +53,9 @@ export type { CodiffPreferences, DiffRange, DiffSection, + GenerationMetadata, + GenerationProfile, + GenerationSettings, GitIdentity, GitSha, NarrativeWalkthrough, @@ -75,4 +87,8 @@ export type { SubmittedReviewComment, WalkthroughShareManifestV1, WalkthroughHunk, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, + WalkthroughProgressEvent, + WalkthroughProgressPhase, } from './types.ts'; diff --git a/core/lib/walkthrough-generation-tasks.ts b/core/lib/walkthrough-generation-tasks.ts new file mode 100644 index 00000000..7964ab4a --- /dev/null +++ b/core/lib/walkthrough-generation-tasks.ts @@ -0,0 +1,271 @@ +import type { + GenerationMetadata, + GenerationProfile, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, +} from '../types.ts'; + +export const walkthroughGenerationConcurrency = 3; + +export type WalkthroughGenerationTask = { + id: string; + identity: Identity; + label: string; + profile: GenerationProfile; + run: (input: { + profile: GenerationProfile; + semanticInput: SemanticInput; + signal: AbortSignal; + }) => Promise<{ generationMetadata: GenerationMetadata; output: Output }>; + semanticInput: SemanticInput; +}; + +export type ReusableWalkthroughGenerationComponent = { + generationMetadata: GenerationMetadata; + identity: Identity; + output: Output; + profile: GenerationProfile; + semanticInput: SemanticInput; +}; + +export type WalkthroughGenerationFailure = { + error: string; + identity: Identity; + label: string; +}; + +export type RunWalkthroughGenerationTasksInput = { + concurrency?: number; + onProgress?: (progress: WalkthroughGenerationProgress) => void; + reusableComponents?: ReadonlyArray< + ReusableWalkthroughGenerationComponent + >; + signal?: AbortSignal; + tasks: ReadonlyArray>; +}; + +export type RunWalkthroughGenerationTasksResult = + | { + components: ReadonlyArray< + ReusableWalkthroughGenerationComponent + >; + status: 'ready'; + } + | { + components: ReadonlyArray< + ReusableWalkthroughGenerationComponent + >; + failures: ReadonlyArray>; + reason: string; + status: 'failed'; + } + | { + components: ReadonlyArray< + ReusableWalkthroughGenerationComponent + >; + reason: string; + status: 'cancelled'; + }; + +const stableValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(stableValue); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([first], [second]) => first.localeCompare(second)) + .map(([key, child]) => [key, stableValue(child)]), + ); + } + return value; +}; + +const inputsEqual = (first: unknown, second: unknown) => + JSON.stringify(stableValue(first)) === JSON.stringify(stableValue(second)); + +const failureReason = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +const cancellationReason = (signal: AbortSignal) => + signal.reason instanceof Error + ? signal.reason.message + : typeof signal.reason === 'string' && signal.reason + ? signal.reason + : 'Walkthrough generation was cancelled.'; + +const runWithCancellation = async ( + signal: AbortSignal, + run: () => Promise, +): Promise => { + signal.throwIfAborted(); + let rejectCancellation: ((reason: unknown) => void) | null = null; + const cancelled = new Promise((_, reject) => { + rejectCancellation = reject; + }); + const onAbort = () => + rejectCancellation?.(signal.reason ?? new Error(cancellationReason(signal))); + signal.addEventListener('abort', onAbort, { once: true }); + try { + return await Promise.race([run(), cancelled]); + } finally { + signal.removeEventListener('abort', onAbort); + } +}; + +const validateGenerationMetadata = (metadata: GenerationMetadata, profile: GenerationProfile) => { + if (!inputsEqual(metadata.profile, profile)) { + throw new Error('Successful generation metadata does not match the requested profile.'); + } + if (metadata.agent !== profile.agent) { + throw new Error('The successful agent does not match the requested profile.'); + } + if (!profile.modelCandidates.includes(metadata.model)) { + throw new Error('The successful model is not in the requested fallback chain.'); + } +}; + +const findReusable = ( + reusable: ReadonlyArray>, + task: WalkthroughGenerationTask, +) => + reusable.find((component) => { + if ( + !inputsEqual(component.identity, task.identity) || + !inputsEqual(component.semanticInput, task.semanticInput) || + !inputsEqual(component.profile, task.profile) + ) { + return false; + } + try { + validateGenerationMetadata(component.generationMetadata, task.profile); + return true; + } catch { + return false; + } + }); + +/** + * Run format-neutral model tasks with bounded concurrency. Successful + * components remain reusable after another task fails, so a retry invokes only + * the failed or invalidated tasks. Cancellation prevents queued work from + * starting and suppresses a ready result. + */ +export async function runWalkthroughGenerationTasks( + input: RunWalkthroughGenerationTasksInput, +): Promise> { + const signal = input.signal ?? new AbortController().signal; + const reusableComponents = input.reusableComponents ?? []; + const progressUnits: Array = input.tasks.map((task) => ({ + id: task.id, + label: task.label, + status: 'pending', + })); + const outcomes: Array< + | ReusableWalkthroughGenerationComponent + | WalkthroughGenerationFailure + | undefined + > = new Array(input.tasks.length); + const emitProgress = (phase: WalkthroughGenerationProgress['phase'], summary: string) => + input.onProgress?.({ + completed: progressUnits.filter((unit) => unit.status === 'ready').length, + phase, + summary, + total: progressUnits.length, + units: progressUnits.map((unit) => ({ ...unit })), + }); + + emitProgress('preparing', `Preparing ${input.tasks.length} walkthrough generation tasks.`); + const pending: Array = []; + input.tasks.forEach((task, index) => { + const reused = findReusable(reusableComponents, task); + if (!reused) { + pending.push(index); + return; + } + outcomes[index] = reused; + progressUnits[index] = { ...progressUnits[index]!, status: 'ready' }; + }); + + let nextPending = 0; + let cancelled = signal.aborted; + const worker = async () => { + while (nextPending < pending.length && !signal.aborted) { + const index = pending[nextPending]!; + nextPending += 1; + const task = input.tasks[index]!; + progressUnits[index] = { ...progressUnits[index]!, status: 'generating' }; + emitProgress( + input.tasks.length === 1 ? 'generating' : 'generating-units', + `Generating ${task.label}.`, + ); + try { + const result = await runWithCancellation(signal, () => + task.run({ profile: task.profile, semanticInput: task.semanticInput, signal }), + ); + validateGenerationMetadata(result.generationMetadata, task.profile); + const component = { + generationMetadata: result.generationMetadata, + identity: task.identity, + output: result.output, + profile: task.profile, + semanticInput: task.semanticInput, + } satisfies ReusableWalkthroughGenerationComponent; + outcomes[index] = component; + progressUnits[index] = { ...progressUnits[index]!, status: 'ready' }; + emitProgress( + input.tasks.length === 1 ? 'generating' : 'generating-units', + `Completed ${task.label}.`, + ); + } catch (error: unknown) { + if (signal.aborted) { + cancelled = true; + progressUnits[index] = { + ...progressUnits[index]!, + detail: cancellationReason(signal), + status: 'failed', + }; + break; + } + const failure = { error: failureReason(error), identity: task.identity, label: task.label }; + outcomes[index] = failure; + progressUnits[index] = { + ...progressUnits[index]!, + detail: failure.error, + status: 'failed', + }; + emitProgress( + input.tasks.length === 1 ? 'generating' : 'generating-units', + `Failed ${task.label}.`, + ); + } + } + }; + const concurrency = Math.max( + 1, + Math.min(Math.floor(input.concurrency ?? walkthroughGenerationConcurrency), pending.length), + ); + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + const completedComponents = outcomes.flatMap((outcome) => + outcome && !('error' in outcome) ? [outcome] : [], + ); + + if (cancelled || signal.aborted) { + const reason = cancellationReason(signal); + emitProgress('generating', reason); + return { components: completedComponents, reason, status: 'cancelled' }; + } + + const failures = outcomes.flatMap((outcome) => (outcome && 'error' in outcome ? [outcome] : [])); + if (failures.length > 0) { + return { + components: completedComponents, + failures, + reason: failures.map((failure) => `${failure.label}: ${failure.error}`).join('; '), + status: 'failed', + }; + } + + emitProgress('combining', 'All walkthrough generation tasks are ready.'); + return { components: completedComponents, status: 'ready' }; +} diff --git a/core/package.json b/core/package.json index 9a68dcc8..6bb6100c 100644 --- a/core/package.json +++ b/core/package.json @@ -61,10 +61,15 @@ "types": "./dist/lib/narrative-walkthrough-diff.d.ts", "@nkzw/codiff-source": "./lib/narrative-walkthrough-diff.js", "default": "./dist/lib/narrative-walkthrough-diff.mjs" + }, + "./walkthrough-generation": { + "types": "./dist/walkthrough-generation.d.ts", + "@nkzw/codiff-source": "./walkthrough-generation.ts", + "default": "./dist/walkthrough-generation.mjs" } }, "scripts": { - "build": "rm -rf dist && vp pack -d dist --target=node24 index.ts react.ts share.ts lib/narrative-walkthrough-diff.js App.css && vp exec tsc -p tsconfig.build.json" + "build": "rm -rf dist && vp pack -d dist --target=node24 index.ts react.ts share.ts walkthrough-generation.ts lib/narrative-walkthrough-diff.js App.css && vp exec tsc -p tsconfig.build.json" }, "dependencies": { "@nkzw/mdx-editor": "^1.0.1", diff --git a/core/tsconfig.build.json b/core/tsconfig.build.json index 6e08bed6..f4aed047 100644 --- a/core/tsconfig.build.json +++ b/core/tsconfig.build.json @@ -16,6 +16,8 @@ "index.ts", "react.ts", "share.ts", + "walkthrough-generation.ts", + "walkthrough-progress.ts", "types.ts", "SharedPlanApp.tsx", "ReviewSurface.tsx", diff --git a/core/types/generation.ts b/core/types/generation.ts index b3f512db..e514b119 100644 --- a/core/types/generation.ts +++ b/core/types/generation.ts @@ -1,8 +1,45 @@ import type { GitSha, ResolvedReviewSource } from './review-identity.ts'; import type { NarrativeWalkthrough } from './walkthrough.ts'; +export type GenerationSettings = Readonly>; + +/** Safe model policy that materially affects one generated component. */ +export type GenerationProfile = { + agent: NarrativeWalkthrough['agent']; + authoringVersion: string; + modelCandidates: ReadonlyArray; + settings?: GenerationSettings; +}; + +/** Provenance for one successful model-produced component. */ +export type GenerationMetadata = { + agent: NarrativeWalkthrough['agent']; + generatedAt: string; + model: string; + profile: GenerationProfile; +}; + +export type WalkthroughGenerationUnitProgress = { + detail?: string; + id: string; + label: string; + status: 'failed' | 'generating' | 'pending' | 'preparing' | 'ready'; +}; + +/** Format-neutral progress for one in-flight walkthrough generation. */ +export type WalkthroughGenerationProgress = { + completed?: number; + phase: 'combining' | 'generating' | 'generating-units' | 'preparing'; + summary: string; + total?: number; + units?: ReadonlyArray; +}; + export type WalkthroughProgressPhase = 'agent-generation' | 'response-received'; -export type WalkthroughProgressEvent = { phase: WalkthroughProgressPhase }; +export type WalkthroughProgressEvent = { + generation?: WalkthroughGenerationProgress; + phase?: WalkthroughProgressPhase; +}; export type NarrativeWalkthroughResult = | { status: 'ready'; walkthrough: NarrativeWalkthrough } diff --git a/core/walkthrough-generation.ts b/core/walkthrough-generation.ts new file mode 100644 index 00000000..15436d90 --- /dev/null +++ b/core/walkthrough-generation.ts @@ -0,0 +1,9 @@ +export { + runWalkthroughGenerationTasks, + walkthroughGenerationConcurrency, + type ReusableWalkthroughGenerationComponent, + type RunWalkthroughGenerationTasksInput, + type RunWalkthroughGenerationTasksResult, + type WalkthroughGenerationFailure, + type WalkthroughGenerationTask, +} from './lib/walkthrough-generation-tasks.ts'; diff --git a/electron/__tests__/agent.test.ts b/electron/__tests__/agent.test.ts index 416ca109..8612a9fa 100644 --- a/electron/__tests__/agent.test.ts +++ b/electron/__tests__/agent.test.ts @@ -13,6 +13,9 @@ const { isAvailable?: (agent: { id: 'codex' | 'claude' | 'opencode' | 'pi' }) => boolean, ) => string; getAgent: (backendId: unknown) => { + defaultModel: string; + fallbackModel: string; + getModelCandidates: (model: unknown) => ReadonlyArray; id: string; isAvailable: () => boolean; label: string; @@ -114,3 +117,20 @@ test('shows a custom configured model in the agent model menu', () => { test('falls back to the default backend for unknown ids', () => { expect(getAgent('unknown').id).toBe('codex'); }); + +test('constructs normalized fallback chains for every agent backend', () => { + const codex = getAgent('codex'); + expect(codex.getModelCandidates('gpt-5.6-sol')).toEqual([ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.5', + ]); + + for (const backend of ['claude', 'opencode', 'pi']) { + const agent = getAgent(backend); + const candidates = agent.getModelCandidates(agent.defaultModel); + expect(candidates[0]).toBe(agent.defaultModel); + expect(candidates).toContain(agent.fallbackModel); + expect(new Set(candidates).size).toBe(candidates.length); + } +}); diff --git a/electron/__tests__/forge-package.test.ts b/electron/__tests__/forge-package.test.ts index a9b59d1e..bcd5024f 100644 --- a/electron/__tests__/forge-package.test.ts +++ b/electron/__tests__/forge-package.test.ts @@ -24,11 +24,12 @@ test('Forge excludes provider sources and retains application entry points', () expect(isIgnored('/dist/index.html')).toBe(false); }); -test('Forge restores built provider runtime artifacts after copy', async () => { +test('Forge restores built runtime artifacts after copy', async () => { const directory = await mkdtemp(join(tmpdir(), 'codiff-forge-runtime-')); try { await forge.hooks.packageAfterCopy({}, directory); for (const path of [ + 'core/dist/walkthrough-generation.mjs', 'core/lib/narrative-walkthrough-diff.cjs', 'github/dist/index.mjs', 'gitlab/dist/index.mjs', diff --git a/electron/__tests__/reviewed-diff-signature.test.ts b/electron/__tests__/reviewed-diff-signature.test.ts new file mode 100644 index 00000000..b523458d --- /dev/null +++ b/electron/__tests__/reviewed-diff-signature.test.ts @@ -0,0 +1,40 @@ +import { createRequire } from 'node:module'; +import { expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { getReviewedDiffSignature } = require('../reviewed-diff-signature.cjs') as { + getReviewedDiffSignature: ( + files: ReadonlyArray<{ + fingerprint: string; + oldPath?: string; + path: string; + sections: ReadonlyArray<{ range?: unknown }>; + status: string; + }>, + ) => string; +}; + +const file = (base: string, path = 'src/app.ts') => ({ + fingerprint: 'same-content', + path, + sections: [ + { + range: { + base: { kind: 'commit', sha: base }, + head: { kind: 'commit', sha: 'h'.repeat(40) }, + }, + }, + ], + status: 'modified', +}); + +test('reviewed diff signatures change for base-only changes and ignore file order', () => { + const first = getReviewedDiffSignature([file('a'.repeat(40)), file('a'.repeat(40), 'b.ts')]); + + expect(getReviewedDiffSignature([file('b'.repeat(40)), file('a'.repeat(40), 'b.ts')])).not.toBe( + first, + ); + expect(getReviewedDiffSignature([file('a'.repeat(40), 'b.ts'), file('a'.repeat(40))])).toBe( + first, + ); +}); diff --git a/electron/__tests__/shared-walkthrough-upload.test.ts b/electron/__tests__/shared-walkthrough-upload.test.ts index 7a30f46a..5baf5edb 100644 --- a/electron/__tests__/shared-walkthrough-upload.test.ts +++ b/electron/__tests__/shared-walkthrough-upload.test.ts @@ -2,18 +2,20 @@ import { createRequire } from 'node:module'; import { expect, test, vi } from 'vite-plus/test'; const require = createRequire(import.meta.url); -const { uploadSharedWalkthrough } = require('../shared-walkthrough-upload.cjs') as { - uploadSharedWalkthrough: (options: { - authenticate: () => Promise; - fetchImpl: typeof fetch; - openExternal: (url: string) => Promise; - openClaimPage?: boolean; - serviceUrl: string; - snapshot: unknown; - trustCertificates?: () => { reason?: string; status: string }; - uploader?: { email: string; name: string }; - }) => Promise; -}; +const { sanitizeSharedWalkthroughSnapshot, uploadSharedWalkthrough } = + require('../shared-walkthrough-upload.cjs') as { + sanitizeSharedWalkthroughSnapshot: (snapshot: any) => any; + uploadSharedWalkthrough: (options: { + authenticate: () => Promise; + fetchImpl: typeof fetch; + openExternal: (url: string) => Promise; + openClaimPage?: boolean; + serviceUrl: string; + snapshot: unknown; + trustCertificates?: () => { reason?: string; status: string }; + uploader?: { email: string; name: string }; + }) => Promise; + }; test('uploads git identity separately from the walkthrough snapshot', async () => { const authenticate = vi.fn(async () => {}); @@ -210,3 +212,31 @@ test('explains certificate trust status for certificate-chain failures', async ( 'Codiff share upload intent request failed: fetch failed: SELF_SIGNED_CERT_IN_CHAIN - self signed certificate in certificate chain System certificate trust was not applied (this Node/Electron runtime does not expose system certificate APIs).', ); }); + +test('strips hidden session context and local repository paths from walkthrough uploads', () => { + const snapshot = { + kind: 'codiff-walkthrough-share', + repository: { root: '/Users/ada/private-repo', source: { type: 'working-tree' } }, + version: 1, + walkthrough: { + context: { + messages: [{ role: 'user', text: 'Private implementation conversation.' }], + source: { threadId: 'private-session', type: 'codex-session-excerpt' }, + }, + repo: { branch: 'main', root: '/Users/ada/private-repo' }, + title: 'Review', + }, + }; + + expect(sanitizeSharedWalkthroughSnapshot(snapshot)).toEqual({ + kind: 'codiff-walkthrough-share', + repository: { root: '[redacted]', source: { type: 'working-tree' } }, + version: 1, + walkthrough: { + repo: { branch: 'main', root: '[redacted]' }, + title: 'Review', + }, + }); + expect(snapshot.walkthrough.context.messages[0].text).toContain('Private'); + expect(snapshot.repository.root).toContain('/Users/ada'); +}); diff --git a/electron/__tests__/walkthrough-generation-cache-key.test.ts b/electron/__tests__/walkthrough-generation-cache-key.test.ts new file mode 100644 index 00000000..46fd54a7 --- /dev/null +++ b/electron/__tests__/walkthrough-generation-cache-key.test.ts @@ -0,0 +1,92 @@ +import { createRequire } from 'node:module'; +import { expect, test } from 'vite-plus/test'; +import type { GenerationProfile, GitSha, RepositoryState } from '../../core/types.ts'; + +type CacheInput = { + profile: GenerationProfile; + request: { customInstructions: string; scope: string }; + state: RepositoryState; +}; + +const require = createRequire(import.meta.url); +const { buildWalkthroughGenerationCacheIdentity, getWalkthroughGenerationCacheKey } = + require('../walkthrough-generation-cache-key.cjs') as { + buildWalkthroughGenerationCacheIdentity: (input: CacheInput) => { + source: { description: string | null; title: string | null }; + }; + getWalkthroughGenerationCacheKey: (input: CacheInput) => string; + }; + +const input = (): CacheInput => ({ + profile: { + agent: 'codex', + authoringVersion: 'format-neutral-test', + modelCandidates: ['gpt-5.6-terra'], + }, + request: { customInstructions: 'Review the change.', scope: 'complete-diff' }, + state: { + branch: 'feature/cache-key', + files: [ + { + fingerprint: 'src/app.ts:1', + path: 'src/app.ts', + sections: [ + { + binary: false, + id: 'src/app.ts:commit', + kind: 'commit', + patch: '@@ -1 +1 @@\n-old\n+new\n', + }, + ], + status: 'modified', + }, + ], + generatedAt: 1, + root: '/repo', + source: { + description: ' Explain the new behavior. ', + headSha: 'a'.repeat(40) as GitSha, + number: 42, + provider: 'github', + title: ' Improve cache behavior ', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/42', + }, + }, +}); + +test('cache identities normalize review prose and exclude checkout roots', () => { + const base = input(); + const movedCheckout = input(); + movedCheckout.state.root = '/another/checkout'; + + expect(getWalkthroughGenerationCacheKey(movedCheckout)).toBe( + getWalkthroughGenerationCacheKey(base), + ); + expect(buildWalkthroughGenerationCacheIdentity(base).source).toMatchObject({ + description: 'Explain the new behavior.', + title: 'Improve cache behavior', + }); +}); + +test('cache keys include semantic requests, profiles, and review identity', () => { + const base = input(); + const changedRequest = input(); + changedRequest.request.customInstructions = 'Focus on cancellation.'; + const changedProfile = input(); + changedProfile.profile = { ...changedProfile.profile, modelCandidates: ['gpt-5.6-sol'] }; + const changedReview = input(); + if (changedReview.state.source.type === 'pull-request') { + changedReview.state.source.title = 'Different review'; + } + + expect(getWalkthroughGenerationCacheKey(changedRequest)).not.toBe( + getWalkthroughGenerationCacheKey(base), + ); + expect(getWalkthroughGenerationCacheKey(changedProfile)).not.toBe( + getWalkthroughGenerationCacheKey(base), + ); + expect(getWalkthroughGenerationCacheKey(changedReview)).not.toBe( + getWalkthroughGenerationCacheKey(base), + ); +}); diff --git a/electron/__tests__/walkthrough-generation-coordinator.test.ts b/electron/__tests__/walkthrough-generation-coordinator.test.ts new file mode 100644 index 00000000..abae61f5 --- /dev/null +++ b/electron/__tests__/walkthrough-generation-coordinator.test.ts @@ -0,0 +1,67 @@ +import { createRequire } from 'node:module'; +import { expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { createWalkthroughGenerationCoordinator, SUPERSEDED_GENERATION_REASON } = + require('../walkthrough-generation-coordinator.cjs') as { + createWalkthroughGenerationCoordinator: () => { + begin: (key: number) => AbortController; + cancel: (key: number, reason?: unknown) => void; + clear: (key: number, reason?: unknown) => void; + finish: (key: number, controller: AbortController) => void; + getReusable: ( + key: number, + cacheKey: string, + force?: boolean, + ) => ReadonlyArray | undefined; + retain: ( + key: number, + controller: AbortController, + cacheKey: string, + components: ReadonlyArray, + ) => boolean; + }; + SUPERSEDED_GENERATION_REASON: string; + }; + +test('supersedes an active generation without letting it overwrite current retry state', () => { + const coordinator = createWalkthroughGenerationCoordinator(); + const first = coordinator.begin(7); + const second = coordinator.begin(7); + + expect(first.signal.aborted).toBe(true); + expect(first.signal.reason).toEqual(new Error(SUPERSEDED_GENERATION_REASON)); + expect(coordinator.retain(7, first, 'review-a', ['stale'])).toBe(false); + coordinator.finish(7, first); + expect(coordinator.retain(7, second, 'review-a', ['current'])).toBe(true); + expect(coordinator.getReusable(7, 'review-a')).toEqual(['current']); +}); + +test('cancel aborts active work without discarding reusable components', () => { + const coordinator = createWalkthroughGenerationCoordinator(); + const controller = coordinator.begin(9); + const components = [{ identity: 'ready-unit' }]; + const reason = new Error('The review source changed.'); + + expect(coordinator.retain(9, controller, 'review-a', components)).toBe(true); + coordinator.cancel(9, reason); + + expect(controller.signal.aborted).toBe(true); + expect(controller.signal.reason).toBe(reason); + expect(coordinator.getReusable(9, 'review-a')).toBe(components); +}); + +test('retains partial components only for matching non-forced retries', () => { + const coordinator = createWalkthroughGenerationCoordinator(); + const current = coordinator.begin(11); + const components = [{ identity: 'ready-unit' }]; + + expect(coordinator.retain(11, current, 'review-a', components)).toBe(true); + expect(coordinator.getReusable(11, 'review-a')).toBe(components); + expect(coordinator.getReusable(11, 'review-b')).toBeUndefined(); + expect(coordinator.getReusable(11, 'review-a', true)).toBeUndefined(); + + coordinator.clear(11); + expect(current.signal.aborted).toBe(true); + expect(coordinator.getReusable(11, 'review-a')).toBeUndefined(); +}); diff --git a/electron/__tests__/walkthrough-model-invocation.test.ts b/electron/__tests__/walkthrough-model-invocation.test.ts new file mode 100644 index 00000000..651e8bb6 --- /dev/null +++ b/electron/__tests__/walkthrough-model-invocation.test.ts @@ -0,0 +1,130 @@ +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { expect, test, vi } from 'vite-plus/test'; +import type { GenerationProfile } from '../../core/types.ts'; + +const require = createRequire(import.meta.url); +const { getAgent } = require('../agent.cjs') as { getAgent: (id: string) => any }; +const { invokeWalkthroughModel, parseStructuredModelResponse } = + require('../walkthrough-model-invocation.cjs') as { + invokeWalkthroughModel: (input: any) => Promise<{ + generationMetadata: { model: string; profile: GenerationProfile }; + response: string; + }>; + parseStructuredModelResponse: (response: unknown) => unknown; + }; + +const profile: GenerationProfile = { + agent: 'codex', + authoringVersion: 'format-neutral-test', + modelCandidates: ['primary', 'intermediate', 'fallback'], +}; + +const agent = (run: (...args: Array) => Promise) => ({ + defaultTimeoutMs: 1_000, + id: 'codex', + normalizeModel: (value: unknown) => String(value), + run, +}); + +test('records an actual intermediate fallback while preserving the complete candidate chain', async () => { + const onModelFallback = vi.fn(); + const run = vi.fn(async (...args: Array) => { + expect(args[5]).toMatchObject({ fallbackModel: 'fallback', model: 'primary' }); + await args[5].onModelFallback('intermediate', 'primary'); + return '```json\n{"value":42}\n```'; + }); + + const result = await invokeWalkthroughModel({ + agent: agent(run), + agentOptions: { onModelFallback }, + generatedAt: () => '2026-08-04T00:00:00.000Z', + profile, + prompt: 'Explain the review.', + repoRoot: '/repo', + schema: { type: 'object' }, + }); + + expect(result.generationMetadata).toMatchObject({ model: 'intermediate', profile }); + expect(onModelFallback).toHaveBeenCalledWith('intermediate', 'primary'); + expect(parseStructuredModelResponse(result.response)).toEqual({ value: 42 }); +}); + +test('cancels publication while a model invocation is still pending', async () => { + const controller = new AbortController(); + const run = vi.fn(() => new Promise(() => {})); + const invocation = invokeWalkthroughModel({ + agent: agent(run), + profile, + prompt: 'Explain the review.', + repoRoot: '/repo', + schema: { type: 'object' }, + signal: controller.signal, + }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + + controller.abort(new Error('The review changed.')); + + await expect(invocation).rejects.toThrow('The review changed.'); +}); + +test('aborting model invocation terminates the actual agent child process', async () => { + let child: ReturnType | null = null; + let resolveStarted!: (pid: number) => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + let resolveExit!: (value: { code: number | null; signal: NodeJS.Signals | null }) => void; + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + resolveExit = resolve; + }); + const fixtureSource = ` + process.stdout.write(JSON.stringify({ pid: process.pid }) + '\\n'); + process.on('SIGTERM', () => process.exit(0)); + process.stdin.resume(); + setTimeout(() => process.exit(96), 4000); + `; + const commandTransport = { + command: 'bounded-claude-fixture', + spawn: (_command: string, _args: ReadonlyArray, options: { signal?: AbortSignal }) => { + child = spawn(process.execPath, ['-e', fixtureSource], { + signal: options.signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + output += chunk; + const line = output.split('\n').find(Boolean); + if (line) { + resolveStarted((JSON.parse(line) as { pid: number }).pid); + } + }); + child.once('exit', (code, signal) => resolveExit({ code, signal })); + return child; + }, + }; + const controller = new AbortController(); + const claudeAgent = getAgent('claude'); + const invocation = invokeWalkthroughModel({ + agent: claudeAgent, + agentOptions: { commandTransport }, + profile: { + agent: 'claude', + authoringVersion: 'child-cancellation-test', + modelCandidates: [claudeAgent.defaultModel], + }, + prompt: 'Wait for cancellation.', + repoRoot: '/repo', + schema: { type: 'object' }, + signal: controller.signal, + timeoutMs: 3500, + }); + const pid = await started; + controller.abort(new Error('The review changed.')); + + await expect(invocation).rejects.toThrow('The review changed.'); + await expect(exited).resolves.toMatchObject({ code: 0 }); + expect(child?.pid).toBe(pid); + expect(child?.exitCode).not.toBeNull(); +}); diff --git a/electron/__tests__/walkthrough-progress.test.ts b/electron/__tests__/walkthrough-progress.test.ts index 5049ed48..14e873c8 100644 --- a/electron/__tests__/walkthrough-progress.test.ts +++ b/electron/__tests__/walkthrough-progress.test.ts @@ -5,8 +5,8 @@ const require = createRequire(import.meta.url); const { createWalkthroughProgressReporter } = require('../walkthrough-progress.cjs') as { createWalkthroughProgressReporter: (webContents: { isDestroyed: () => boolean; - send: (channel: string, progress: { phase: string }) => void; - }) => (phase: string) => void; + send: (channel: string, progress: unknown) => void; + }) => (update: { phase: string; summary: string } | string) => void; }; test('forwards repeated real progress events while the request is current', () => { @@ -38,3 +38,19 @@ test('forwards repeated real progress events while the request is current', () = reportProgress('agent-generation'); expect(send).toHaveBeenCalledTimes(2); }); + +test('forwards structured generation progress without provider fields', () => { + const send = vi.fn(); + const reportProgress = createWalkthroughProgressReporter({ + isDestroyed: () => false, + send, + }); + const generation = { + phase: 'generating-units', + summary: 'Generating the second task.', + }; + + reportProgress(generation); + + expect(send).toHaveBeenCalledWith('codiff:walkthroughProgress', { generation }); +}); diff --git a/electron/agent.cjs b/electron/agent.cjs index 29ff8efb..88d91894 100644 --- a/electron/agent.cjs +++ b/electron/agent.cjs @@ -28,6 +28,7 @@ const { readPiSessionContext } = require('./pi-session-context.cjs'); * onPartialText?: (delta: string) => void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; * reasoningEffort?: 'low' | 'medium' | 'high'; + * signal?: AbortSignal; * timeoutMs?: number; * }} AgentOptions * @typedef {{ @@ -39,6 +40,7 @@ const { readPiSessionContext } = require('./pi-session-context.cjs'); * models: ReadonlyArray<{id: string; label: string}>; * defaultModel: string; * fallbackModel: string; + * getModelCandidates: (model: unknown) => ReadonlyArray; * modelSettingKey: 'openAIModel' | 'claudeModel' | 'opencodeModel' | 'piModel'; * normalizeModel: (value: unknown) => string; * notFoundCode: string; @@ -88,6 +90,10 @@ const createCodexAgent = () => ({ models: codex.OPENAI_MODELS, defaultModel: codex.DEFAULT_OPENAI_MODEL, fallbackModel: codex.FALLBACK_OPENAI_MODEL, + getModelCandidates: (model) => { + const normalized = codex.normalizeOpenAIModel(model); + return [normalized, ...codex.getOpenAIModelFallbacks(normalized)]; + }, modelSettingKey: 'openAIModel', normalizeModel: codex.normalizeOpenAIModel, notFoundCode: codex.CODEX_NOT_FOUND_CODE, @@ -108,6 +114,12 @@ const createClaudeAgent = () => ({ models: claude.CLAUDE_MODELS, defaultModel: claude.DEFAULT_CLAUDE_MODEL, fallbackModel: claude.FALLBACK_CLAUDE_MODEL, + getModelCandidates: (model) => [ + ...new Set([ + claude.normalizeClaudeModel(model), + claude.normalizeClaudeModel(claude.FALLBACK_CLAUDE_MODEL), + ]), + ], modelSettingKey: 'claudeModel', normalizeModel: claude.normalizeClaudeModel, notFoundCode: claude.CLAUDE_NOT_FOUND_CODE, @@ -128,6 +140,12 @@ const createOpenCodeAgent = () => ({ models: opencode.OPENCODE_MODELS, defaultModel: opencode.DEFAULT_OPENCODE_MODEL, fallbackModel: opencode.FALLBACK_OPENCODE_MODEL, + getModelCandidates: (model) => [ + ...new Set([ + opencode.normalizeOpenCodeModel(model), + opencode.normalizeOpenCodeModel(opencode.FALLBACK_OPENCODE_MODEL), + ]), + ], modelSettingKey: 'opencodeModel', normalizeModel: opencode.normalizeOpenCodeModel, notFoundCode: opencode.OPENCODE_NOT_FOUND_CODE, @@ -149,6 +167,7 @@ const createPiAgent = () => ({ models: pi.PI_MODELS, defaultModel: pi.DEFAULT_PI_MODEL, fallbackModel: pi.FALLBACK_PI_MODEL, + getModelCandidates: (model) => [pi.normalizePiModel(model)], modelSettingKey: 'piModel', normalizeModel: pi.normalizePiModel, notFoundCode: pi.PI_NOT_FOUND_CODE, diff --git a/electron/claude.cjs b/electron/claude.cjs index 78ea976e..4e0058cb 100644 --- a/electron/claude.cjs +++ b/electron/claude.cjs @@ -28,6 +28,7 @@ const CLAUDE_NOT_LOGGED_IN_MESSAGE = * model?: string; * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; + * signal?: AbortSignal; * timeoutMs?: number; * }} ClaudeOptions */ @@ -270,6 +271,7 @@ const runClaude = async ( const child = commandTransport.spawn(commandTransport.command, claudeArgs, { cwd: repoRoot, env: environment, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }); const streamParser = streamProgress ? createClaudeStreamParser(options.onProgress) : null; diff --git a/electron/codex.cjs b/electron/codex.cjs index 8ae1144b..d1eabc62 100644 --- a/electron/codex.cjs +++ b/electron/codex.cjs @@ -44,6 +44,7 @@ const CODEX_NOT_FOUND_MESSAGE = * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; * reasoningEffort?: 'low' | 'medium' | 'high'; + * signal?: AbortSignal; * timeoutMs?: number; * }} CodexOptions */ @@ -424,6 +425,7 @@ const runCodex = async ( ]; const child = commandTransport.spawn(commandTransport.command, codexArgs, { env: environment, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }); const eventParser = createCodexEventParser(options.onProgress); @@ -514,6 +516,7 @@ const runCodex = async ( { cwd: repoRoot, env: environment, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }, ); @@ -821,6 +824,7 @@ module.exports = { DEFAULT_OPENAI_MODEL, FALLBACK_OPENAI_MODEL, getCodexCommand, + getOpenAIModelFallbacks, isCodexNotFoundError, normalizeOpenAIModel, OPENAI_MODELS, diff --git a/electron/main.cjs b/electron/main.cjs index da9a0242..f204f038 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -91,11 +91,22 @@ const { writeWindowState, } = require('./window-state.cjs'); const { - getNarrativeWalkthroughCacheKey, + NARRATIVE_WALKTHROUGH_AUTHORING_VERSION, + buildNarrativeWalkthroughPrompt, + createNarrativeWalkthroughGenerationRequest, + narrativeWalkthroughResponseSchema, normalizeNarrativeWalkthrough, - readNarrativeWalkthrough, resolveNarrativeWalkthroughModel, } = require('./narrative-walkthrough.cjs'); +const { runWalkthroughGenerationTasks } = require('./walkthrough-generation-bridge.cjs'); +const { + createWalkthroughGenerationCoordinator, +} = require('./walkthrough-generation-coordinator.cjs'); +const { getWalkthroughGenerationCacheKey } = require('./walkthrough-generation-cache-key.cjs'); +const { + invokeWalkthroughModel, + parseStructuredModelResponse, +} = require('./walkthrough-model-invocation.cjs'); const { readStoredWalkthrough, writeStoredWalkthrough } = require('./walkthrough-store.cjs'); const { uploadSharedSnapshot } = require('./shared-walkthrough-upload.cjs'); const { @@ -145,6 +156,7 @@ const windowLaunchOptions = new Map(); const windowInitialRepositoryStates = new Map(); /** @type {Map} */ const walkthroughProgressGenerations = new Map(); +const walkthroughGenerationCoordinator = createWalkthroughGenerationCoordinator(); /** @type {Map} */ const planInitialVersions = new Map(); /** @type {Set} */ @@ -1063,6 +1075,10 @@ const createWindow = ( completedPlanWindows.delete(webContentsId); planInitialVersions.delete(webContentsId); readyPlanWindows.delete(webContentsId); + walkthroughGenerationCoordinator.clear( + webContentsId, + new Error('The walkthrough window was closed.'), + ); abortDiffContentRequests(webContentsId); windowIdentities.delete(webContentsId); windowInitialRepositoryStates.delete(webContentsId); @@ -1070,8 +1086,21 @@ const createWindow = ( windowRepositories.delete(webContentsId); windowLaunchOptions.delete(webContentsId); }); + window.webContents.on('did-start-navigation', (_event, _url, _inPlace, isMainFrame) => { + if (isMainFrame) { + abortDiffContentRequests(webContentsId); + walkthroughGenerationCoordinator.clear( + webContentsId, + new Error('The walkthrough renderer was reloaded.'), + ); + } + }); window.webContents.on('render-process-gone', () => { definitionSearchCoordinator.cancel(webContentsId); + walkthroughGenerationCoordinator.clear( + webContentsId, + new Error('The walkthrough renderer process exited.'), + ); abortDiffContentRequests(webContentsId); writePlanResult(webContentsId, 'canceled'); }); @@ -1242,6 +1271,11 @@ const focusOrCreateWindow = ( if (identity) { windowIdentities.set(matchingWebContentsId, identity); } + walkthroughGenerationCoordinator.clear( + matchingWebContentsId, + new Error('The walkthrough window was retargeted.'), + ); + abortDiffContentRequests(matchingWebContentsId); matchingWindow.reload(); } focusWindow(matchingWindow); @@ -1610,8 +1644,15 @@ ipcMain.handle('codiff:installTerminalHelper', async (event) => { return getTerminalHelperStatus(); }); +ipcMain.handle('codiff:cancelNarrativeWalkthrough', (event) => { + const progressGeneration = (walkthroughProgressGenerations.get(event.sender.id) || 0) + 1; + walkthroughProgressGenerations.set(event.sender.id, progressGeneration); + walkthroughGenerationCoordinator.cancel(event.sender.id, new Error('The review source changed.')); +}); + ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) => { const launchOptions = windowLaunchOptions.get(event.sender.id); + const abortController = walkthroughGenerationCoordinator.begin(event.sender.id); const progressGeneration = (walkthroughProgressGenerations.get(event.sender.id) || 0) + 1; walkthroughProgressGenerations.set(event.sender.id, progressGeneration); const reportProgress = createWalkthroughProgressReporter( @@ -1620,11 +1661,12 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) ); try { + reportProgress({ phase: 'preparing', summary: 'Loading review state.' }); const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - const state = await readRepositoryStateWithConfig( - repositoryPath, - source || launchOptions?.source, + const state = await runWithCommandSignal(abortController.signal, () => + readRepositoryStateWithConfig(repositoryPath, source || launchOptions?.source), ); + abortController.signal.throwIfAborted(); const agent = resolveWindowAgent(event.sender.id); const walkthroughFile = launchOptions?.walkthroughFile; if (walkthroughFile) { @@ -1633,6 +1675,9 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) try { contents = readFileSync(walkthroughFile, 'utf8'); } catch (error) { + if (abortController.signal.aborted) { + throw abortController.signal.reason; + } const detail = error instanceof Error ? error.message : String(error); return { reason: `Could not read walkthrough file: ${detail}`, @@ -1643,9 +1688,13 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) const sessionContext = await Promise.resolve( agent.readSessionContext(launchOptions?.[agent.sessionLaunchOptionKey]), ).catch(() => null); + abortController.signal.throwIfAborted(); try { input = JSON.parse(contents); } catch (error) { + if (abortController.signal.aborted) { + throw abortController.signal.reason; + } const detail = error instanceof Error ? error.message : String(error); return { reason: `Could not read walkthrough file: ${detail}`, @@ -1654,6 +1703,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) } try { + abortController.signal.throwIfAborted(); return { status: 'ready', walkthrough: normalizeNarrativeWalkthrough(input, state.files, { @@ -1666,6 +1716,9 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) }), }; } catch (error) { + if (abortController.signal.aborted) { + throw abortController.signal.reason; + } const detail = error instanceof Error ? error.message : String(error); // The usual cause of an unanchored working-tree walkthrough is that the // changes were committed (or reverted) after it was authored. Surface a @@ -1686,19 +1739,40 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) launchOptions?.walkthroughContext, await agent.readSessionContext(launchOptions?.[agent.sessionLaunchOptionKey]), ); + abortController.signal.throwIfAborted(); const agentOptions = getAgentOptions(agent); const walkthroughModel = resolveNarrativeWalkthroughModel(state, agent, agentOptions.model); const walkthroughPrompt = config.settings.walkthroughPrompt; - const cacheKey = getNarrativeWalkthroughCacheKey( + const modelCandidates = agent.getModelCandidates(walkthroughModel); + const profile = { + agent: agent.id, + authoringVersion: NARRATIVE_WALKTHROUGH_AUTHORING_VERSION, + modelCandidates, + }; + const cacheRequest = { + prompt: buildNarrativeWalkthroughPrompt( + state, + walkthroughContext, + agent.label, + walkthroughPrompt, + ), + responseSchema: narrativeWalkthroughResponseSchema, + }; + const cacheKey = getWalkthroughGenerationCacheKey({ + profile, + request: cacheRequest, state, - agent, - walkthroughModel, - walkthroughContext, - walkthroughPrompt, - ); + }); if (!options?.force) { const cachedWalkthrough = readStoredWalkthrough(cacheKey); if (cachedWalkthrough) { + reportProgress({ + completed: 1, + phase: 'combining', + summary: 'Loaded the cached walkthrough.', + total: 1, + units: [{ id: 'narrative', label: 'Walkthrough narrative', status: 'ready' }], + }); return { status: 'ready', walkthrough: { @@ -1715,46 +1789,116 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) } } - let generatedModel = walkthroughModel; - const onModelFallback = agentOptions.onModelFallback; - const result = await readNarrativeWalkthrough( + const generationRequest = createNarrativeWalkthroughGenerationRequest( state, agent, - { - ...agentOptions, - model: walkthroughModel, - onModelFallback: async (fallbackModel, originalModel) => { - generatedModel = fallbackModel; - await onModelFallback(fallbackModel, originalModel); - }, - onProgress: reportProgress, - }, walkthroughContext, walkthroughPrompt, options?.previousWalkthrough, ); + let notFoundCode; + const result = await runWalkthroughGenerationTasks({ + onProgress: reportProgress, + reusableComponents: walkthroughGenerationCoordinator.getReusable( + event.sender.id, + cacheKey, + options?.force, + ), + signal: abortController.signal, + tasks: [ + { + id: 'narrative', + identity: cacheKey, + label: 'Walkthrough narrative', + profile, + run: async ({ profile: taskProfile, semanticInput, signal }) => { + try { + reportProgress('agent-generation'); + const invocation = await invokeWalkthroughModel({ + agent, + agentOptions: { ...agentOptions, onProgress: reportProgress }, + outputName: generationRequest.outputName, + profile: taskProfile, + prompt: semanticInput.prompt, + repoRoot: state.root, + schema: generationRequest.schema, + signal, + timeoutMessage: generationRequest.timeoutMessage, + timeoutMs: generationRequest.timeoutMs, + }); + reportProgress('response-received'); + const walkthrough = normalizeNarrativeWalkthrough( + parseStructuredModelResponse(invocation.response), + state.files, + { + agent: agent.id, + branch: state.branch, + generatedAt: invocation.generationMetadata.generatedAt, + root: state.root, + source: state.source, + }, + generationRequest.hunkIdByAlias, + ); + if (walkthroughContext && !walkthrough.context) { + walkthrough.context = walkthroughContext; + } + return { generationMetadata: invocation.generationMetadata, output: walkthrough }; + } catch (error) { + if (agent.isNotFoundError(error)) { + notFoundCode = agent.notFoundCode; + } + throw error; + } + }, + semanticInput: { prompt: generationRequest.prompt }, + }, + ], + }); + walkthroughGenerationCoordinator.retain( + event.sender.id, + abortController, + cacheKey, + result.components, + ); if (result.status === 'ready') { - const generatedCacheKey = getNarrativeWalkthroughCacheKey( - state, - agent, - generatedModel, - walkthroughContext, - walkthroughPrompt, - ); + const walkthrough = result.components.find( + (component) => component.identity === cacheKey, + )?.output; + if (!walkthrough) { + throw new Error('Walkthrough generation completed without a validated narrative.'); + } + abortController.signal.throwIfAborted(); try { - const cacheableWalkthrough = { ...result.walkthrough }; + const cacheableWalkthrough = { ...walkthrough }; delete cacheableWalkthrough.context; - writeStoredWalkthrough(generatedCacheKey, cacheableWalkthrough); + writeStoredWalkthrough(cacheKey, cacheableWalkthrough); } catch { // Caching is optional; a filesystem failure must not hide a generated result. } + return { status: 'ready', walkthrough }; } - return result; + return { + ...(notFoundCode ? { code: notFoundCode } : {}), + reason: result.reason, + status: 'unavailable', + }; } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + reportProgress({ + completed: 0, + phase: 'generating', + summary: 'Walkthrough generation failed.', + total: 1, + units: [ + { detail: reason, id: 'narrative', label: 'Walkthrough narrative', status: 'failed' }, + ], + }); return { - reason: error instanceof Error ? error.message : String(error), + reason, status: 'unavailable', }; + } finally { + walkthroughGenerationCoordinator.finish(event.sender.id, abortController); } }); @@ -1870,8 +2014,7 @@ ipcMain.on('codiff:cancelDiffContentRequest', (event, requestId) => { return; } const requests = diffContentRequests.get(event.sender.id); - const controller = requests?.get(requestId); - controller?.abort(new DOMException('Diff content request canceled.', 'AbortError')); + requests?.get(requestId)?.abort(new DOMException('Diff content request canceled.', 'AbortError')); }); ipcMain.handle('codiff:getRepositoryHistory', async (event, limit, source) => { diff --git a/electron/narrative-walkthrough.cjs b/electron/narrative-walkthrough.cjs index 6c56aaa8..8ad5f7fb 100644 --- a/electron/narrative-walkthrough.cjs +++ b/electron/narrative-walkthrough.cjs @@ -56,6 +56,7 @@ const TIMEOUT_MS_PER_EXTRA_FILE = 1_000; const TIMEOUT_MS_PER_EXTRA_HUNK = 2_000; const LARGE_WALKTHROUGH_HUNK_THRESHOLD = 100; const WALKTHROUGH_CACHE_KEY_VERSION = 1; +const NARRATIVE_WALKTHROUGH_AUTHORING_VERSION = 'narrative-v4'; /** @param {unknown} value @param {string} [fallback] */ const cleanRich = (value, fallback = '') => { @@ -854,6 +855,31 @@ const buildNarrativeWalkthroughPrompt = ( buildNarrativeWalkthroughRequest(state, context, agentLabel, customPrompt, previousWalkthrough) .prompt; +const createNarrativeWalkthroughGenerationRequest = ( + state, + agent, + context, + customPrompt, + previousWalkthrough, +) => { + const { fileCount, hunkCount } = getWalkthroughSize(state); + const request = buildNarrativeWalkthroughRequest( + state, + context, + agent.label, + customPrompt, + previousWalkthrough, + ); + const timeoutMs = getNarrativeWalkthroughTimeoutMs(state, agent.defaultTimeoutMs); + return { + ...request, + outputName: 'walkthrough.json', + schema: narrativeWalkthroughResponseSchema, + timeoutMessage: `${agent.label} walkthrough timed out after ${Math.ceil(timeoutMs / 1_000)} seconds while processing ${fileCount} files and ${hunkCount} reviewable hunks.`, + timeoutMs, + }; +}; + /** * Cache identity for the exact model input. The previous walkthrough is * intentionally excluded: forced regeneration replaces the cached result for @@ -900,25 +926,23 @@ const readNarrativeWalkthrough = async ( previousWalkthrough, ) => { try { - const timeoutMs = getNarrativeWalkthroughTimeoutMs(state, agent.defaultTimeoutMs); - const { fileCount, hunkCount } = getWalkthroughSize(state); - const { hunkIdByAlias, prompt } = buildNarrativeWalkthroughRequest( + const request = createNarrativeWalkthroughGenerationRequest( state, + agent, context, - agent.label, customPrompt, previousWalkthrough, ); agentOptions?.onProgress?.('agent-generation'); const response = await agent.run( state.root, - prompt, - narrativeWalkthroughResponseSchema, - 'walkthrough.json', - `${agent.label} walkthrough timed out after ${Math.ceil(timeoutMs / 1_000)} seconds while processing ${fileCount} files and ${hunkCount} reviewable hunks.`, + request.prompt, + request.schema, + request.outputName, + request.timeoutMessage, { ...agentOptions, - timeoutMs, + timeoutMs: request.timeoutMs, }, ); agentOptions?.onProgress?.('response-received'); @@ -933,7 +957,7 @@ const readNarrativeWalkthrough = async ( root: state.root, source: state.source, }, - hunkIdByAlias, + request.hunkIdByAlias, ); if (context && !walkthrough.context) { walkthrough.context = context; @@ -960,9 +984,12 @@ const readNarrativeWalkthrough = async ( }; module.exports = { + NARRATIVE_WALKTHROUGH_AUTHORING_VERSION, buildNarrativeWalkthroughPrompt, + createNarrativeWalkthroughGenerationRequest, getNarrativeWalkthroughCacheKey, narrativeWalkthroughSchema, + narrativeWalkthroughResponseSchema, normalizeNarrativeWalkthrough, readNarrativeWalkthrough, resolveNarrativeWalkthroughModel, diff --git a/electron/opencode.cjs b/electron/opencode.cjs index 78216822..153ba927 100644 --- a/electron/opencode.cjs +++ b/electron/opencode.cjs @@ -327,6 +327,7 @@ const getOpenCodeServerModel = (model) => { * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onPartialText?: (delta: string) => void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; + * signal?: AbortSignal; * timeoutMs?: number; * }} [options] */ @@ -375,6 +376,7 @@ const runOpenCode = async ( ...environment, OPENCODE_PERMISSION: JSON.stringify({ '*': 'deny' }), }, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -451,10 +453,18 @@ const runOpenCode = async ( ...environment, OPENCODE_PERMISSION: JSON.stringify({ '*': 'deny' }), }, + signal: options.signal, stdio: ['ignore', 'pipe', 'pipe'], }, ); const abortController = new AbortController(); + const abortFromCaller = () => + abortController.abort(options.signal?.reason ?? new Error('Generation canceled.')); + if (options.signal?.aborted) { + abortFromCaller(); + } else { + options.signal?.addEventListener('abort', abortFromCaller, { once: true }); + } let baseUrl = ''; let sessionId = ''; let startupOutput = ''; @@ -608,6 +618,7 @@ const runOpenCode = async ( await eventStream; return normalizeStructuredOutput(output || streamedText, schema, 'OpenCode'); } finally { + options.signal?.removeEventListener('abort', abortFromCaller); clearTimeout(timeout); if (baseUrl && sessionId) { await fetch( diff --git a/electron/pi.cjs b/electron/pi.cjs index d061c2a3..86fb8d09 100644 --- a/electron/pi.cjs +++ b/electron/pi.cjs @@ -29,6 +29,7 @@ const PI_NOT_FOUND_MESSAGE = * model?: string; * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onPartialText?: (delta: string) => void; + * signal?: AbortSignal; * timeoutMs?: number; * }} PiOptions */ @@ -150,6 +151,7 @@ const runPi = async ( const child = commandTransport.spawn(commandTransport.command, piArgs, { cwd: repoRoot, env: environment, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }); diff --git a/electron/preload.cjs b/electron/preload.cjs index 89a9beb6..f9ea067e 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -19,6 +19,7 @@ const codiff = { dismissUpdate: () => ipcRenderer.invoke('codiff:dismissUpdate'), cancelDiffContentRequest: (requestId) => ipcRenderer.send('codiff:cancelDiffContentRequest', requestId), + cancelNarrativeWalkthrough: () => ipcRenderer.invoke('codiff:cancelNarrativeWalkthrough'), createWalkthroughCommit: (request) => ipcRenderer.invoke('codiff:createWalkthroughCommit', request), completePlan: (review, status) => ipcRenderer.invoke('codiff:completePlan', review, status), diff --git a/electron/reviewed-diff-signature.cjs b/electron/reviewed-diff-signature.cjs new file mode 100644 index 00000000..28f1edf8 --- /dev/null +++ b/electron/reviewed-diff-signature.cjs @@ -0,0 +1,38 @@ +// @ts-check + +const revisionIdentity = (range) => + range + ? `${range.base.kind || 'commit'}:${range.base.sha || ''}:${ + range.head.kind || 'commit' + }:${range.head.sha || ''}` + : ''; + +/** + * Stable identity for the reviewed diff. Base-only range changes invalidate + * the signature independently of provider ordering. + * + * @param {ReadonlyArray} files + */ +const getReviewedDiffSignature = (files) => { + const input = [...files] + .sort((left, right) => { + const path = left.path.localeCompare(right.path); + return path || (left.oldPath || '').localeCompare(right.oldPath || ''); + }) + .flatMap((file) => [ + file.path, + file.oldPath || '', + file.status, + file.fingerprint, + ...[...file.sections].map((section) => revisionIdentity(section.range)).sort(), + ]) + .join('\0'); + let hash = 2_166_136_261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0).toString(16); +}; + +module.exports = { getReviewedDiffSignature }; diff --git a/electron/shared-walkthrough-upload.cjs b/electron/shared-walkthrough-upload.cjs index da9196c8..1b478de4 100644 --- a/electron/shared-walkthrough-upload.cjs +++ b/electron/shared-walkthrough-upload.cjs @@ -4,6 +4,30 @@ const { trustSystemCertificates } = require('./system-certificates.cjs'); const poll = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const REDACTED_REPOSITORY_ROOT = '[redacted]'; + +const sanitizeSharedWalkthroughSnapshot = (snapshot) => { + if (!snapshot || typeof snapshot !== 'object' || snapshot.kind !== 'codiff-walkthrough-share') { + return snapshot; + } + const repository = + snapshot.repository && typeof snapshot.repository === 'object' + ? { ...snapshot.repository, root: REDACTED_REPOSITORY_ROOT } + : snapshot.repository; + let walkthrough = snapshot.walkthrough; + if (walkthrough && typeof walkthrough === 'object') { + const { context: _context, ...walkthroughWithoutContext } = walkthrough; + walkthrough = { + ...walkthroughWithoutContext, + repo: + walkthrough.repo && typeof walkthrough.repo === 'object' + ? { ...walkthrough.repo, root: REDACTED_REPOSITORY_ROOT } + : walkthrough.repo, + }; + } + return { ...snapshot, repository, walkthrough }; +}; + const CERTIFICATE_ERROR_CODES = new Set([ 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', @@ -93,6 +117,7 @@ const uploadSharedSnapshot = async ({ uploader, }) => { const certificateTrust = trustCertificates(); + const safeSnapshot = sanitizeSharedWalkthroughSnapshot(snapshot); const baseUrl = serviceUrl.replace(/\/+$/, ''); await authenticate(); @@ -153,7 +178,7 @@ const uploadSharedSnapshot = async ({ } const uploadResponse = await fetchShareService('upload request', `${baseUrl}/api/uploads`, { - body: JSON.stringify(uploader ? { snapshot, uploader } : snapshot), + body: JSON.stringify(uploader ? { snapshot: safeSnapshot, uploader } : safeSnapshot), credentials: 'include', headers: { authorization: `Bearer ${uploadToken}`, @@ -171,6 +196,7 @@ const uploadSharedSnapshot = async ({ }; module.exports = { + sanitizeSharedWalkthroughSnapshot, uploadSharedSnapshot, uploadSharedWalkthrough: uploadSharedSnapshot, }; diff --git a/electron/walkthrough-generation-bridge.cjs b/electron/walkthrough-generation-bridge.cjs new file mode 100644 index 00000000..b982e208 --- /dev/null +++ b/electron/walkthrough-generation-bridge.cjs @@ -0,0 +1,27 @@ +// @ts-check + +/** CJS bridge to Core's format-neutral walkthrough task runner (ESM). */ + +const { join } = require('node:path'); +const { pathToFileURL } = require('node:url'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadWalkthroughGeneration = async () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../core/dist/walkthrough-generation.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +/** + * @param {Parameters[0]} input + */ +const runWalkthroughGenerationTasks = async (input) => { + const module = await loadWalkthroughGeneration(); + return module.runWalkthroughGenerationTasks(input); +}; + +module.exports = { loadWalkthroughGeneration, runWalkthroughGenerationTasks }; diff --git a/electron/walkthrough-generation-cache-key.cjs b/electron/walkthrough-generation-cache-key.cjs new file mode 100644 index 00000000..e0daa65c --- /dev/null +++ b/electron/walkthrough-generation-cache-key.cjs @@ -0,0 +1,47 @@ +// @ts-check + +const { createHash } = require('node:crypto'); +const { getReviewedDiffSignature } = require('./reviewed-diff-signature.cjs'); + +/** @param {unknown} value */ +const normalizeReviewText = (value) => { + if (typeof value !== 'string') { + return null; + } + return value.trim() || null; +}; + +/** + * @param {{ + * profile: import('../core/types.ts').GenerationProfile, + * request: unknown, + * state: import('../core/types.ts').RepositoryState, + * }} input + */ +const buildWalkthroughGenerationCacheIdentity = ({ profile, request, state }) => ({ + profile, + request, + reviewedDiffSignature: getReviewedDiffSignature(state.files), + source: + state.source.type === 'pull-request' + ? { + description: normalizeReviewText(state.source.description), + headSha: state.source.headSha ?? null, + number: state.source.number ?? null, + provider: state.source.provider ?? null, + projectPath: state.source.projectPath ?? null, + targetBranch: state.source.targetBranch ?? null, + title: normalizeReviewText(state.source.title), + url: state.source.url, + } + : state.source, + version: 1, +}); + +/** @param {Parameters[0]} input */ +const getWalkthroughGenerationCacheKey = (input) => + `walkthrough-generation:${createHash('sha256') + .update(JSON.stringify(buildWalkthroughGenerationCacheIdentity(input))) + .digest('hex')}`; + +module.exports = { buildWalkthroughGenerationCacheIdentity, getWalkthroughGenerationCacheKey }; diff --git a/electron/walkthrough-generation-coordinator.cjs b/electron/walkthrough-generation-coordinator.cjs new file mode 100644 index 00000000..3297e0a2 --- /dev/null +++ b/electron/walkthrough-generation-coordinator.cjs @@ -0,0 +1,67 @@ +// @ts-check + +const SUPERSEDED_GENERATION_REASON = 'A newer walkthrough generation request replaced this one.'; + +const createWalkthroughGenerationCoordinator = () => { + /** @type {Map} */ + const active = new Map(); + /** @type {Map}>} */ + const reusable = new Map(); + + /** @param {number} key @param {unknown} [reason] */ + const cancel = (key, reason = new Error('Walkthrough generation was canceled.')) => { + const controller = active.get(key); + if (!controller) { + return; + } + active.delete(key); + controller.abort(reason); + }; + + return { + /** @param {number} key */ + begin(key) { + active.get(key)?.abort(new Error(SUPERSEDED_GENERATION_REASON)); + const controller = new AbortController(); + active.set(key, controller); + return controller; + }, + + cancel, + + /** @param {number} key @param {unknown} [reason] */ + clear(key, reason = new Error('The walkthrough window was closed.')) { + cancel(key, reason); + reusable.delete(key); + }, + + /** @param {number} key @param {AbortController} controller */ + finish(key, controller) { + if (active.get(key) === controller) { + active.delete(key); + } + }, + + /** @param {number} key @param {string} cacheKey @param {boolean} [force] */ + getReusable(key, cacheKey, force = false) { + const entry = reusable.get(key); + return !force && entry?.cacheKey === cacheKey ? entry.components : undefined; + }, + + /** + * @param {number} key + * @param {AbortController} controller + * @param {string} cacheKey + * @param {ReadonlyArray} components + */ + retain(key, controller, cacheKey, components) { + if (active.get(key) !== controller) { + return false; + } + reusable.set(key, { cacheKey, components }); + return true; + }, + }; +}; + +module.exports = { createWalkthroughGenerationCoordinator, SUPERSEDED_GENERATION_REASON }; diff --git a/electron/walkthrough-model-invocation.cjs b/electron/walkthrough-model-invocation.cjs new file mode 100644 index 00000000..64b1f86a --- /dev/null +++ b/electron/walkthrough-model-invocation.cjs @@ -0,0 +1,98 @@ +// @ts-check + +/** @param {unknown} response */ +const parseStructuredModelResponse = (response) => { + const text = typeof response === 'string' ? response : String(response ?? ''); + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const raw = (fenced?.[1] || text).trim(); + try { + return JSON.parse(raw); + } catch { + return raw; + } +}; + +/** @param {AbortSignal | undefined} signal @param {() => Promise} run */ +const runWithCancellation = async (signal, run) => { + if (!signal) { + return run(); + } + signal.throwIfAborted(); + /** @type {((reason?: unknown) => void) | null} */ + let rejectCancellation = null; + const cancelled = new Promise((_, reject) => { + rejectCancellation = reject; + }); + const onAbort = () => rejectCancellation?.(signal.reason ?? new Error('Generation cancelled.')); + signal.addEventListener('abort', onAbort, { once: true }); + try { + return await Promise.race([run(), cancelled]); + } finally { + signal.removeEventListener('abort', onAbort); + } +}; + +/** + * Invoke one configured model profile while recording the actual fallback that + * produced the accepted response. The caller owns schema-specific parsing. + * + * @param {{ + * agent: ReturnType, + * agentOptions?: Parameters['run']>[5], + * generatedAt?: () => string, + * outputName?: string, + * profile: import('../core/types.ts').GenerationProfile, + * prompt: string, + * repoRoot: string, + * schema: unknown, + * signal?: AbortSignal, + * timeoutMessage?: string, + * timeoutMs?: number, + * }} input + */ +const invokeWalkthroughModel = async (input) => { + if (input.profile.agent !== input.agent.id) { + throw new Error('The generation profile does not match the selected agent.'); + } + const candidates = [ + ...new Set(input.profile.modelCandidates.map((model) => input.agent.normalizeModel(model))), + ]; + if (candidates.length === 0) { + throw new Error('A generation profile requires at least one model candidate.'); + } + let generatedModel = candidates[0]; + const onModelFallback = input.agentOptions?.onModelFallback; + const response = await runWithCancellation(input.signal, () => + input.agent.run( + input.repoRoot, + input.prompt, + input.schema, + input.outputName, + input.timeoutMessage, + { + ...input.agentOptions, + fallbackModel: candidates.at(-1), + model: candidates[0], + onModelFallback: async (fallbackModel, originalModel) => { + input.signal?.throwIfAborted(); + generatedModel = input.agent.normalizeModel(fallbackModel); + await onModelFallback?.(fallbackModel, originalModel); + }, + signal: input.signal, + timeoutMs: input.timeoutMs ?? input.agent.defaultTimeoutMs, + }, + ), + ); + input.signal?.throwIfAborted(); + return { + generationMetadata: { + agent: input.agent.id, + generatedAt: input.generatedAt?.() ?? new Date().toISOString(), + model: generatedModel, + profile: input.profile, + }, + response, + }; +}; + +module.exports = { invokeWalkthroughModel, parseStructuredModelResponse }; diff --git a/electron/walkthrough-progress.cjs b/electron/walkthrough-progress.cjs index dc0f0457..565751c5 100644 --- a/electron/walkthrough-progress.cjs +++ b/electron/walkthrough-progress.cjs @@ -8,14 +8,16 @@ * @param {() => boolean} [isCurrent] */ const createWalkthroughProgressReporter = (webContents, isCurrent = () => true) => { - /** @param {import('../core/types.ts').WalkthroughProgressPhase} phase */ - return (phase) => { + /** + * @param {import('../core/types.ts').WalkthroughGenerationProgress | import('../core/types.ts').WalkthroughProgressPhase} update + */ + return (update) => { if (webContents.isDestroyed() || !isCurrent()) { return; } /** @type {import('../core/types.ts').WalkthroughProgressEvent} */ - const progress = { phase }; + const progress = typeof update === 'string' ? { phase: update } : { generation: update }; webContents.send('codiff:walkthroughProgress', progress); }; }; diff --git a/forge.config.cjs b/forge.config.cjs index 3a17ff88..06b99e4c 100644 --- a/forge.config.cjs +++ b/forge.config.cjs @@ -17,6 +17,7 @@ const macAssetCatalogPath = existsSync(join(__dirname, 'electron/icons/Assets.ca const linuxIconPath = './electron/icons/icon.png'; const windowsIconPath = './electron/icons/icon.ico'; const runtimeCopies = [ + ['core/dist', 'core/dist'], ['core/lib/narrative-walkthrough-diff.cjs', 'core/lib/narrative-walkthrough-diff.cjs'], ['github/dist', 'github/dist'], ['gitlab/dist', 'gitlab/dist'], diff --git a/scripts/verify-package-runtime.mjs b/scripts/verify-package-runtime.mjs index 5645d3ee..05694133 100644 --- a/scripts/verify-package-runtime.mjs +++ b/scripts/verify-package-runtime.mjs @@ -12,9 +12,12 @@ const root = process.cwd(); const runtimeFiles = [ 'electron/github-history-bridge.cjs', 'electron/gitlab-history-bridge.cjs', + 'electron/walkthrough-generation-bridge.cjs', + 'core/dist/walkthrough-generation.mjs', 'github/dist/index.mjs', 'gitlab/dist/index.mjs', ]; +const runtimeDirectories = ['core/dist', 'github/dist', 'gitlab/dist']; const builtin = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]); const run = (command, args) => @@ -99,23 +102,36 @@ for (const path of ['github/dist/index.mjs', 'gitlab/dist/index.mjs']) { const directory = await mkdtemp(join(tmpdir(), 'codiff-package-runtime-')); try { - for (const path of runtimeFiles) { + for (const path of runtimeFiles.filter((path) => path.startsWith('electron/'))) { + const destination = join(directory, path); + await mkdir(dirname(destination), { recursive: true }); + await cp(join(root, path), destination, { recursive: true }); + } + for (const path of runtimeDirectories) { const destination = join(directory, path); await mkdir(dirname(destination), { recursive: true }); await cp(join(root, path), destination, { recursive: true }); } const require = createRequire(join(directory, 'package.json')); - const [{ loadGitHubHistory }, { loadGitLabHistory }] = [ + const [{ loadGitHubHistory }, { loadGitLabHistory }, { loadWalkthroughGeneration }] = [ require(join(directory, 'electron/github-history-bridge.cjs')), require(join(directory, 'electron/gitlab-history-bridge.cjs')), + require(join(directory, 'electron/walkthrough-generation-bridge.cjs')), ]; - const [github, gitlab] = await Promise.all([loadGitHubHistory(), loadGitLabHistory()]); + const [github, gitlab, walkthroughGeneration] = await Promise.all([ + loadGitHubHistory(), + loadGitLabHistory(), + loadWalkthroughGeneration(), + ]); if (typeof github.createGitHubArtifactSource !== 'function') { throw new Error('GitHub runtime bridge did not load createGitHubArtifactSource.'); } if (typeof gitlab.createGitLabArtifactSource !== 'function') { throw new Error('GitLab runtime bridge did not load createGitLabArtifactSource.'); } + if (typeof walkthroughGeneration.runWalkthroughGenerationTasks !== 'function') { + throw new Error('Core walkthrough-generation runtime did not load the task runner.'); + } } finally { await rm(directory, { force: true, recursive: true }); } From cf4e2a96f028ca4b20d3b1892b159b081685b450 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 12:10:52 -0500 Subject: [PATCH 10/17] Carry exact review ranges through local diffs and comment positions Attach commit-to-commit, commit-to-index, and index-to-working-copy ranges to local sections, including nullable absent sides and conflict-stage index coordinates. Separate local, provider, and share submissions; require two commit endpoints for provider writes; persist share positions; and keep outdated provider threads at their original locations. --- core/ReviewSurface.tsx | 311 ++++++++++---- core/__tests__/App.test.tsx | 5 + core/__tests__/CopyCommentsButton.test.tsx | 40 +- ...RepositoryReviewHost-capabilities.test.tsx | 17 + core/__tests__/ReviewCodeView-scroll.test.tsx | 39 +- .../ReviewSurface-capabilities.test.tsx | 125 +++++- core/__tests__/ReviewSurface.test.tsx | 4 - .../app-review-comment-hooks.test.tsx | 20 +- core/__tests__/git-state.test.ts | 107 ++++- core/__tests__/review-comment-hooks.test.tsx | 16 +- core/__tests__/review-comments.test.ts | 393 +++++++++++++++--- core/app/RepositoryReviewHost.tsx | 78 ++-- core/app/components/ReviewCodeView.tsx | 50 ++- core/app/hooks/useAppReviewComments.ts | 54 ++- core/app/hooks/useReviewCommentDrafts.ts | 66 ++- core/index.ts | 14 + core/lib/app-types.ts | 90 +++- core/lib/review-comments.ts | 390 +++++++++++++---- core/lib/review-history.ts | 13 +- core/react.ts | 12 + core/types/review-comments.ts | 89 +++- electron/git-state/commit.cjs | 2 +- electron/git-state/common.cjs | 43 +- electron/git-state/comparison.cjs | 29 +- electron/git-state/working-tree.cjs | 19 +- service/api.ts | 6 + service/fate.ts | 35 +- service/react.test.ts | 2 + service/react.tsx | 2 + service/review-position.test.ts | 35 ++ service/review-position.ts | 36 ++ service/schema.ts | 1 + service/views.ts | 1 + .../0003_share_comment_position.sql | 1 + web/src/sharing/ShareComments.tsx | 1 + web/src/sharing/WalkthroughPage.test.tsx | 208 ++++++++- web/src/sharing/WalkthroughPage.tsx | 24 +- 37 files changed, 1970 insertions(+), 408 deletions(-) create mode 100644 service/review-position.test.ts create mode 100644 service/review-position.ts create mode 100644 web/db/migrations/0003_share_comment_position.sql diff --git a/core/ReviewSurface.tsx b/core/ReviewSurface.tsx index e612e0e5..2068dbf3 100644 --- a/core/ReviewSurface.tsx +++ b/core/ReviewSurface.tsx @@ -73,8 +73,13 @@ import type { CodiffDiffStyle, CodiffKeymap } from './config/types.ts'; import { getAgentLabel } from './lib/app-constants.ts'; import type { CodeViewInstance, + LocalReviewNote, + ProviderCommentDraft, + RenderedSubmittedReviewComment, ReviewComment, + ReviewDraft, ReviewScrollTarget, + ShareCommentDraft, WalkthroughError, } from './lib/app-types.ts'; import type { Command } from './lib/command-registry.ts'; @@ -91,9 +96,15 @@ import { buildReviewCommentsMarkdown, getPendingPullRequestReviewComments, getReviewCommentsFromState, + isLocalReviewNote, + isProviderCommentDraft, + isReviewDraft, + isShareCommentDraft, + isSubmittedReviewComment, mergeReviewComments, - toSubmittedReviewComment, - toPullRequestReviewComment, + toProviderCommentSubmission, + toRenderedSubmittedReviewComment, + toShareCommentSubmission, } from './lib/review-comments.ts'; import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; import { @@ -141,7 +152,8 @@ import type { export { ReadOnlyGeneralCommentCard } from './app/components/merge-request/GeneralComments.tsx'; export type { ReviewCommenting } from './types.ts'; -const emptyReviewComments: ReadonlyArray = []; +const emptyReviewComments: ReadonlyArray = []; +const emptyReviewDrafts: ReadonlyArray = []; const emptyGeneralCommentThreads: ReadonlyArray = []; const emptyPaths = new Set(); const emptyWalkthroughNotes = new Map(); @@ -230,18 +242,18 @@ export type ControlledReviewValue = Readonly<{ value: Value; }>; -export type ControlledReviewDrafts = Readonly<{ +export type ControlledReviewDrafts = Readonly<{ onChange: Dispatch>>; value: ReadonlyArray; }>; -type ReviewDraftCapabilities = { +type ReviewDraftCapabilities = { canCreateInline?: boolean; - drafts?: ControlledReviewDrafts; - onAsk?: (comment: ReviewComment) => void; + drafts?: ControlledReviewDrafts; + onAsk?: (comment: Draft) => void; }; -export type LocalReviewNoteCapabilities = ReviewDraftCapabilities; +export type LocalReviewNoteCapabilities = ReviewDraftCapabilities; export type CommentDestination = 'provider' | 'share'; export type CommentAnchorPolicy = 'provider-target' | 'share-snapshot'; @@ -254,16 +266,19 @@ export type SubmitProviderReviewRequest = { }; export type ProviderReviewSessionCapabilities = { - drafts: ControlledReviewDrafts; + drafts: ControlledReviewDrafts; submit: (request: SubmitProviderReviewRequest) => Promise; }; +type ReviewCommentDraftForDestination = + Destination extends 'share' ? ShareCommentDraft : ProviderCommentDraft; + type ReviewCommentSubmission = Destination extends 'share' ? ShareCommentSubmission : ProviderCommentSubmission; type CommonReviewCommentCapabilities = { - authoring: ReviewDraftCapabilities; + authoring: ReviewDraftCapabilities>; destination: Destination; general?: { onCreate?: (body: string) => Promise; @@ -428,20 +443,24 @@ export const buildSharedReviewSnapshot = ({ const getSnapshotReviewComments = ( snapshot: SharedWalkthroughSnapshot, -): ReadonlyArray => { + destination: CommentDestination, +): ReadonlyArray => { if (!snapshot.reviewComments?.length) { return emptyReviewComments; } - return getReviewCommentsFromState({ - branch: snapshot.branch, - files: snapshot.files, - generatedAt: Date.parse(snapshot.exportedAt) || Date.now(), - launchPath: snapshot.repository.root, - reviewComments: snapshot.reviewComments as ReadonlyArray, - root: snapshot.repository.root, - source: snapshot.repository.source, - } satisfies RepositoryState); + return getReviewCommentsFromState( + { + branch: snapshot.branch, + files: snapshot.files, + generatedAt: Date.parse(snapshot.exportedAt) || Date.now(), + launchPath: snapshot.repository.root, + reviewComments: snapshot.reviewComments as ReadonlyArray, + root: snapshot.repository.root, + source: snapshot.repository.source, + } satisfies RepositoryState, + destination, + ); }; const noop = () => {}; @@ -519,20 +538,17 @@ export function ReviewSurface({ const history = capabilities?.history; const localReviewNotes = capabilities?.localReviewNotes; const comments = capabilities?.comments; + const providerComments = comments?.destination === 'provider' ? comments : undefined; + const shareComments = comments?.destination === 'share' ? comments : undefined; const controlledPreferences = capabilities?.preferences; const sourceNavigation = capabilities?.sourceNavigation; const walkthrough = capabilities?.walkthrough; - const reviewSession = comments?.destination === 'provider' ? comments.reviewSession : undefined; + const reviewSession = providerComments?.reviewSession; const canComment = localReviewNotes?.canCreateInline ?? comments?.authoring.canCreateInline ?? comments?.inline.onSubmit != null; - const reviewDrafts = - localReviewNotes ?? - (comments && (canComment || comments.authoring.drafts || reviewSession?.drafts) - ? comments.authoring - : undefined); - const controlledReviewDrafts = reviewSession?.drafts ?? reviewDrafts?.drafts; + const reviewDrafts = localReviewNotes ?? comments?.authoring; const copyPendingCommentsLabel = localReviewNotes ? 'Copy Review Notes' : 'Copy Pending Review Comments'; @@ -553,7 +569,6 @@ export function ReviewSurface({ onReplyGeneralComment: comments.general?.onReply, onResolveDiscussion: comments.general?.onResolve ?? comments.inline.onResolve, onSignIn: comments.onSignIn, - onSubmitComment: comments.inline.onSubmit, onSubmitGeneralComment: comments.general?.onCreate, onUpdateComment: comments.inline.onUpdate, onUpdateGeneralComment: comments.general?.onUpdate, @@ -661,46 +676,128 @@ export function ReviewSurface({ position: sidebarPosition, readWidth: readSharedSidebarWidth, }); - const snapshotReviewComments = useMemo(() => getSnapshotReviewComments(snapshot), [snapshot]); + const snapshotReviewComments = useMemo( + () => getSnapshotReviewComments(snapshot, comments?.destination ?? 'provider'), + [comments?.destination, snapshot], + ); + const reviewCommentScopeKey = `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`; const showOutdated = controlledPreferences?.outdatedVisibility?.value ?? true; - const [editedReviewCommentBodies, setEditedReviewCommentBodies] = useState< - Readonly> - >({}); - const visibleSnapshotReviewComments = useMemo( + const [submittedReviewCommentState, setSubmittedReviewCommentState] = useState<{ + comments: ReadonlyArray; + scopeKey: string; + }>(() => ({ comments: [], scopeKey: reviewCommentScopeKey })); + const submittedReviewComments = useMemo( + () => + submittedReviewCommentState.scopeKey === reviewCommentScopeKey + ? submittedReviewCommentState.comments + : [], + [reviewCommentScopeKey, submittedReviewCommentState], + ); + const setSubmittedReviewComments = useCallback< + Dispatch>> + >( + (update) => { + setSubmittedReviewCommentState((current) => { + const scopedCurrent = current.scopeKey === reviewCommentScopeKey ? current.comments : []; + return { + comments: typeof update === 'function' ? update(scopedCurrent) : update, + scopeKey: reviewCommentScopeKey, + }; + }); + }, + [reviewCommentScopeKey], + ); + const [editedReviewCommentBodyState, setEditedReviewCommentBodyState] = useState<{ + bodies: Readonly>; + scopeKey: string; + }>(() => ({ bodies: {}, scopeKey: reviewCommentScopeKey })); + const editedReviewCommentBodies = useMemo( () => - snapshotReviewComments - .filter((comment) => showOutdated || !comment.isOutdated) - .map((comment) => ({ - ...comment, - ...(editedReviewCommentBodies[comment.id] != null && - editedReviewCommentBodies[comment.id] !== comment.body - ? { body: editedReviewCommentBodies[comment.id] } - : {}), - canDelete: comment.canDelete === true && comments?.inline.onDelete != null, - canEdit: comment.canEdit === true && comments?.inline.onUpdate != null, - canReplyThread: comment.canReplyThread !== false && comments?.inline.onSubmit != null, - canResolveThread: comment.canResolveThread === true && comments?.inline.onResolve != null, - })), - [comments, editedReviewCommentBodies, showOutdated, snapshotReviewComments], + editedReviewCommentBodyState.scopeKey === reviewCommentScopeKey + ? editedReviewCommentBodyState.bodies + : {}, + [editedReviewCommentBodyState, reviewCommentScopeKey], ); + const setEditedReviewCommentBodies = useCallback< + Dispatch>>> + >( + (update) => { + setEditedReviewCommentBodyState((current) => { + const scopedCurrent = current.scopeKey === reviewCommentScopeKey ? current.bodies : {}; + return { + bodies: typeof update === 'function' ? update(scopedCurrent) : update, + scopeKey: reviewCommentScopeKey, + }; + }); + }, + [reviewCommentScopeKey], + ); + const visibleSnapshotReviewComments = useMemo(() => { + const snapshotIds = new Set(snapshotReviewComments.map((comment) => comment.id)); + return [ + ...snapshotReviewComments, + ...submittedReviewComments.filter((comment) => !snapshotIds.has(comment.id)), + ] + .filter((comment) => showOutdated || !comment.isOutdated) + .map((comment) => ({ + ...comment, + ...(editedReviewCommentBodies[comment.id] != null && + editedReviewCommentBodies[comment.id] !== comment.body + ? { body: editedReviewCommentBodies[comment.id] } + : {}), + canDelete: comment.canDelete === true && comments?.inline.onDelete != null, + canEdit: comment.canEdit === true && comments?.inline.onUpdate != null, + canReplyThread: comment.canReplyThread !== false && comments?.inline.onSubmit != null, + canResolveThread: comment.canResolveThread === true && comments?.inline.onResolve != null, + })); + }, [ + comments, + editedReviewCommentBodies, + showOutdated, + snapshotReviewComments, + submittedReviewComments, + ]); const [uncontrolledLocalReviewComments, setUncontrolledLocalReviewComments] = - useState>(emptyReviewComments); + useState>(emptyReviewDrafts); const uncontrolledLocalReviewCommentsRef = useRef(uncontrolledLocalReviewComments); - const localReviewComments = controlledReviewDrafts?.value ?? uncontrolledLocalReviewComments; + const providerControlledDrafts = reviewSession?.drafts ?? providerComments?.authoring.drafts; + const localReviewComments: ReadonlyArray = + localReviewNotes?.drafts?.value ?? + shareComments?.authoring.drafts?.value ?? + providerControlledDrafts?.value ?? + uncontrolledLocalReviewComments; const setLocalReviewComments = useCallback< Dispatch>> >( (update) => { - if (controlledReviewDrafts) { - controlledReviewDrafts.onChange(update); + if (localReviewNotes?.drafts) { + localReviewNotes.drafts.onChange((current) => { + const next = typeof update === 'function' ? update(current) : update; + return next.filter(isLocalReviewNote); + }); + return; + } + if (shareComments?.authoring.drafts) { + shareComments.authoring.drafts.onChange((current) => { + const next = typeof update === 'function' ? update(current) : update; + return next.filter(isShareCommentDraft); + }); + return; + } + if (providerControlledDrafts) { + providerControlledDrafts.onChange((current) => { + const next = typeof update === 'function' ? update(current) : update; + return next.filter(isProviderCommentDraft); + }); return; } const nextComments = typeof update === 'function' ? update(uncontrolledLocalReviewCommentsRef.current) : update; - uncontrolledLocalReviewCommentsRef.current = nextComments; - setUncontrolledLocalReviewComments(nextComments); + const nextDrafts = nextComments.filter(isReviewDraft); + uncontrolledLocalReviewCommentsRef.current = nextDrafts; + setUncontrolledLocalReviewComments(nextDrafts); }, - [controlledReviewDrafts], + [localReviewNotes, providerControlledDrafts, shareComments], ); const reviewComments = useMemo( () => mergeReviewComments(visibleSnapshotReviewComments, localReviewComments), @@ -721,6 +818,7 @@ export function ReviewSurface({ } = useReviewCommentDrafts({ canCreateComment: canComment, comments: reviewComments, + draftKind: localReviewNotes ? 'local-note' : shareComments ? 'share-draft' : 'provider-draft', onCommentFileChange: bumpItemVersion, setComments: setLocalReviewComments, }); @@ -859,6 +957,18 @@ export function ReviewSurface({ }, [activeMode], ); + const askReviewAssistant = useCallback( + (comment: ReviewComment) => { + if (isLocalReviewNote(comment)) { + localReviewNotes?.onAsk?.(comment); + } else if (isProviderCommentDraft(comment)) { + providerComments?.authoring.onAsk?.(comment); + } else if (isShareCommentDraft(comment)) { + shareComments?.authoring.onAsk?.(comment); + } + }, + [localReviewNotes, providerComments, shareComments], + ); const activateGeneralComment = useCallback( (commentId: string) => { @@ -961,14 +1071,19 @@ export function ReviewSurface({ bumpItemVersion(comment.filePath); } }, - [bumpItemVersion, reviewCommentsRef, updateReviewComment], + [bumpItemVersion, reviewCommentsRef, setEditedReviewCommentBodies, updateReviewComment], ); const deleteComment = useCallback( (commentId: string) => { const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if (comment?.isReadOnly && comment.canDelete && comments?.inline.onDelete) { + if ( + comment && + isSubmittedReviewComment(comment) && + comment.canDelete && + comments?.inline.onDelete + ) { updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => + setSubmittedReviewComments((current) => current.filter((candidate) => candidate.id !== commentId), ); void comments.inline.onDelete(commentId).catch((error: unknown) => { @@ -982,17 +1097,19 @@ export function ReviewSurface({ comments, deleteLocalComment, reviewCommentsRef, - setLocalReviewComments, + setSubmittedReviewComments, updateActiveReviewCommentDraft, ], ); const submitComment = useCallback( (commentId: string) => { const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + const isPersistedDraft = + comment != null && (isProviderCommentDraft(comment) || isShareCommentDraft(comment)); if ( - !submitReviewComment || + !comments?.inline.onSubmit || !comment || - comment.isReadOnly || + !isPersistedDraft || !comment.body.trim() || comment.remoteSubmit?.status === 'submitting' ) { @@ -1002,36 +1119,63 @@ export function ReviewSurface({ updateActiveReviewCommentDraft(null); setLocalReviewComments((current) => current.map((candidate) => - candidate.id === commentId - ? { ...candidate, remoteSubmit: { status: 'submitting' } } + candidate.id === commentId && + (isProviderCommentDraft(candidate) || isShareCommentDraft(candidate)) + ? { ...candidate, remoteSubmit: { status: 'submitting' as const } } : candidate, ), ); - const submission = toPullRequestReviewComment(comment, { - includeSectionId: comments?.destination === 'share', - }); - void submitReviewComment(submission) + + let submission: Promise; + try { + if (comments.destination === 'share' && isShareCommentDraft(comment)) { + submission = comments.inline.onSubmit(toShareCommentSubmission(comment)); + } else if (comments.destination === 'provider' && isProviderCommentDraft(comment)) { + submission = comments.inline.onSubmit(toProviderCommentSubmission(comment)); + } else { + return; + } + } catch (error: unknown) { + setLocalReviewComments((current) => + current.map((candidate) => + candidate.id === commentId && + (isProviderCommentDraft(candidate) || isShareCommentDraft(candidate)) + ? { + ...candidate, + remoteSubmit: { + error: error instanceof Error ? error.message : String(error), + status: 'error' as const, + }, + } + : candidate, + ), + ); + return; + } + + void submission .then((submittedComment) => { clearCommentFocus(commentId); + const submitted = toRenderedSubmittedReviewComment(submittedComment, comment); setLocalReviewComments((current) => - current.flatMap((candidate) => { - if (candidate.id !== commentId) { - return [candidate]; - } - return [toSubmittedReviewComment(submittedComment, candidate)]; - }), + current.filter((candidate) => candidate.id !== commentId), ); + setSubmittedReviewComments((current) => [ + ...current.filter((candidate) => candidate.id !== submitted.id), + submitted, + ]); bumpItemVersion(comment.filePath); }) .catch((error: unknown) => { setLocalReviewComments((current) => current.map((candidate) => - candidate.id === commentId + candidate.id === commentId && + (isProviderCommentDraft(candidate) || isShareCommentDraft(candidate)) ? { ...candidate, remoteSubmit: { error: error instanceof Error ? error.message : String(error), - status: 'error', + status: 'error' as const, }, } : candidate, @@ -1043,10 +1187,10 @@ export function ReviewSurface({ [ bumpItemVersion, clearCommentFocus, - comments?.destination, + comments, reviewCommentsRef, setLocalReviewComments, - submitReviewComment, + setSubmittedReviewComments, updateActiveReviewCommentDraft, ], ); @@ -1063,7 +1207,7 @@ export function ReviewSurface({ } const pendingComments = getPendingPullRequestReviewComments( - reviewCommentsRef.current, + reviewCommentsRef.current.filter(isProviderCommentDraft), activeReviewCommentDraftRef.current, ); if (event === 'COMMENT' && pendingComments.length === 0 && !body?.trim()) { @@ -1072,7 +1216,7 @@ export function ReviewSurface({ const pendingIds = new Set(pendingComments.map((comment) => comment.id)); setPullRequestReviewSubmitting(event); const formattedComments = pendingComments.map((comment) => - toPullRequestReviewComment(comment), + toProviderCommentSubmission(comment), ); const submission = reviewSession.submit({ comments: formattedComments, @@ -1479,7 +1623,10 @@ export function ReviewSurface({ itemVersionByKey, keymap, loadingSectionIds: content?.loadingSectionIds ?? new Set(), - onAskCodex: reviewDrafts?.onAsk, + onAskCodex: + localReviewNotes?.onAsk || providerComments?.authoring.onAsk || shareComments?.authoring.onAsk + ? askReviewAssistant + : undefined, onCommentDraftChange: updateActiveReviewCommentDraft, onCreateComment: createComment, onDeleteComment: deleteComment, @@ -1536,8 +1683,10 @@ export function ReviewSurface({ pullRequestReadySubmitting } hasPendingComments={ - getPendingPullRequestReviewComments(localReviewComments, activeReviewCommentDraftState) - .length > 0 + getPendingPullRequestReviewComments( + localReviewComments.filter(isProviderCommentDraft), + activeReviewCommentDraftState, + ).length > 0 } onClosePullRequest={sourceNavigation?.onClosePullRequest ? closePullRequest : undefined} onMarkPullRequestReady={ diff --git a/core/__tests__/App.test.tsx b/core/__tests__/App.test.tsx index 7099236a..4c7baebb 100644 --- a/core/__tests__/App.test.tsx +++ b/core/__tests__/App.test.tsx @@ -647,6 +647,7 @@ test('review comment markdown includes file and patch context', () => { body: 'Please double-check this value.', filePath: 'src/comment.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 2, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -688,6 +689,7 @@ test('review comment markdown includes file-level comments', () => { body: 'Please review the file structure.', filePath: 'src/comment.ts', id: 'comment-file', + kind: 'local-note', sectionId: 'src/comment.ts:unstaged', }, ], @@ -729,6 +731,7 @@ test('review comment markdown uses custom prefix', () => { body: 'Check this.', filePath: 'src/comment.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 2, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -772,6 +775,7 @@ test('review comment markdown omits prefix when set to empty string', () => { body: 'Check this.', filePath: 'src/comment.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 2, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -815,6 +819,7 @@ test('review comment markdown includes multi-line ranges', () => { body: 'These should be considered together.', filePath: 'src/range.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 2, sectionId: 'src/range.ts:unstaged', side: 'additions', diff --git a/core/__tests__/CopyCommentsButton.test.tsx b/core/__tests__/CopyCommentsButton.test.tsx index 4cc6fca8..59d1f3af 100644 --- a/core/__tests__/CopyCommentsButton.test.tsx +++ b/core/__tests__/CopyCommentsButton.test.tsx @@ -4,30 +4,40 @@ import { expect, test } from 'vite-plus/test'; import { CopyCommentsButton } from '../app/components/Panels.tsx'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { LocalReviewNote, ReviewComment } from '../lib/app-types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { renderReact } from './helpers/react.tsx'; const file = createChangedFile('src/app.ts'); -const createReviewComment = (comment: Partial) => - ({ - body: 'Rename this helper.', - filePath: file.path, - id: 'comment-1', - lineNumber: 1, - sectionId: file.sections[0].id, - side: 'additions', - ...comment, - }) satisfies ReviewComment; +const createReviewComment = (comment: Partial): LocalReviewNote => ({ + body: 'Rename this helper.', + filePath: file.path, + id: 'comment-1', + kind: 'local-note', + lineNumber: 1, + sectionId: file.sections[0].id, + side: 'additions', + ...comment, +}); + +const submittedComment = { + author: { login: 'reviewer' }, + body: 'Existing comment.', + destination: 'share', + filePath: file.path, + id: 'comment-2', + isReadOnly: true, + kind: 'submitted-comment', + lineNumber: 1, + sectionId: file.sections[0].id, + side: 'additions', +} satisfies ReviewComment; test('stays visible but disabled until a comment with a body exists', async () => { await using app = await renderReact( getSurfaceProps().capabilities?.history?.onSelectSource(commitRequest)); await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(commitSource)); expect(getSurfaceProps().capabilities?.localReviewNotes).toBeDefined(); + expect(getSurfaceProps().capabilities?.localReviewNotes?.drafts?.value).toEqual([]); + expect(getSurfaceProps().capabilities?.history?.pullRequestSource).toEqual(pullRequestSource); await act(async () => getSurfaceProps().capabilities?.localReviewNotes?.drafts?.onChange([localNote]), ); diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index 0a943494..cb298f25 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -620,6 +620,7 @@ test('focused walkthrough blocks render only global comments visible in the focu body: 'Visible focused comment.', filePath: file.path, id: 'visible-comment', + kind: 'local-note', lineNumber: 1, sectionId: file.sections[0].id, side: 'additions', @@ -666,6 +667,7 @@ test('focused walkthrough blocks keep cross-side comments when their rendered an body: 'Cross-side comment.', filePath: file.path, id: 'cross-side-comment', + kind: 'local-note', lineNumber: 10, sectionId: file.sections[0].id, side: 'additions', @@ -750,6 +752,7 @@ test('review comment drafts resync clean external updates and reset on comment s body: 'Original body', filePath: file.path, id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: file.sections[0].id, side: 'additions', @@ -808,11 +811,13 @@ test('read-only review comments render safe details blocks', async () => { const comment = { author: { login: 'ai-reviewer', name: 'AI Code Reviewer' }, body: '
\nReview rationale\n\nThis branch needs attention.\n\n
', + destination: 'provider', filePath: file.path, id: 'comment-details', isReadOnly: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', } satisfies ReviewComment; @@ -1360,6 +1365,7 @@ test('hunk navigation orders deletion comments before added rows in unified chan body: 'Needs work.', filePath: 'src/first.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: 'src/first.ts:unstaged', side: 'deletions', @@ -1420,6 +1426,7 @@ test('review comment typing stays local until a comment action commits it', asyn body: '', filePath: file.path, id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -1497,6 +1504,7 @@ const renderLocalReviewComment = async ({ body, filePath: file.path, id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: file.sections[0].id, side: 'additions', @@ -1797,6 +1805,7 @@ test('a Codex reply does not repeatedly invalidate the diff item layout', async body: 'Please explain this change.', filePath: file.path, id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -1816,7 +1825,7 @@ test('a Codex reply does not repeatedly invalidate the diff item layout', async ...comment, codexReply: { body: 'This reply is tall enough to use a different markdown measurement.', - status: 'ready', + status: 'ready' as const, }, }, ]} @@ -1833,6 +1842,7 @@ test('failed pull request comments keep their draft and can be retried', async ( body: 'Keep this comment.', filePath: file.path, id: 'comment-1', + kind: 'provider-draft', lineNumber: 1, remoteSubmit: { error: @@ -1880,6 +1890,7 @@ test('working-tree share comments support the Comment button and Mod+Enter', asy body: 'Submit this shared comment.', filePath: file.path, id: 'shared-comment', + kind: 'share-draft', lineNumber: 1, sectionId: file.sections[0].id, side: 'additions', @@ -1922,7 +1933,12 @@ test('working-tree share comments support the Comment button and Mod+Enter', asy }); test('file comments can be created for GitLab merge requests but not GitHub pull requests', async () => { - const file = createChangedFile('src/comment.ts'); + const file: ChangedFile = createChangedFile('src/comment.ts'); + const range = { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: gitSha('a'.repeat(40)) }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: gitSha('b'.repeat(40)) }, + }; + file.sections[0]!.range = range; const onCreateComment = vi.fn(); const gitLabSource = { provider: 'gitlab', @@ -1952,6 +1968,7 @@ test('file comments can be created for GitLab merge requests but not GitHub pull expect(onCreateComment).toHaveBeenCalledWith({ anchor: 'file', filePath: 'src/comment.ts', + position: { range }, sectionId: 'src/comment.ts:unstaged', }); await view.rerender( @@ -1974,10 +1991,12 @@ test('file-level review comments render as measured file annotations', async () anchor: 'file', author: { login: 'reviewer' }, body: 'Review this file as a whole.', + destination: 'provider', filePath: file.path, id: 'gitlab:file', isReadOnly: true, - sectionId: 'src/comment.ts:unstaged', + kind: 'submitted-comment', + resolvedSectionId: 'src/comment.ts:unstaged', }, ]} files={[file]} @@ -2617,10 +2636,18 @@ test('modifier navigation follows file hosts reused by CodeView virtualization', test('line content clicks only ignore text selected on the clicked line', async () => { const onCreateComment = vi.fn(); - const file = createChangedFileWithPatch( + const file: ChangedFile = createChangedFileWithPatch( 'src/click.ts', 'diff --git a/src/click.ts b/src/click.ts\n@@ -1,2 +1,2 @@\n-old one\n-old two\n+new one\n+new two\n', ); + const reviewRange = { + base: { kind: 'index' as const, label: { kind: 'review-marker' as const, text: 'Index' } }, + head: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }; + file.sections[0]!.range = reviewRange; const container = document.createElement('div'); const shadowHost = document.createElement('div'); const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); @@ -2665,6 +2692,7 @@ test('line content clicks only ignore text selected on the clicked line', async expect(onCreateComment).toHaveBeenLastCalledWith({ filePath: 'src/click.ts', lineNumber: 1, + position: { range: reviewRange }, sectionId: 'src/click.ts:unstaged', side: 'additions', }); @@ -2731,6 +2759,7 @@ test('line content clicks only ignore text selected on the clicked line', async expect(onCreateComment).toHaveBeenLastCalledWith({ filePath: 'src/click.ts', lineNumber: 1, + position: { range: reviewRange }, sectionId: 'src/click.ts:unstaged', side: 'additions', }); diff --git a/core/__tests__/ReviewSurface-capabilities.test.tsx b/core/__tests__/ReviewSurface-capabilities.test.tsx index 8a42bb0f..ccd06f53 100644 --- a/core/__tests__/ReviewSurface-capabilities.test.tsx +++ b/core/__tests__/ReviewSurface-capabilities.test.tsx @@ -7,7 +7,11 @@ import { createRoot } from 'react-dom/client'; import { expect, expectTypeOf, test, vi } from 'vite-plus/test'; import { createDefaultConfig } from '../config/defaults.ts'; import { getShortcutLabel } from '../config/keymap.ts'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { + LocalReviewNote, + ProviderCommentDraft, + ProviderInlineComment, +} from '../lib/app-types.ts'; import { buildSharedReviewSnapshot, ReviewSurface, @@ -22,6 +26,7 @@ import type { NarrativeWalkthrough, RepositoryState, SharedWalkthroughSnapshot, + SubmittedReviewComment, } from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { waitFor } from './helpers/react.tsx'; @@ -228,6 +233,8 @@ test('rejects simultaneous controlled and uncontrolled mode inputs at the type b snapshot: SharedWalkthroughSnapshot; }; expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); }); test('exposes Comments through persisted-comment capabilities independent of source', async () => { @@ -331,10 +338,11 @@ test('copies local notes with the local label and Markdown heading', async () => body: 'Keep this local note.', filePath: file.path, id: 'local-note', + kind: 'local-note', lineNumber: 1, sectionId: file.sections[0]!.id, side: 'additions', - } satisfies ReviewComment; + } satisfies LocalReviewNote; const writeText = vi.fn(async () => {}); Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -369,10 +377,11 @@ test('copies provider drafts with the provider label and Markdown heading', asyn body: 'Submit this provider draft.', filePath: file.path, id: 'provider-draft', + kind: 'provider-draft', lineNumber: 1, sectionId: file.sections[0]!.id, side: 'additions', - } satisfies ReviewComment; + } satisfies ProviderCommentDraft; const writeText = vi.fn(async () => {}); Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -402,6 +411,48 @@ test('copies provider drafts with the provider label and Markdown heading', asyn await waitFor(() => expect(writeText).toHaveBeenCalledWith(markdown)); }); +test('excludes existing provider comments from Ask and pending-copy flows', async () => { + const file = providerSnapshot.files[0]!; + const onAsk = vi.fn(); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + authoring: { + drafts: { onChange: vi.fn(), value: [] }, + onAsk, + }, + }), + }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: { + ...providerSnapshot, + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'Existing provider feedback.', + filePath: file.path, + id: 'provider:existing', + lineNumber: 1, + side: 'additions', + }, + ], + }, + }); + + expect(view.container.textContent).toContain('Existing provider feedback.'); + expect(findButton(view.container, 'Ask')).toBeUndefined(); + const copyButton = view.container.querySelector('.copy-comments-button'); + expect(copyButton).not.toBeNull(); + expect(copyButton?.disabled).toBe(true); + expect(copyButton?.getAttribute('title')).toBe('Copy Pending Review Comments (0)'); + expect(bridge.current?.copyPendingComments()).toBe(''); + expect(onAsk).not.toHaveBeenCalled(); +}); + test('hides the persistent copy action while a desktop source switch is pending', async () => { const file = snapshot.files[0]!; await using view = await renderSurface({ @@ -415,6 +466,7 @@ test('hides the persistent copy action while a desktop source switch is pending' body: 'Keep this note through the source switch.', filePath: file.path, id: 'switching-note', + kind: 'local-note', lineNumber: 1, sectionId: file.sections[0]!.id, side: 'additions', @@ -668,32 +720,41 @@ test('composes host commands while keeping controlled preferences authoritative' test('forwards controlled draft updates atomically across an asynchronous submission', async () => { const file = snapshot.files[0]!; - const firstDraft: ReviewComment = { + const firstDraft: ProviderCommentDraft = { body: 'First draft', filePath: file.path, id: 'draft-1', + kind: 'provider-draft', lineNumber: 1, + position: { + range: { + base: { + label: { kind: 'commit', text: 'base' }, + sha: 'a'.repeat(40) as import('../types.ts').GitSha, + }, + head: { + label: { kind: 'commit', text: 'head' }, + sha: 'b'.repeat(40) as import('../types.ts').GitSha, + }, + }, + }, sectionId: file.sections[0]!.id, side: 'additions', }; - const secondDraft: ReviewComment = { + const secondDraft: ProviderCommentDraft = { ...firstDraft, body: 'Second draft', id: 'draft-2', }; - let completeSubmission!: ( - comment: import('../types.ts').PullRequestExistingReviewComment, - ) => void; - const submission = new Promise( - (resolve) => { - completeSubmission = resolve; - }, - ); - let setDrafts!: Dispatch>>; - let latestDrafts: ReadonlyArray = []; + let completeSubmission!: (comment: SubmittedReviewComment) => void; + const submission = new Promise((resolve) => { + completeSubmission = resolve; + }); + let setDrafts!: Dispatch>>; + let latestDrafts: ReadonlyArray = []; - function ControlledSurface() { - const [drafts, updateDrafts] = useState>([firstDraft]); + function ControlledSurface({ currentSnapshot }: { currentSnapshot: SharedWalkthroughSnapshot }) { + const [drafts, updateDrafts] = useState>([firstDraft]); useEffect(() => { setDrafts = updateDrafts; latestDrafts = drafts; @@ -710,7 +771,7 @@ test('forwards controlled draft updates atomically across an asynchronous submis }), }} initialMode="tree" - snapshot={providerSnapshot} + snapshot={currentSnapshot} /> ); } @@ -718,8 +779,10 @@ test('forwards controlled draft updates atomically across an asynchronous submis const container = document.createElement('div'); document.body.append(container); const root = createRoot(container); - await act(async () => root.render()); - const commentButton = findButton(container, 'Comment'); + await act(async () => root.render()); + const commentButton = Array.from( + container.querySelectorAll('button.review-comment-action'), + ).find((button) => button.textContent === 'Comment'); expect(commentButton).not.toBeUndefined(); await act(async () => commentButton?.click()); await act(async () => setDrafts((current) => [...current, secondDraft])); @@ -727,14 +790,32 @@ test('forwards controlled draft updates atomically across an asynchronous submis completeSubmission({ author: { login: 'ada', name: 'Ada' }, body: firstDraft.body, + destination: 'provider', filePath: firstDraft.filePath, id: 'submitted-1', + isReadOnly: true, lineNumber: firstDraft.lineNumber, - sectionId: firstDraft.sectionId, + position: firstDraft.position, side: firstDraft.side, }), ); - await waitFor(() => expect(latestDrafts.map(({ id }) => id)).toEqual(['submitted-1', 'draft-2'])); + await waitFor(() => expect(latestDrafts.map(({ id }) => id)).toEqual(['draft-2'])); + expect(container.textContent).toContain('First draft'); + + const nextSnapshot = { + ...providerSnapshot, + repository: { + root: '/another-repo', + source: { + ...providerSnapshot.repository.source, + number: 8, + url: 'https://github.com/cloudflare/codiff/pull/8', + }, + }, + } satisfies SharedWalkthroughSnapshot; + await act(async () => root.render()); + await waitFor(() => expect(container.textContent).not.toContain('First draft')); + await act(async () => root.unmount()); container.remove(); }); diff --git a/core/__tests__/ReviewSurface.test.tsx b/core/__tests__/ReviewSurface.test.tsx index 56411b46..7f7e43fa 100644 --- a/core/__tests__/ReviewSurface.test.tsx +++ b/core/__tests__/ReviewSurface.test.tsx @@ -58,9 +58,6 @@ const commenting = { onReplyGeneralComment: async () => {}, onResolveDiscussion: async () => {}, onSignIn: () => {}, - onSubmitComment: async () => { - throw new Error('Not used by this test.'); - }, onSubmitGeneralComment: async () => {}, onUpdateComment: async () => {}, onUpdateGeneralComment: async () => {}, @@ -336,7 +333,6 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy inline: { onDelete: commenting.onDeleteComment, onResolve: commenting.onResolveDiscussion, - onSubmit: commenting.onSubmitComment, onUpdate: commenting.onUpdateComment, }, onSignIn: commenting.onSignIn, diff --git a/core/__tests__/app-review-comment-hooks.test.tsx b/core/__tests__/app-review-comment-hooks.test.tsx index 43e734c0..d43f3704 100644 --- a/core/__tests__/app-review-comment-hooks.test.tsx +++ b/core/__tests__/app-review-comment-hooks.test.tsx @@ -5,7 +5,7 @@ import { act, useRef } from 'react'; import { afterEach, expect, test, vi } from 'vite-plus/test'; import { useAppReviewComments } from '../app/hooks/useAppReviewComments.ts'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { LocalReviewNote } from '../lib/app-types.ts'; import type { RepositoryState } from '../types.ts'; import { renderReact, waitFor } from './helpers/react.tsx'; @@ -20,10 +20,11 @@ const workingTreeState = { root: '/repo', source: { type: 'working-tree' }, } satisfies RepositoryState; -const comment: ReviewComment = { +const localNote: LocalReviewNote = { body: 'Review this', filePath: 'src/app.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 4, sectionId: 'src/app.ts:pull-request', side: 'additions', @@ -40,6 +41,7 @@ function AppReviewCommentsHarness({ }) { const stateRef = useRef(state); const comments = useAppReviewComments({ + draftKind: state.source.type === 'pull-request' ? 'provider-draft' : 'local-note', onCommentFileChange, stateRef, }); @@ -83,18 +85,18 @@ test('app review comments request and store assistant replies', async () => { const { getState, onCommentFileChange } = view; await act(async () => { - getState().setReviewComments([comment]); + getState().setReviewComments([localNote]); }); await act(async () => { - getState().askCodex(comment); + getState().askCodex(localNote); }); expect(askReviewAssistant).toHaveBeenCalledWith({ comment: { - body: comment.body, - filePath: comment.filePath, - lineNumber: comment.lineNumber, - sectionId: comment.sectionId, - side: comment.side, + body: localNote.body, + filePath: localNote.filePath, + lineNumber: localNote.lineNumber, + sectionId: localNote.sectionId, + side: localNote.side, }, source: workingTreeState.source, }); diff --git a/core/__tests__/git-state.test.ts b/core/__tests__/git-state.test.ts index 3e5ce470..6be6a3b9 100644 --- a/core/__tests__/git-state.test.ts +++ b/core/__tests__/git-state.test.ts @@ -36,10 +36,10 @@ type PullRequestFileContent = { }; type GeneratedFilesModule = { - readGeneratedAttributeStates: ( + readRevisionGeneratedAttributeStates: ( repoRoot: string, paths: ReadonlyArray, - source?: string, + revision: import('../types.ts').Revision, ) => Promise>; }; @@ -149,7 +149,7 @@ type GitStateModule = { const execFileAsync = promisify(execFile); const gitSha = (value: string) => value as GitSha; const require = createRequire(import.meta.url); -const { readGeneratedAttributeStates } = +const { readRevisionGeneratedAttributeStates } = require('../../electron/generated-files.cjs') as GeneratedFilesModule; const { collectResolvedReviewCommentIds, @@ -382,10 +382,13 @@ exec git "$@" CODIFF_TEST_ORIGINAL_PATH: process.env.PATH ?? '', PATH: `${wrapperDirectory.path}:${process.env.PATH ?? ''}`, }); - const generatedStates = await readGeneratedAttributeStates( + const generatedStates = await readRevisionGeneratedAttributeStates( repo, ['client.api.ts', 'pnpm-lock.yaml'], - commit, + { + label: { kind: 'commit', text: commit.slice(0, 7) }, + sha: gitSha(commit), + }, ); expect(generatedStates).toEqual( new Map([ @@ -396,6 +399,39 @@ exec git "$@" }); }); +test('working-tree ranges represent unborn staged additions with an absent base', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'first.txt', 'first\n'); + await git(repo, ['add', 'first.txt']); + + const state = await readWorkingTreeState(repo); + const file = state.files.find((candidate) => candidate.path === 'first.txt'); + const section = file?.sections.find((candidate) => candidate.kind === 'staged'); + + expect(section?.range).toMatchObject({ base: null, head: { kind: 'index' } }); + }); +}); + +test('working-tree ranges preserve the selected conflict index stage', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'conflict.txt', 'base\n'); + await commitAll(repo, 'base'); + const main = (await git(repo, ['branch', '--show-current'])).trim(); + await git(repo, ['checkout', '-b', 'other']); + await writeRepoFile(repo, 'conflict.txt', 'other\n'); + await commitAll(repo, 'other'); + await git(repo, ['checkout', main]); + await writeRepoFile(repo, 'conflict.txt', 'ours\n'); + await commitAll(repo, 'ours'); + await git(repo, ['merge', 'other']).catch(() => ''); + + const state = await readWorkingTreeState(repo); + const file = state.files.find((candidate) => candidate.path === 'conflict.txt'); + + expect(file?.sections[0]?.range?.base).toMatchObject({ kind: 'index', stage: 2 }); + }); +}); + test('parseStatus reads staged rename paths in porcelain v1 -z order', () => { expect(parseStatus('R new.txt\0old.txt\0')).toEqual([ { @@ -1202,6 +1238,7 @@ test('readWorkingTreeState separates staged and unstaged modifications', async ( await writeRepoFile(repo, 'file.txt', 'two\n'); await git(repo, ['add', 'file.txt']); await writeRepoFile(repo, 'file.txt', 'three\n'); + const head = (await git(repo, ['rev-parse', 'HEAD'])).trim(); const state = await readWorkingTreeState(repo); @@ -1214,9 +1251,64 @@ test('readWorkingTreeState separates staged and unstaged modifications', async ( expect(state.files[0].sections[0].newFile?.contents).toBe('two\n'); expect(state.files[0].sections[1].oldFile?.contents).toBe('two\n'); expect(state.files[0].sections[1].newFile?.contents).toBe('three\n'); + expect(state.files[0].sections[0].range).toMatchObject({ + base: { sha: head }, + head: { kind: 'index' }, + }); + expect(state.files[0].sections[1].range).toMatchObject({ + base: { kind: 'index' }, + head: { kind: 'working-copy' }, + }); }); }); +test.sequential('readWorkingTreeState resolves one HEAD for every staged section', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'alpha.txt', 'alpha initial\n'); + await writeRepoFile(repo, 'beta.txt', 'beta initial\n'); + await commitAll(repo, 'initial commit'); + const head = (await git(repo, ['rev-parse', 'HEAD'])).trim(); + + await writeRepoFile(repo, 'alpha.txt', 'alpha staged\n'); + await writeRepoFile(repo, 'beta.txt', 'beta staged\n'); + await git(repo, ['add', 'alpha.txt', 'beta.txt']); + await writeRepoFile(repo, 'alpha.txt', 'alpha unstaged\n'); + await writeRepoFile(repo, 'beta.txt', 'beta unstaged\n'); + + const tracePath = join(repo, '.git', 'working-tree-head-trace.jsonl'); + let state: RepositoryState; + { + using _environment = createTemporaryEnvironment({ GIT_TRACE2_EVENT: tracePath }); + state = await readWorkingTreeState(repo); + } + + const headResolutionCommands = readFileSync(tracePath, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as { argv?: ReadonlyArray; event?: string }) + .flatMap(({ argv, event }) => { + if (event !== 'start' || !argv || argv.at(-1) !== 'HEAD^{commit}') { + return []; + } + const revParseIndex = argv.indexOf('rev-parse'); + return revParseIndex >= 0 ? [argv.slice(revParseIndex)] : []; + }); + expect(headResolutionCommands).toEqual([['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']]); + + const stagedSections = state.files.flatMap((file) => + file.sections.filter((section) => section.kind === 'staged'), + ); + expect(stagedSections).toHaveLength(2); + expect( + stagedSections.map((section) => { + const base = section.range?.base; + return base && 'sha' in base ? base.sha : undefined; + }), + ).toEqual([head, head]); + }); +}, 15_000); + test('readRepositoryState uses hidden-whitespace patches for patch-only working tree files', async () => { await withRepo(async (repo) => { await writeRepoFile(repo, 'src/code.ts', 'const value = 1;\n'); @@ -1971,6 +2063,7 @@ test('readRepositoryState reads commit diffs from short hashes', async () => { await writeRepoFile(repo, 'new.txt', 'created\n'); await commitAll(repo, 'second commit'); const commit = (await git(repo, ['rev-parse', 'HEAD'])).trim(); + const parent = (await git(repo, ['rev-parse', 'HEAD^'])).trim(); const shortCommit = commit.slice(0, 8); const state = await readRepositoryState(repo, { @@ -1987,6 +2080,10 @@ test('readRepositoryState reads commit diffs from short hashes', async () => { expect(state.files.find((file) => file.path === 'file.txt')?.sections[0].patch).toContain( '+two', ); + expect(state.files.find((file) => file.path === 'file.txt')?.sections[0].range).toMatchObject({ + base: { sha: parent }, + head: { sha: commit }, + }); }); }); diff --git a/core/__tests__/review-comment-hooks.test.tsx b/core/__tests__/review-comment-hooks.test.tsx index 31b37178..096987fb 100644 --- a/core/__tests__/review-comment-hooks.test.tsx +++ b/core/__tests__/review-comment-hooks.test.tsx @@ -5,17 +5,18 @@ import { act, useState } from 'react'; import { expect, test, vi } from 'vite-plus/test'; import { useReviewCommentDrafts } from '../app/hooks/useReviewCommentDrafts.ts'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { LocalReviewNote, ReviewComment } from '../lib/app-types.ts'; import { renderReact } from './helpers/react.tsx'; type ReviewCommentDrafts = ReturnType & { comments: ReadonlyArray; }; -const createComment = (id: string, overrides: Partial = {}): ReviewComment => ({ +const createComment = (id: string, overrides: Partial = {}): LocalReviewNote => ({ body: '', filePath: 'src/app.ts', id, + kind: 'local-note', lineNumber: 1, sectionId: 'src/app.ts:unstaged', side: 'additions', @@ -37,6 +38,7 @@ function ReviewCommentDraftsHarness({ const state = useReviewCommentDrafts({ canCreateComment, comments, + draftKind: 'local-note', onCommentFileChange, setComments, }); @@ -86,7 +88,7 @@ test('review comment drafts create, update, focus, and delete local comments', a }, }; const comment = createComment('ignored'); - const { body: _body, id: _id, ...location } = comment; + const { body: _body, id: _id, kind: _kind, ...location } = comment; await act(async () => { getState().createComment(location); }); @@ -95,6 +97,7 @@ test('review comment drafts create, update, focus, and delete local comments', a ...location, body: '', id: '00000000-0000-4000-8000-000000000001', + kind: 'local-note', }, ]); expect(getState().focusCommentId).toBe('00000000-0000-4000-8000-000000000001'); @@ -127,7 +130,7 @@ test('review comment drafts focus an existing empty comment at the same location }); const { getState, onCommentFileChange } = view; - const { body: _body, id: _id, ...location } = existing; + const { body: _body, id: _id, kind: _kind, ...location } = existing; await act(async () => { getState().createComment(location); }); @@ -166,7 +169,7 @@ test('review comment drafts preserve active text when reusing another empty draf filePath: 'src/new.ts', sectionId: 'src/new.ts:unstaged', }); - const { body: _body, id: _id, ...location } = next; + const { body: _body, id: _id, kind: _kind, ...location } = next; await act(async () => { getState().createComment(location); }); @@ -175,6 +178,7 @@ test('review comment drafts preserve active text when reusing another empty draf ...location, body: '', id: '00000000-0000-4000-8000-000000000002', + kind: 'local-note', }); expect(onCommentFileChange).toHaveBeenNthCalledWith(1, 'src/old.ts'); expect(onCommentFileChange).toHaveBeenNthCalledWith(2, 'src/new.ts'); @@ -187,7 +191,7 @@ test('review comment drafts can disable comment creation', async () => { const { getState, onCommentFileChange } = view; const comment = createComment('ignored'); - const { body: _body, id: _id, ...location } = comment; + const { body: _body, id: _id, kind: _kind, ...location } = comment; await act(async () => { getState().createComment(location); }); diff --git a/core/__tests__/review-comments.test.ts b/core/__tests__/review-comments.test.ts index dcdfb5af..1c1f74cd 100644 --- a/core/__tests__/review-comments.test.ts +++ b/core/__tests__/review-comments.test.ts @@ -1,26 +1,72 @@ import { expect, test } from 'vite-plus/test'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { + ProviderCommentDraft, + ProviderInlineComment, + ShareCommentDraft, +} from '../lib/app-types.ts'; import { findReusableReviewCommentDraft, getPendingPullRequestReviewComments, getReviewCommentsFromState, getVisibleReviewComments, mergeReviewComments, - toSubmittedReviewComment, - toPullRequestReviewComment, + toProviderCommentSubmission, + toProviderSubmittedReviewComment, + toPullRequestExistingReviewComment, + toRenderedSubmittedReviewComment, + toShareCommentSubmission, } from '../lib/review-comments.ts'; -import type { RepositoryState } from '../types.ts'; +import type { GitSha, RepositoryState } from '../types.ts'; -const createReviewComment = (overrides: Partial): ReviewComment => ({ +const providerPosition = { + range: { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: 'b'.repeat(40) as GitSha }, + }, +}; + +const createProviderDraft = ( + overrides: Partial = {}, +): ProviderCommentDraft => ({ body: 'A comment.', filePath: 'src/a.ts', id: 'github:1', + kind: 'provider-draft', lineNumber: 5, + position: providerPosition, sectionId: 'src/a.ts:pull-request:1', side: 'additions', ...overrides, }); +const createShareDraft = (overrides: Partial = {}): ShareCommentDraft => ({ + body: 'A shared comment.', + filePath: 'src/a.ts', + id: 'share:1', + kind: 'share-draft', + lineNumber: 5, + sectionId: 'src/a.ts:unstaged', + side: 'additions', + ...overrides, +}); + +const createProviderComment = ( + overrides: Partial = {}, +): ProviderInlineComment => ({ + author: { login: 'reviewer' }, + body: 'A provider comment.', + destination: 'provider', + filePath: 'src/a.ts', + id: 'github:remote', + isReadOnly: true, + kind: 'submitted-comment', + lineNumber: 5, + position: providerPosition, + resolvedSectionId: 'src/a.ts:pull-request:1', + side: 'additions', + ...overrides, +}); + const createPullRequestState = (): RepositoryState => ({ branch: null, files: [ @@ -70,11 +116,57 @@ test('getReviewCommentsFromState carries the outdated flag through to review com const comments = getReviewCommentsFromState(createPullRequestState()); expect(comments).toHaveLength(2); + expect( + comments.every( + (comment) => comment.kind === 'submitted-comment' && comment.destination === 'provider', + ), + ).toBe(true); expect(comments.find((comment) => comment.id === 'github:1')?.isOutdated).toBe(true); expect(comments.find((comment) => comment.id === 'github:2')?.isOutdated).toBeUndefined(); }); -test('getReviewCommentsFromState hydrates shared comments on their exact working-tree section', () => { +test('retains unresolved provider comments with their original persisted coordinates', () => { + const state = createPullRequestState(); + state.reviewComments = [ + { + author: { login: 'reviewer' }, + body: 'The original line is no longer present.', + canDelete: true, + canEdit: true, + filePath: 'src/a.ts', + id: 'github:unresolved', + isOutdated: true, + lineNumber: 999, + position: providerPosition, + side: 'additions', + submittedAt: '2026-08-01T00:00:00.000Z', + threadId: 'thread-unresolved', + url: 'https://github.com/example/repo/pull/1#discussion_r1', + }, + ]; + + const comments = getReviewCommentsFromState(state); + expect(comments).toEqual([ + expect.objectContaining({ + body: 'The original line is no longer present.', + canDelete: true, + canEdit: true, + destination: 'provider', + filePath: 'src/a.ts', + id: 'github:unresolved', + isOutdated: true, + lineNumber: 999, + position: providerPosition, + threadId: 'thread-unresolved', + url: 'https://github.com/example/repo/pull/1#discussion_r1', + }), + ]); + expect(comments[0]).not.toHaveProperty('resolvedSectionId'); + expect(getVisibleReviewComments(comments, false)).toEqual([]); + expect(getVisibleReviewComments(comments, true)).toEqual(comments); +}); + +test('getReviewCommentsFromState derives a working-tree section from line coordinates', () => { const state = createPullRequestState(); state.source = { type: 'working-tree' }; state.files = [ @@ -85,13 +177,15 @@ test('getReviewCommentsFromState hydrates shared comments on their exact working binary: false, id: 'src/a.ts:staged', kind: 'staged', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new', }, { binary: false, id: 'src/a.ts:unstaged', kind: 'unstaged', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -5 +5 @@\n-old\n+new', }, ], }, @@ -108,20 +202,90 @@ test('getReviewCommentsFromState hydrates shared comments on their exact working }, ]; - expect(getReviewCommentsFromState(state)).toEqual([ + expect(getReviewCommentsFromState(state, 'share')).toEqual([ expect.objectContaining({ body: 'Persist this shared walkthrough comment.', + destination: 'share', id: 'shared:1', isReadOnly: true, + resolvedSectionId: 'src/a.ts:unstaged', sectionId: 'src/a.ts:unstaged', }), ]); }); +test('getReviewCommentsFromState prefers a persisted range over matching line coordinates', () => { + const state = createPullRequestState(); + state.source = { type: 'working-tree' }; + const head = { + label: { kind: 'commit' as const, text: 'current-head' }, + sha: 'a'.repeat(40) as GitSha, + }; + const index = { + kind: 'index' as const, + label: { kind: 'review-marker' as const, text: 'Index' }, + }; + const workingCopy = { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }; + state.files = [ + { + ...state.files[0]!, + sections: [ + { + binary: false, + id: 'src/a.ts:staged', + kind: 'staged', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -5 +5 @@\n-old\n+staged', + range: { base: head, head: index }, + }, + { + binary: false, + id: 'src/a.ts:unstaged', + kind: 'unstaged', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -5 +5 @@\n-staged\n+unstaged', + range: { base: index, head: workingCopy }, + }, + ], + }, + ]; + const persistedIndex = { + ...index, + label: { kind: 'review-marker' as const, text: 'Persisted index label' }, + }; + const persistedWorkingCopy = { + ...workingCopy, + label: { kind: 'review-marker' as const, text: 'Persisted working copy label' }, + }; + state.reviewComments = [ + { + author: { login: 'reviewer' }, + body: 'Keep this unstaged.', + filePath: 'src/a.ts', + id: 'shared:range', + lineNumber: 5, + position: { range: { base: persistedIndex, head: persistedWorkingCopy } }, + side: 'additions', + }, + ]; + + expect(getReviewCommentsFromState(state, 'share')).toEqual([ + expect.objectContaining({ + destination: 'share', + id: 'shared:range', + position: { range: { base: persistedIndex, head: persistedWorkingCopy } }, + resolvedSectionId: 'src/a.ts:unstaged', + }), + ]); +}); + test('getPendingPullRequestReviewComments includes an unflushed active draft', () => { const comments = [ - createReviewComment({ body: '', id: 'draft' }), - createReviewComment({ body: 'Already flushed.', id: 'ready', lineNumber: 6 }), + createProviderDraft({ body: '', id: 'draft' }), + createProviderDraft({ body: 'Already flushed.', id: 'ready', lineNumber: 6 }), ]; expect( @@ -136,7 +300,7 @@ test('getPendingPullRequestReviewComments includes an unflushed active draft', ( }); test('getPendingPullRequestReviewComments replaces a stale flushed draft', () => { - const comments = [createReviewComment({ body: 'Old text.', id: 'draft' })]; + const comments = [createProviderDraft({ body: 'Old text.', id: 'draft' })]; expect( getPendingPullRequestReviewComments(comments, { @@ -147,7 +311,7 @@ test('getPendingPullRequestReviewComments replaces a stale flushed draft', () => }); test('getPendingPullRequestReviewComments respects an emptied active draft', () => { - const comments = [createReviewComment({ body: 'Old text.', id: 'draft' })]; + const comments = [createProviderDraft({ body: 'Old text.', id: 'draft' })]; expect( getPendingPullRequestReviewComments(comments, { @@ -167,7 +331,7 @@ test('getPendingPullRequestReviewComments ignores drafts outside the current rev }); test('getPendingPullRequestReviewComments excludes comments being submitted individually', () => { - const comment = createReviewComment({ body: 'Already submitting.', id: 'draft' }); + const comment = createProviderDraft({ body: 'Already submitting.', id: 'draft' }); expect( getPendingPullRequestReviewComments([{ ...comment, remoteSubmit: { status: 'submitting' } }]), @@ -175,8 +339,8 @@ test('getPendingPullRequestReviewComments excludes comments being submitted indi }); test('findReusableReviewCommentDraft preserves an active draft with unflushed content', () => { - const activeDraft = createReviewComment({ body: '', id: 'active' }); - const reusableDraft = createReviewComment({ body: '', id: 'reusable', lineNumber: 6 }); + const activeDraft = createProviderDraft({ body: '', id: 'active' }); + const reusableDraft = createProviderDraft({ body: '', id: 'reusable', lineNumber: 6 }); expect( findReusableReviewCommentDraft([activeDraft, reusableDraft], { @@ -187,7 +351,7 @@ test('findReusableReviewCommentDraft preserves an active draft with unflushed co }); test('findReusableReviewCommentDraft returns no draft when the only empty draft has content', () => { - const activeDraft = createReviewComment({ body: '', id: 'active' }); + const activeDraft = createProviderDraft({ body: '', id: 'active' }); expect( findReusableReviewCommentDraft([activeDraft], { @@ -197,12 +361,11 @@ test('findReusableReviewCommentDraft returns no draft when the only empty draft ).toBeUndefined(); }); -test('findReusableReviewCommentDraft skips read-only drafts and reuses whitespace-only drafts', () => { - const readOnlyDraft = createReviewComment({ body: '', id: 'readonly', isReadOnly: true }); - const activeDraft = createReviewComment({ body: '', id: 'active' }); +test('findReusableReviewCommentDraft reuses whitespace-only provider drafts', () => { + const activeDraft = createProviderDraft({ body: '', id: 'active' }); expect( - findReusableReviewCommentDraft([readOnlyDraft, activeDraft], { + findReusableReviewCommentDraft([activeDraft], { body: ' ', id: activeDraft.id, }), @@ -264,15 +427,15 @@ test('getReviewCommentsFromState preserves file-level GitLab anchors', () => { filePath: 'src/a.ts', id: 'gitlab:file', isReadOnly: true, - sectionId: 'src/a.ts:pull-request:1', + resolvedSectionId: 'src/a.ts:pull-request:1', }), ]); }); test('getVisibleReviewComments hides outdated comments unless they are shown', () => { const comments = [ - createReviewComment({ id: 'github:1', isOutdated: true }), - createReviewComment({ id: 'github:2' }), + createProviderComment({ id: 'github:1', isOutdated: true }), + createProviderComment({ id: 'github:2' }), ]; expect(getVisibleReviewComments(comments, false).map((comment) => comment.id)).toEqual([ @@ -285,63 +448,173 @@ test('getVisibleReviewComments hides outdated comments unless they are shown', ( }); test('getVisibleReviewComments keeps user-authored comments that are never outdated', () => { - const comments = [createReviewComment({ id: 'draft', isReadOnly: false })]; + const comments = [createProviderDraft({ id: 'draft' })]; expect(getVisibleReviewComments(comments, false)).toHaveLength(1); }); -test('serializes file-level thread replies without inventing line metadata', () => { - expect( - toPullRequestReviewComment( - createReviewComment({ - anchor: 'file', - body: 'Reply in the existing discussion.', - lineNumber: undefined, - side: undefined, - threadId: 'discussion-1', - }), - ), - ).toEqual({ +test('serializes provider replies without requiring position metadata', () => { + const submission = toProviderCommentSubmission( + createProviderDraft({ + anchor: 'file', + body: 'Reply in the existing discussion.', + lineNumber: undefined, + position: undefined, + side: undefined, + threadId: 'discussion-1', + }), + ); + + expect(submission).toEqual({ anchor: 'file', body: 'Reply in the existing discussion.', filePath: 'src/a.ts', threadId: 'discussion-1', }); + expect( + toProviderSubmittedReviewComment( + { + author: { login: 'reviewer' }, + body: submission.body, + filePath: submission.filePath, + id: 'github:reply', + threadId: submission.threadId, + }, + submission, + ), + ).not.toHaveProperty('position'); }); -test('serializes section identity only for shared walkthrough comments', () => { - const comment = createReviewComment({ body: 'Persist this comment.' }); +test('omits UI-only section identity from provider review comment payloads', () => { + const comment = createProviderDraft({ body: 'Persist this comment.' }); - expect(toPullRequestReviewComment(comment)).not.toHaveProperty('sectionId'); - expect(toPullRequestReviewComment(comment, { includeSectionId: true })).toMatchObject({ - sectionId: 'src/a.ts:pull-request:1', + expect(toProviderCommentSubmission(comment)).not.toHaveProperty('sectionId'); + expect(toProviderCommentSubmission(comment).position).toEqual(providerPosition); +}); + +test('rejects provider submissions that contain pseudo-revisions', () => { + const comment = createProviderDraft(); + const invalid = { + ...comment, + position: { + range: { + base: { kind: 'index' as const, label: { kind: 'review-marker' as const, text: 'Index' } }, + head: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }, + }, + } as unknown as ProviderCommentDraft; + + expect(() => toProviderCommentSubmission(invalid)).toThrow( + 'Provider comments require an exact immutable commit position.', + ); +}); + +test('uses durable positions and preserves section IDs only for legacy shared comments', () => { + const legacy = createShareDraft({ sectionId: 'src/a.ts:unstaged' }); + expect(toShareCommentSubmission(legacy)).toMatchObject({ + sectionId: 'src/a.ts:unstaged', }); + + const positioned = { + ...legacy, + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'a' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'b' }, sha: 'b'.repeat(40) as GitSha }, + }, + }, + }; + expect(toShareCommentSubmission(positioned)).toMatchObject({ + position: positioned.position, + }); + expect(toShareCommentSubmission(positioned)).not.toHaveProperty('sectionId'); +}); + +test('accepts index and working-copy revisions in shared comment positions', () => { + const position = { + range: { + base: { kind: 'index' as const, label: { kind: 'review-marker' as const, text: 'Index' } }, + head: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }, + }; + + expect(toShareCommentSubmission(createShareDraft({ position }))).toMatchObject({ position }); }); -test('keeps a submitted shared comment visible until the matching snapshot comment arrives', () => { - const draft = createReviewComment({ +test('converts submitted drafts into provider comments with durable positions', () => { + const position = { + range: { + base: { label: { kind: 'commit' as const, text: 'a' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'b' }, sha: 'b'.repeat(40) as GitSha }, + }, + }; + const draft = createProviderDraft({ id: 'draft-comment', + position, remoteSubmit: { status: 'submitting' }, }); - const submitted = toSubmittedReviewComment( - { - author: { login: 'ada', name: 'Ada Lovelace' }, - body: draft.body, - canDelete: true, - canEdit: true, - filePath: draft.filePath, - id: 'persisted-comment', - lineNumber: draft.lineNumber, - sectionId: draft.sectionId, - side: draft.side, - submittedAt: '2026-07-16T12:00:00.000Z', - threadId: 'persisted-thread', - }, + const submission = toProviderCommentSubmission(draft); + const submitted = toRenderedSubmittedReviewComment( + toProviderSubmittedReviewComment( + { + author: { login: 'ada', name: 'Ada Lovelace' }, + body: draft.body, + canDelete: true, + canEdit: true, + filePath: draft.filePath, + id: 'persisted-comment', + lineNumber: draft.lineNumber, + side: draft.side, + submittedAt: '2026-07-16T12:00:00.000Z', + threadId: 'persisted-thread', + }, + submission, + ), draft, ); - expect(mergeReviewComments([], [submitted])).toEqual([submitted]); + expect(submitted.position).toEqual(position); + expect(submitted.destination).toBe('provider'); + expect(submitted.resolvedSectionId).toBe(draft.sectionId); + expect(mergeReviewComments([submitted], [])).toEqual([submitted]); const snapshotComment = { ...submitted, body: 'Canonical server comment.' }; - expect(mergeReviewComments([snapshotComment], [submitted])).toEqual([snapshotComment]); + expect(mergeReviewComments([snapshotComment], [])).toEqual([snapshotComment]); +}); + +test('round trips provider and share provenance independently', () => { + const providerComment = createProviderComment(); + const providerState = createPullRequestState(); + providerState.reviewComments = [toPullRequestExistingReviewComment(providerComment)]; + expect(getReviewCommentsFromState(providerState)[0]).toMatchObject({ + destination: 'provider', + position: providerPosition, + }); + expect(getReviewCommentsFromState(providerState)[0]).not.toHaveProperty('sectionId'); + + const shareComment = { + author: { login: 'ada' }, + body: 'Legacy shared feedback.', + destination: 'share' as const, + filePath: 'src/a.ts', + id: 'share:submitted', + isReadOnly: true as const, + kind: 'submitted-comment' as const, + lineNumber: 5, + resolvedSectionId: 'src/a.ts:pull-request:1', + sectionId: 'src/a.ts:pull-request:1', + side: 'additions' as const, + }; + const shareState = createPullRequestState(); + shareState.reviewComments = [toPullRequestExistingReviewComment(shareComment)]; + expect(getReviewCommentsFromState(shareState, 'share')[0]).toMatchObject({ + destination: 'share', + sectionId: 'src/a.ts:pull-request:1', + }); }); diff --git a/core/app/RepositoryReviewHost.tsx b/core/app/RepositoryReviewHost.tsx index f1fa9ecd..f9476ac2 100644 --- a/core/app/RepositoryReviewHost.tsx +++ b/core/app/RepositoryReviewHost.tsx @@ -18,7 +18,13 @@ import { import { reconcileRepositoryRefresh } from '../lib/repository-refresh.ts'; import type { RepositoryReviewBootstrap } from '../lib/repository-review-bootstrap.ts'; import { resolveReviewCommandTarget } from '../lib/review-command-target.ts'; -import { getReviewCommentsFromState, mergeReviewComments } from '../lib/review-comments.ts'; +import { + getReviewCommentsFromState, + isReviewDraft, + mergeReviewComments, + toProviderSubmittedReviewComment, + toPullRequestExistingReviewComment, +} from '../lib/review-comments.ts'; import { getFileReviewIdentity } from '../lib/review-identity.ts'; import { getHistorySource, @@ -177,11 +183,7 @@ const getCollapsedViewedPaths = ( const mergeStateReviewComments = ( state: RepositoryState, currentComments: ReadonlyArray, -) => - mergeReviewComments( - getReviewCommentsFromState(state), - currentComments.filter((comment) => !comment.isReadOnly), - ); +) => mergeReviewComments(getReviewCommentsFromState(state), currentComments.filter(isReviewDraft)); export type RepositoryReviewHostProps = { bootstrap: RepositoryReviewBootstrap; @@ -332,12 +334,23 @@ export function RepositoryReviewHost({ window.codiff.reportInitialLoadMilestone?.('first-usable-review-rendered'); }, [state]); - const { askCodex, resetCommentFocus, reviewComments, reviewCommentsRef, setReviewComments } = - useAppReviewComments({ - initialReviewComments: getReviewCommentsFromState(initialState), - onCommentFileChange: bumpItemVersion, - stateRef, - }); + const { + askCodex, + localReviewNotes, + providerDrafts, + providerInlineComments, + resetCommentFocus, + reviewComments, + reviewCommentsRef, + setLocalReviewNotes, + setProviderDrafts, + setReviewComments, + } = useAppReviewComments({ + draftKind: state?.source.type === 'pull-request' ? 'provider-draft' : 'local-note', + initialReviewComments: getReviewCommentsFromState(initialState), + onCommentFileChange: bumpItemVersion, + stateRef, + }); const hydrateReviewComments = useCallback( (requestedState: RepositoryState | null = stateRef.current) => { @@ -1178,7 +1191,7 @@ export function RepositoryReviewHost({ const refreshHistorySource = historySourceRef.current ? getRefreshSource(historySourceRef.current) : undefined; - const pendingReviewComments = reviewComments.filter((comment) => !comment.isReadOnly); + const pendingReviewComments = reviewComments.filter(isReviewDraft); Promise.all([ window.codiff.getRepositoryState(refreshSource), @@ -1492,13 +1505,20 @@ export function RepositoryReviewHost({ ? 'failed' : 'idle'; const walkthroughAgent = launchOptions.agentBackend ?? config.settings.agentBackend; - const snapshot = buildSharedReviewSnapshot({ - preferences, - state, - title, - walkthrough: - narrativeWalkthrough ?? createPlaceholderWalkthrough(state, title, walkthroughAgent), - }); + const snapshot = { + ...buildSharedReviewSnapshot({ + preferences, + state, + title, + walkthrough: + narrativeWalkthrough ?? createPlaceholderWalkthrough(state, title, walkthroughAgent), + }), + ...(source.type === 'pull-request' + ? { + reviewComments: providerInlineComments.map(toPullRequestExistingReviewComment), + } + : {}), + }; const branchSource = historySource?.type === 'branch-diff' ? historySource @@ -1525,13 +1545,19 @@ export function RepositoryReviewHost({ }, destination: 'provider', inline: { - onSubmit: (comment) => - window.codiff.submitPullRequestComment({ comment, source: providerSource }), + onSubmit: async (comment) => + toProviderSubmittedReviewComment( + await window.codiff.submitPullRequestComment({ + comment, + source: providerSource, + }), + comment, + ), }, reviewSession: { drafts: { - onChange: setReviewComments, - value: reviewComments, + onChange: setProviderDrafts, + value: providerDrafts, }, submit: ({ comments, outcome, summary }) => window.codiff.submitPullRequestReview({ @@ -1549,8 +1575,8 @@ export function RepositoryReviewHost({ localReviewNotes: { canCreateInline: true, drafts: { - onChange: setReviewComments, - value: reviewComments, + onChange: setLocalReviewNotes, + value: localReviewNotes, }, onAsk: askCodex, }, diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index 3783c7d2..2089e2b0 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -53,6 +53,7 @@ import type { ReviewAnnotationMetadata, ReviewComment, ReviewCommentAnnotationMetadata, + ReviewCommentCreation, ReviewIdentity, ReviewScrollBehavior, ReviewScrollTarget, @@ -93,11 +94,16 @@ import { isGeneratedWalkthroughFile } from '../../lib/narrative-walkthrough-diff import { getCommentKey, getReviewCommentLineLabel, + getReviewCommentRendererSectionId, getReviewCommentsDigest, hasActiveTextSelection, isFileReviewComment, isInteractiveReviewEvent, isLineReviewComment, + isProviderCommentDraft, + isReviewDraft, + isShareCommentDraft, + isSubmittedReviewComment, shouldDiscardReviewCommentOnEscape, updateStickyHeaderState, } from '../../lib/review-comments.ts'; @@ -398,10 +404,12 @@ const agentIconUrl = (agentId: 'codex' | 'claude' | 'opencode' | 'pi') => { }; const canAskCodexForComment = (comment: ReviewComment) => - !comment.isReadOnly && comment.body.trim().length > 0 && comment.codexReply?.status !== 'loading'; + isReviewDraft(comment) && + comment.body.trim().length > 0 && + comment.codexReply?.status !== 'loading'; const canSubmitComment = (comment: ReviewComment) => - !comment.isReadOnly && + (isProviderCommentDraft(comment) || isShareCommentDraft(comment)) && comment.body.trim().length > 0 && comment.remoteSubmit?.status !== 'submitting'; @@ -1417,7 +1425,7 @@ function ReviewCommentEditor({ draft, }, ); - if (!comment.isReadOnly && draft !== comment.body) { + if (isReviewDraft(comment) && draft !== comment.body) { onUpdateComment(comment.id, draft); } return withCommentBody(comment, draft); @@ -1624,7 +1632,8 @@ function ReviewCommentEditor({
) : null} - {!comment.isReadOnly && onAskCodex ? ( + {isReviewDraft(comment) && onAskCodex ? ( ) : null} {isReviewDraft(comment) ? ( @@ -1819,7 +1824,8 @@ function ReviewCommentEditor({ /> )} - {comment.remoteSubmit?.status === 'error' ? ( + {comment.remoteSubmit?.status === 'error' || + comment.remoteSubmit?.status === 'outcome-unknown' ? (
{comment.remoteSubmit.error}
) : null}
@@ -3279,7 +3285,12 @@ export function ReviewCodeView({ }, []); const canCreateFileComments = - !isReadOnly && source.type === 'pull-request' && source.provider === 'gitlab'; + !isReadOnly && + source.type === 'pull-request' && + (source.provider === 'gitlab' || + source.provider === 'github' || + source.host === 'github.com' || + (!source.provider && !source.host)); const createFileComment = useCallback( (meta: CodeViewItemMetadata, itemId: string) => { diff --git a/core/global.d.ts b/core/global.d.ts index a235e7a6..488e60cc 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -35,6 +35,7 @@ import type { SubmitPullRequestCommentRequest, PullRequestExistingReviewComment, SubmitPullRequestReviewRequest, + SubmitPullRequestReviewResult, TerminalHelperStatus, WalkthroughCommitMessageRequest, WalkthroughCommitMessageResult, @@ -134,7 +135,9 @@ declare global { submitPullRequestComment: ( request: SubmitPullRequestCommentRequest, ) => Promise; - submitPullRequestReview: (request: SubmitPullRequestReviewRequest) => Promise; + submitPullRequestReview: ( + request: SubmitPullRequestReviewRequest, + ) => Promise; updateWalkthroughCommitMessage: ( request: WalkthroughCommitMessageRequest, ) => Promise; diff --git a/core/index.ts b/core/index.ts index 85ef7fb4..7788bce7 100644 --- a/core/index.ts +++ b/core/index.ts @@ -1,4 +1,11 @@ export { defaultReviewPreferences } from './defaults.ts'; +export { + diffRangesMatch, + resolveProviderCommentTarget, + resolveShareCommentTarget, + type ProviderCommentTargetResolution, + type ShareCommentTargetResolution, +} from './lib/review-comment-target.ts'; export { diffRange, isCommitRevision, shaForRevision } from './lib/review-history.ts'; export { orderReviewCommitStack, @@ -87,6 +94,7 @@ export type { SharedPlanSnapshot, SharedWalkthroughSnapshot, SubmittedReviewComment, + SubmitPullRequestReviewResult, WalkthroughShareManifestV1, WalkthroughHunk, WalkthroughGenerationProgress, diff --git a/core/lib/app-types.ts b/core/lib/app-types.ts index 31dbe693..e67e9794 100644 --- a/core/lib/app-types.ts +++ b/core/lib/app-types.ts @@ -146,7 +146,7 @@ export type ProviderCommentDraft = EditableReviewCommentDraft & { position?: ProviderReviewCommentPosition; remoteSubmit?: { error?: string; - status: 'error' | 'submitting'; + status: 'error' | 'outcome-unknown' | 'submitting'; }; threadId?: string; }; diff --git a/core/lib/review-comment-target.ts b/core/lib/review-comment-target.ts new file mode 100644 index 00000000..ac8b2ffc --- /dev/null +++ b/core/lib/review-comment-target.ts @@ -0,0 +1,207 @@ +import type { + ChangedFile, + DiffRange, + DiffSection, + ProviderReviewCommentPosition, + ReviewCommentPosition, + Revision, +} from '../types.ts'; +import { parseSectionDiffWithOptions } from './diff.ts'; +import { isCommitRevision } from './review-history.ts'; + +type ReviewCommentTargetFailure = { + reason: + | 'anchor-not-in-target' + | 'ambiguous-target-range' + | 'file-not-in-target' + | 'missing-target-range' + | 'non-commit-target' + | 'section-not-in-target'; + status: 'read-only'; +}; + +export type ShareCommentTargetResolution = + | ReviewCommentTargetFailure + | { + position: ReviewCommentPosition; + sectionId?: never; + status: 'enabled'; + } + | { + position?: never; + sectionId: string; + status: 'enabled'; + }; + +export type ProviderCommentTargetResolution = + | ReviewCommentTargetFailure + | { + position: ProviderReviewCommentPosition; + status: 'enabled'; + }; + +type ReviewCommentTargetInput = { + anchor?: 'file' | 'line'; + file: ChangedFile; + lineNumber?: number; + section?: DiffSection; + showWhitespace: boolean; + side?: 'additions' | 'deletions'; + startLineNumber?: number; + startSide?: 'additions' | 'deletions'; +}; + +const revisionKind = (revision: Revision) => revision.kind ?? 'commit'; + +const revisionsMatch = (left: Revision | null, right: Revision | null) => { + if (!left || !right) { + return left === right; + } + const leftKind = revisionKind(left); + const rightKind = revisionKind(right); + return ( + leftKind === rightKind && + (leftKind !== 'commit' || ('sha' in left && 'sha' in right && left.sha === right.sha)) + ); +}; + +export const diffRangesMatch = (left: DiffRange | undefined, right: DiffRange | undefined) => + left != null && + right != null && + revisionsMatch(left.base, right.base) && + revisionsMatch(left.head, right.head); + +const lineExistsInSection = ( + file: ChangedFile, + section: DiffSection, + lineNumber: number, + side: 'additions' | 'deletions', + showWhitespace: boolean, +) => { + const parsed = parseSectionDiffWithOptions(file, section, showWhitespace); + return parsed.hunks.some((hunk) => { + let oldLine = hunk.deletionStart; + let newLine = hunk.additionStart; + for (const content of hunk.hunkContent) { + if (content.type === 'context') { + const start = side === 'additions' ? newLine : oldLine; + if (lineNumber >= start && lineNumber < start + content.lines) { + return true; + } + oldLine += content.lines; + newLine += content.lines; + continue; + } + const start = side === 'additions' ? newLine : oldLine; + const length = side === 'additions' ? content.additions : content.deletions; + if (lineNumber >= start && lineNumber < start + length) { + return true; + } + oldLine += content.deletions; + newLine += content.additions; + } + return false; + }); +}; + +const targetContainsAnchor = ({ + anchor, + file, + lineNumber, + section, + showWhitespace, + side, + startLineNumber, + startSide, +}: ReviewCommentTargetInput & { section: DiffSection }) => { + if (anchor === 'file' || lineNumber == null) { + return true; + } + const endSide = side ?? 'additions'; + const resolvedStartSide = startSide ?? endSide; + return ( + lineExistsInSection(file, section, lineNumber, endSide, showWhitespace) && + (startLineNumber == null || + lineExistsInSection(file, section, startLineNumber, resolvedStartSide, showWhitespace)) + ); +}; + +export const resolveShareCommentTarget = ({ + displayedFiles, + ...input +}: ReviewCommentTargetInput & { + displayedFiles: ReadonlyArray; +}): ShareCommentTargetResolution => { + const candidates = displayedFiles.filter( + (candidate) => candidate.path === input.file.path || candidate.oldPath === input.file.path, + ); + if (candidates.length === 0) { + return { reason: 'file-not-in-target', status: 'read-only' }; + } + if (!input.section) { + return { reason: 'section-not-in-target', status: 'read-only' }; + } + const targets = candidates.flatMap((file) => + file.sections + .filter((section) => section.id === input.section?.id) + .map((section) => ({ file, section })), + ); + if (targets.length === 0) { + return { reason: 'section-not-in-target', status: 'read-only' }; + } + if (targets.length > 1) { + return { reason: 'ambiguous-target-range', status: 'read-only' }; + } + const target = targets[0]!; + if (!targetContainsAnchor({ ...input, file: target.file, section: target.section })) { + return { reason: 'anchor-not-in-target', status: 'read-only' }; + } + return target.section.range + ? { position: { range: target.section.range }, status: 'enabled' } + : { sectionId: target.section.id, status: 'enabled' }; +}; + +export const resolveProviderCommentTarget = ({ + canonicalFiles, + ...input +}: ReviewCommentTargetInput & { + canonicalFiles: ReadonlyArray; +}): ProviderCommentTargetResolution => { + const canonicalFileCandidates = canonicalFiles.filter( + (candidate) => candidate.path === input.file.path || candidate.oldPath === input.file.path, + ); + if (canonicalFileCandidates.length === 0) { + return { reason: 'file-not-in-target', status: 'read-only' }; + } + const targetRange = input.section?.range; + if (!targetRange) { + return { reason: 'missing-target-range', status: 'read-only' }; + } + if (!isCommitRevision(targetRange.base) || !isCommitRevision(targetRange.head)) { + return { reason: 'non-commit-target', status: 'read-only' }; + } + const canonicalSections = canonicalFileCandidates.flatMap((file) => + file.sections.flatMap((section) => + diffRangesMatch(section.range, targetRange) ? [{ file, section }] : [], + ), + ); + if (canonicalSections.length === 0) { + return { reason: 'section-not-in-target', status: 'read-only' }; + } + if (canonicalSections.length > 1) { + return { reason: 'ambiguous-target-range', status: 'read-only' }; + } + const target = canonicalSections[0]!; + if (!targetContainsAnchor({ ...input, file: target.file, section: target.section })) { + return { reason: 'anchor-not-in-target', status: 'read-only' }; + } + return { + position: { + range: { + base: targetRange.base, + head: targetRange.head, + }, + }, + status: 'enabled', + }; +}; diff --git a/core/lib/review-comments.ts b/core/lib/review-comments.ts index 80684834..7519364a 100644 --- a/core/lib/review-comments.ts +++ b/core/lib/review-comments.ts @@ -6,7 +6,6 @@ import type { PullRequestExistingReviewComment, RepositoryState, ReviewCommentPosition, - Revision, ShareCommentSubmission, SubmittedReviewComment, } from '../types.ts'; @@ -23,6 +22,7 @@ import type { ShareInlineComment, } from './app-types.ts'; import { parseSectionDiffWithOptions } from './diff.ts'; +import { diffRangesMatch } from './review-comment-target.ts'; import { isCommitRevision } from './review-history.ts'; export const isInteractiveReviewEvent = (event: PointerEvent) => @@ -228,6 +228,7 @@ export const toProviderCommentSubmission = ( if (comment.threadId) { return { ...getCommentSubmissionFields(comment), + localDraftId: comment.id, threadId: comment.threadId, }; } @@ -239,6 +240,7 @@ export const toProviderCommentSubmission = ( return { ...getCommentSubmissionFields(comment), + localDraftId: comment.id, position: { range: { base: position.range.base, @@ -334,6 +336,7 @@ export const mergeReviewComments = ( const isPendingPullRequestReviewComment = (comment: ProviderCommentDraft) => !comment.threadId && comment.remoteSubmit?.status !== 'submitting' && + comment.remoteSubmit?.status !== 'outcome-unknown' && comment.body.trim().length > 0; export const getPendingPullRequestReviewComments = ( @@ -503,57 +506,64 @@ export const getReviewCommentPatchContext = ( return section.summary?.reason || section.patch.trim() || 'No patch context available.'; }; -const revisionKind = (revision: Revision) => revision.kind ?? 'commit'; - -const revisionsMatch = (left: Revision, right: Revision) => { - const leftKind = revisionKind(left); - const rightKind = revisionKind(right); - if (leftKind !== rightKind) { - return false; - } - return leftKind !== 'commit' || ('sha' in left && 'sha' in right && left.sha === right.sha); -}; +export type ReviewCommentSectionResolutionStrategy = + | 'coordinates' + | 'file' + | 'position' + | 'section-id'; + +export type ReviewCommentSectionResolution = + | { kind: 'resolved'; section: DiffSection } + | { + candidateSectionIds: ReadonlyArray; + kind: 'ambiguous' | 'unmapped'; + strategy: ReviewCommentSectionResolutionStrategy; + }; -const rangesMatch = (left: DiffSection['range'], right: DiffSection['range']) => - left != null && - right != null && - revisionsMatch(left.base, right.base) && - revisionsMatch(left.head, right.head); +const resolveReviewCommentSectionCandidates = ( + sections: ReadonlyArray, + strategy: ReviewCommentSectionResolutionStrategy, +): ReviewCommentSectionResolution => + sections.length === 1 + ? { kind: 'resolved', section: sections[0]! } + : { + candidateSectionIds: sections.map((section) => section.id), + kind: sections.length === 0 ? 'unmapped' : 'ambiguous', + strategy, + }; -export const getReviewCommentSection = ( +export const resolveReviewCommentSection = ( file: ChangedFile, comment: Pick< ReviewComment, 'anchor' | 'lineNumber' | 'position' | 'side' | 'startLineNumber' | 'startSide' > & { sectionId?: string }, showWhitespace: boolean, -) => { +): ReviewCommentSectionResolution => { const positionedRange = comment.position?.range; if (positionedRange) { - const section = file.sections.find((candidate) => - rangesMatch(candidate.range, positionedRange), + return resolveReviewCommentSectionCandidates( + file.sections.filter((candidate) => diffRangesMatch(candidate.range, positionedRange)), + 'position', ); - if (section) { - return section; - } } if (comment.sectionId) { - const section = file.sections.find((candidate) => candidate.id === comment.sectionId); - if (section) { - return section; + const sections = file.sections.filter((candidate) => candidate.id === comment.sectionId); + if (sections.length > 0) { + return resolveReviewCommentSectionCandidates(sections, 'section-id'); } } if (isFileReviewComment(comment)) { - return file.sections[0]; + return resolveReviewCommentSectionCandidates(file.sections, 'file'); } const side = comment.side ?? 'additions'; const line = comment.lineNumber ?? 1; const startLine = comment.startLineNumber ?? line; const startSide = comment.startSide ?? side; - return file.sections.find((section) => { + const matchingSections = file.sections.filter((section) => { const parsed = parseSectionDiffWithOptions(file, section, showWhitespace); return parsed.hunks.some((hunk) => { let oldLine = hunk.deletionStart; @@ -608,6 +618,19 @@ export const getReviewCommentSection = ( return hasStart && hasEnd; }); }); + return resolveReviewCommentSectionCandidates(matchingSections, 'coordinates'); +}; + +export const getReviewCommentSection = ( + file: ChangedFile, + comment: Pick< + ReviewComment, + 'anchor' | 'lineNumber' | 'position' | 'side' | 'startLineNumber' | 'startSide' + > & { sectionId?: string }, + showWhitespace: boolean, +) => { + const resolution = resolveReviewCommentSection(file, comment, showWhitespace); + return resolution.kind === 'resolved' ? resolution.section : undefined; }; export const buildReviewCommentsMarkdown = ( diff --git a/core/react.ts b/core/react.ts index 429e20dc..d70e84e7 100644 --- a/core/react.ts +++ b/core/react.ts @@ -34,6 +34,7 @@ export { type ReviewWalkthroughStatus, type ShareReviewCommentCapabilities, type SubmitProviderReviewRequest, + type SubmitProviderReviewResult, } from './ReviewSurface.tsx'; export type { LocalReviewNote, diff --git a/core/types/review-comments.ts b/core/types/review-comments.ts index c30b19e5..2f028315 100644 --- a/core/types/review-comments.ts +++ b/core/types/review-comments.ts @@ -51,6 +51,8 @@ export type PullRequestReviewComment = { body: string; filePath: string; lineNumber?: number; + /** Local-only draft identity; provider payload builders deliberately omit it. */ + localDraftId?: string; position?: ReviewCommentPosition; sectionId?: string; side?: 'additions' | 'deletions'; @@ -92,18 +94,11 @@ export type ProviderReviewCommentPosition = { }; }; -export type ProviderCommentSubmission = ReviewCommentSubmissionBase & - ( - | { - position: ProviderReviewCommentPosition; - sectionId?: never; - } - | { - position?: never; - sectionId?: never; - threadId: string; - } - ); +export type ProviderCommentSubmission = ReviewCommentSubmissionBase & { + /** Host-only identity used to account for partial review submission. */ + localDraftId: string; + sectionId?: never; +} & ({ position: ProviderReviewCommentPosition } | { position?: never; threadId: string }); export type ShareCommentSubmission = ReviewCommentSubmissionBase & ( @@ -178,12 +173,24 @@ export type ReviewCommenting = { export type PullRequestReviewEvent = 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; export type SubmitPullRequestCommentRequest = { - comment: PullRequestReviewComment; + comment: ProviderCommentSubmission; source: Extract; }; export type SubmitPullRequestReviewRequest = { body?: string; - comments: ReadonlyArray; + comments: ReadonlyArray; event: PullRequestReviewEvent; source: Extract; }; + +export type SubmitPullRequestReviewResult = + | { + status: 'submitted'; + submittedDraftIds: ReadonlyArray; + } + | { + outcomeUnknownDraftIds?: ReadonlyArray; + reason: string; + status: 'failed'; + submittedDraftIds: ReadonlyArray; + }; diff --git a/electron/__tests__/github-review-mutations.test.ts b/electron/__tests__/github-review-mutations.test.ts new file mode 100644 index 00000000..6759ed2f --- /dev/null +++ b/electron/__tests__/github-review-mutations.test.ts @@ -0,0 +1,206 @@ +import { createRequire } from 'node:module'; +import { expect, test, vi } from 'vite-plus/test'; +import type { + GitSha, + SubmitPullRequestCommentRequest, + SubmitPullRequestReviewRequest, +} from '../../core/types.ts'; +import { createTemporaryGitRepository } from './helpers/git-repository.ts'; + +const require = createRequire(import.meta.url); +const { createGitHubReviewMutations } = require('../git-state/github-review-mutations.cjs') as { + createGitHubReviewMutations: (dependencies: Record) => { + submitPullRequestComment: ( + launchPath: string, + request: SubmitPullRequestCommentRequest, + ) => Promise; + submitPullRequestReview: ( + launchPath: string, + request: SubmitPullRequestReviewRequest, + ) => Promise; + }; +}; + +const baseSha = 'a'.repeat(40) as GitSha; +const headSha = 'b'.repeat(40) as GitSha; +const source = { + headSha, + provider: 'github' as const, + type: 'pull-request' as const, + url: 'https://github.com/nkzw-tech/codiff/pull/12', +}; +const comment = { + body: 'Please keep this explicit.', + filePath: 'src/app.ts', + lineNumber: 7, + localDraftId: 'draft-1', + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'aaaaaaa' }, sha: baseSha }, + head: { label: { kind: 'commit' as const, text: 'bbbbbbb' }, sha: headSha }, + }, + }, + side: 'additions' as const, +}; + +const createHarness = () => { + const request = vi.fn(async () => ({})); + const mutations = createGitHubReviewMutations({ + assertPullRequestMatchesRepository: async () => undefined, + createTransport: () => ({ request }), + normalizeGitHubReviewComment: (value: Record) => + value.id + ? { + author: { login: 'reviewer' }, + body: String(value.body || ''), + filePath: String(value.path || 'src/app.ts'), + id: `github:${String(value.id)}`, + lineNumber: Number(value.line || 7), + side: 'additions', + threadId: String(value.in_reply_to_id || value.id), + } + : null, + parseGitHubPullRequestUrl: () => ({ + number: 12, + owner: 'nkzw-tech', + repo: 'codiff', + url: source.url, + }), + readCurrentTarget: async () => ({ + baseSha, + files: [{ newPath: 'src/app.ts', patch: '@@ -7 +7 @@\n-old\n+new\n' }], + headSha, + }), + }); + return { mutations, request }; +}; + +test('submits GitHub replies with provider thread identity only', async () => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + request.mockResolvedValueOnce({ + body: 'Reply in the existing thread.', + id: 8, + in_reply_to_id: 7, + line: 7, + path: 'src/app.ts', + }); + + await expect( + mutations.submitPullRequestComment(repository.path, { + comment: { + body: 'Reply in the existing thread.', + filePath: 'src/app.ts', + localDraftId: 'reply-draft', + threadId: '7', + }, + source, + }), + ).resolves.toMatchObject({ id: 'github:8', threadId: '7' }); + expect(request).toHaveBeenCalledWith({ + body: { body: 'Reply in the existing thread.', in_reply_to: 7 }, + method: 'POST', + path: 'repos/nkzw-tech/codiff/pulls/12/comments', + }); +}); + +test('submits a review against the exact validated GitHub head', async () => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitPullRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + expect(request).toHaveBeenCalledWith({ + body: { + body: 'Review comments.', + comments: [ + { + body: comment.body, + line: 7, + path: 'src/app.ts', + side: 'RIGHT', + }, + ], + commit_id: headSha, + event: 'COMMENT', + }, + method: 'POST', + path: 'repos/nkzw-tech/codiff/pulls/12/reviews', + }); +}); + +test('rejects a stale GitHub range before mutating the provider', async () => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + const stale = { + ...comment, + position: { + range: { + ...comment.position.range, + head: { ...comment.position.range.head, sha: 'c'.repeat(40) as GitSha }, + }, + }, + }; + + await expect( + mutations.submitPullRequestReview(repository.path, { + comments: [stale], + event: 'COMMENT', + source, + }), + ).resolves.toMatchObject({ + reason: expect.stringContaining('draft range no longer matches'), + status: 'failed', + submittedDraftIds: [], + }); + expect(request).not.toHaveBeenCalled(); +}); + +test('rejects a GitHub line absent from the fresh target diff', async () => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitPullRequestReview(repository.path, { + comments: [{ ...comment, lineNumber: 99 }], + event: 'COMMENT', + source, + }), + ).resolves.toMatchObject({ + reason: expect.stringContaining('Line 99'), + status: 'failed', + submittedDraftIds: [], + }); + expect(request).not.toHaveBeenCalled(); +}); + +test.each(['APPROVE', 'REQUEST_CHANGES'] as const)( + 'batches GitHub inline comments and summary with the %s outcome', + async (event) => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitPullRequestReview(repository.path, { + body: 'Outcome summary.', + comments: [comment], + event, + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + expect(request).toHaveBeenCalledWith({ + body: expect.objectContaining({ + body: 'Outcome summary.', + commit_id: headSha, + event, + }), + method: 'POST', + path: 'repos/nkzw-tech/codiff/pulls/12/reviews', + }); + }, +); diff --git a/electron/__tests__/gitlab-review-mutations.test.ts b/electron/__tests__/gitlab-review-mutations.test.ts new file mode 100644 index 00000000..b18f9a26 --- /dev/null +++ b/electron/__tests__/gitlab-review-mutations.test.ts @@ -0,0 +1,448 @@ +import { createRequire } from 'node:module'; +import { expect, test, vi } from 'vite-plus/test'; +import type { + GitSha, + SubmitPullRequestCommentRequest, + SubmitPullRequestReviewRequest, +} from '../../core/types.ts'; +import { createTemporaryGitRepository } from './helpers/git-repository.ts'; + +const require = createRequire(import.meta.url); +const { createGitLabReviewMutations } = require('../git-state/gitlab-review-mutations.cjs') as { + createGitLabReviewMutations: (dependencies: Record) => { + submitMergeRequestComment: ( + launchPath: string, + request: SubmitPullRequestCommentRequest, + ) => Promise; + submitMergeRequestReview: ( + launchPath: string, + request: SubmitPullRequestReviewRequest, + ) => Promise; + }; +}; + +const baseSha = 'a'.repeat(40) as GitSha; +const headSha = 'b'.repeat(40) as GitSha; +const source = { + headSha, + provider: 'gitlab' as const, + type: 'pull-request' as const, + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', +}; +const comment = { + body: 'Keep this explicit.', + filePath: 'src/new.ts', + lineNumber: 12, + localDraftId: 'draft-1', + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'aaaaaaa' }, sha: baseSha }, + head: { label: { kind: 'commit' as const, text: 'bbbbbbb' }, sha: headSha }, + }, + }, + side: 'additions' as const, +}; +const secondComment = { + ...comment, + body: 'Keep this second detail explicit.', + localDraftId: 'draft-2', +}; + +type MutationRequest = { body?: unknown; method?: 'DELETE' | 'GET' | 'POST'; path: string }; + +const createHarness = ({ + failRequest, + normalizeSubmittedGitLabReviewComment = () => null, +}: { + failRequest?: (request: MutationRequest, index: number) => Error | undefined; + normalizeSubmittedGitLabReviewComment?: ( + note: Record, + submittedComment: typeof comment, + url: string, + threadId?: string, + ) => unknown; +} = {}) => { + let nextRemoteDraftId = 1; + let requestIndex = 0; + const publishedDraftBatches: Array> = []; + const remoteDrafts = new Map(); + const request = vi.fn(async (nextRequest: MutationRequest) => { + requestIndex += 1; + const failure = failRequest?.(nextRequest, requestIndex); + if (failure) { + throw failure; + } + if (nextRequest.method === 'POST' && nextRequest.path.endsWith('/draft_notes')) { + const id = String(nextRemoteDraftId); + nextRemoteDraftId += 1; + const note = (nextRequest.body as { note?: unknown } | undefined)?.note; + remoteDrafts.set(id, typeof note === 'string' ? note : ''); + return { id }; + } + if (nextRequest.method === 'DELETE') { + const id = nextRequest.path.split('/').at(-1); + if (id) remoteDrafts.delete(decodeURIComponent(id)); + return {}; + } + if ( + nextRequest.method === 'POST' && + (nextRequest.path.endsWith('/draft_notes/bulk_publish') || + nextRequest.path.endsWith('/notes')) + ) { + publishedDraftBatches.push([...remoteDrafts.values()]); + remoteDrafts.clear(); + } + return {}; + }); + const readMergeRequestDiffs = vi.fn(async () => [ + { + diff: '@@ -10,2 +12,2 @@\n context\n-old\n+new\n', + new_path: 'src/new.ts', + old_path: 'src/old.ts', + }, + ]); + const readMergeRequestMetadata = vi.fn(async () => ({ + diff_refs: { base_sha: baseSha, head_sha: headSha, start_sha: baseSha }, + sha: headSha, + })); + const mutations = createGitLabReviewMutations({ + createTransport: () => ({ request }), + getDiscussionReplyEndpoint: () => '', + mergeRequestEndpoint: (_mergeRequest: unknown, suffix = '') => `merge-request${suffix}`, + normalizeSubmittedGitLabReviewComment, + parseGitLabMergeRequestUrl: () => ({ url: source.url }), + readMergeRequestDiffs, + readMergeRequestMetadata, + selectMergeRequestRemote: () => undefined, + }); + return { + mutations, + publishedDraftBatches, + readMergeRequestDiffs, + readMergeRequestMetadata, + remoteDrafts, + request, + }; +}; + +const expectNoNeutralQuickAction = ( + calls: ReadonlyArray]>, +) => { + expect(calls.some(([nextRequest]) => nextRequest.path.endsWith('/notes'))).toBe(false); + expect(JSON.stringify(calls)).not.toContain('/submit_review approve'); + expect(JSON.stringify(calls)).not.toContain('/submit_review request_changes'); +}; + +test('returns the GitLab discussion identity for a newly submitted comment', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const normalizeSubmittedGitLabReviewComment = vi.fn( + ( + note: Record, + submittedComment: typeof comment, + url: string, + threadId?: string, + ) => ({ + ...submittedComment, + author: { login: 'reviewer' }, + id: `gitlab:${String(note.id)}`, + threadId, + url, + }), + ); + const { mutations, request } = createHarness({ normalizeSubmittedGitLabReviewComment }); + request.mockResolvedValueOnce({ + id: 'discussion-42', + notes: [{ body: comment.body, id: 91 }], + }); + + await expect( + mutations.submitMergeRequestComment(repository.path, { comment, source }), + ).resolves.toMatchObject({ id: 'gitlab:91', threadId: 'discussion-42' }); + expect(normalizeSubmittedGitLabReviewComment).toHaveBeenCalledWith( + expect.objectContaining({ id: 91 }), + comment, + source.url, + 'discussion-42', + ); +}); + +test('bulk-publishes an inline-only neutral GitLab review', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: 'POST', path: 'merge-request/draft_notes' }), + ); + expect(request).toHaveBeenNthCalledWith(2, { + body: { reviewer_state: 'reviewed' }, + method: 'POST', + path: 'merge-request/draft_notes/bulk_publish', + }); + expectNoNeutralQuickAction(request.mock.calls); +}); + +test('includes the optional summary in neutral GitLab publication', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + body: 'Review summary.', + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + expect(request).toHaveBeenLastCalledWith({ + body: { note: 'Review summary.', reviewer_state: 'reviewed' }, + method: 'POST', + path: 'merge-request/draft_notes/bulk_publish', + }); + expectNoNeutralQuickAction(request.mock.calls); +}); + +test('bulk-publishes a summary-only neutral GitLab review without a regular note', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + body: 'Summary only.', + comments: [], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: [] }); + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith({ + body: { note: 'Summary only.', reviewer_state: 'reviewed' }, + method: 'POST', + path: 'merge-request/draft_notes/bulk_publish', + }); + expectNoNeutralQuickAction(request.mock.calls); +}); + +test('rejects an empty neutral GitLab review before provider calls', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, readMergeRequestDiffs, readMergeRequestMetadata, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ + reason: 'A neutral review requires an inline comment or summary.', + status: 'failed', + submittedDraftIds: [], + }); + expect(readMergeRequestMetadata).not.toHaveBeenCalled(); + expect(readMergeRequestDiffs).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); +}); + +test('blocks retry when GitLab accepts a draft without returning its identity', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + request.mockImplementation(async ({ method, path }) => { + if (method === 'POST' && path.endsWith('/draft_notes')) { + return {}; + } + return {}; + }); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ + outcomeUnknownDraftIds: ['draft-1'], + reason: 'GitLab accepted a draft note but did not return its ID.', + status: 'failed', + submittedDraftIds: [], + }); +}); + +test('removes accepted GitLab drafts when neutral publication fails', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, remoteDrafts, request } = createHarness({ + failRequest: ({ path }) => + path.endsWith('/draft_notes/bulk_publish') + ? new Error('GitLab rejected bulk publication.') + : undefined, + }); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ + reason: 'GitLab rejected bulk publication.', + status: 'failed', + submittedDraftIds: [], + }); + expect(remoteDrafts.size).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'DELETE', + path: 'merge-request/draft_notes/1', + }); +}); + +test('cleans partial GitLab drafts before retrying the review', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + let draftRequests = 0; + const { mutations, publishedDraftBatches, remoteDrafts, request } = createHarness({ + failRequest: ({ method, path }) => { + if (method !== 'POST' || !path.endsWith('/draft_notes')) { + return undefined; + } + draftRequests += 1; + return draftRequests === 2 ? new Error('GitLab rejected the second draft.') : undefined; + }, + }); + const review = { + comments: [comment, secondComment], + event: 'COMMENT' as const, + source, + }; + + await expect(mutations.submitMergeRequestReview(repository.path, review)).resolves.toEqual({ + outcomeUnknownDraftIds: ['draft-2'], + reason: 'GitLab rejected the second draft.', + status: 'failed', + submittedDraftIds: [], + }); + expect(remoteDrafts.size).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'DELETE', + path: 'merge-request/draft_notes/1', + }); + expect( + request.mock.calls.some(([nextRequest]) => + nextRequest.path.endsWith('/draft_notes/bulk_publish'), + ), + ).toBe(false); + + await expect(mutations.submitMergeRequestReview(repository.path, review)).resolves.toEqual({ + status: 'submitted', + submittedDraftIds: ['draft-1', 'draft-2'], + }); + expect(publishedDraftBatches).toEqual([ + ['Keep this explicit.', 'Keep this second detail explicit.'], + ]); + expect(remoteDrafts.size).toBe(0); +}); + +test('reports remote draft ownership when GitLab cleanup also fails', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, remoteDrafts } = createHarness({ + failRequest: ({ method, path }) => { + if (path.endsWith('/draft_notes/bulk_publish')) { + return new Error('GitLab rejected bulk publication.'); + } + return method === 'DELETE' ? new Error('GitLab rejected draft cleanup.') : undefined; + }, + }); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ + outcomeUnknownDraftIds: ['draft-1'], + reason: + 'GitLab rejected bulk publication. GitLab draft cleanup also failed: GitLab rejected draft cleanup.', + status: 'failed', + submittedDraftIds: [], + }); + expect([...remoteDrafts.values()]).toEqual(['Keep this explicit.']); +}); + +test.each(['APPROVE', 'REQUEST_CHANGES'] as const)( + 'keeps GitLab draft IDs unconfirmed when the final %s outcome fails', + async (event) => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations } = createHarness({ + failRequest: ({ path }) => + path.endsWith('/notes') ? new Error('GitLab rejected the final outcome.') : undefined, + }); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event, + source, + }), + ).resolves.toEqual({ + reason: 'GitLab rejected the final outcome.', + status: 'failed', + submittedDraftIds: [], + }); + }, +); + +test.each([ + ['APPROVE', '/submit_review approve'], + ['REQUEST_CHANGES', '/submit_review request_changes'], +] as const)( + 'confirms all GitLab draft IDs after the final %s outcome succeeds', + async (event, action) => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + body: 'Outcome summary.', + comments: [comment, secondComment], + event, + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1', 'draft-2'] }); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: 'POST', path: 'merge-request/draft_notes' }), + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ method: 'POST', path: 'merge-request/draft_notes' }), + ); + expect(request).toHaveBeenNthCalledWith(3, { + body: { body: `Outcome summary.\n\n${action}` }, + method: 'POST', + path: 'merge-request/notes', + }); + }, +); + +test('validates every GitLab draft before posting the first one', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment, { ...comment, lineNumber: 99, localDraftId: 'draft-2' }], + event: 'REQUEST_CHANGES', + source, + }), + ).resolves.toMatchObject({ + reason: expect.stringContaining('Line 99'), + status: 'failed', + submittedDraftIds: [], + }); + expect(request).not.toHaveBeenCalled(); +}); diff --git a/electron/__tests__/helpers/git-repository.ts b/electron/__tests__/helpers/git-repository.ts new file mode 100644 index 00000000..b62340de --- /dev/null +++ b/electron/__tests__/helpers/git-repository.ts @@ -0,0 +1,19 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { getGitTestEnvironmentForSubprocess } from '../../../core/__tests__/helpers/git.ts'; +import { createTemporaryDirectory } from '../../../core/__tests__/helpers/resources.ts'; + +const execFileAsync = promisify(execFile); + +export const createTemporaryGitRepository = async (prefix: string) => { + const directory = await createTemporaryDirectory(prefix); + try { + await execFileAsync('git', ['-C', directory.path, 'init', '--quiet'], { + env: getGitTestEnvironmentForSubprocess(), + }); + return directory; + } catch (error) { + await directory[Symbol.asyncDispose](); + throw error; + } +}; diff --git a/electron/__tests__/pull-request-command.test.ts b/electron/__tests__/pull-request-command.test.ts index ca97ef7e..2310b62a 100644 --- a/electron/__tests__/pull-request-command.test.ts +++ b/electron/__tests__/pull-request-command.test.ts @@ -28,7 +28,10 @@ const { submitPullRequestReview } = require('../git-state/pull-request.cjs') as url: string; }; }, - ) => Promise; + ) => Promise< + | { status: 'submitted'; submittedDraftIds: ReadonlyArray } + | { reason: string; status: 'failed'; submittedDraftIds: ReadonlyArray } + >; }; const execFileAsync = promisify(execFile); @@ -113,7 +116,11 @@ test('reports a missing GitHub CLI when the resolved executable cannot be spawne url: 'https://github.com/nkzw-tech/codiff/pull/12', }, }), - ).rejects.toThrow('GitHub support requires gh'); + ).resolves.toMatchObject({ + reason: expect.stringContaining('GitHub support requires gh'), + status: 'failed', + submittedDraftIds: [], + }); }); test('reaches the GitHub CLI when it is not on PATH', async () => { @@ -148,11 +155,23 @@ test('reaches the GitHub CLI when it is not on PATH', async () => { fakeGh, `#!/bin/sh printf '%s | %s\\n' "$*" "$(cat)" >> "$CODIFF_GITHUB_COMMAND_TEST_CALLS" +head_sha='0123456789abcdef0123456789abcdef01234567' +base_sha='fedcba9876543210fedcba9876543210fedcba98' for arg in "$@"; do if [ "$arg" = '/repos/nkzw-tech/codiff/pulls/12' ]; then - printf '%s' '{"head":{"sha":"0123456789abcdef0123456789abcdef01234567"}}' + printf '%s' '{"base":{"sha":"'"$base_sha"'"},"head":{"sha":"'"$head_sha"'"}}' exit 0 fi + case "$arg" in + /repos/nkzw-tech/codiff/compare/*) + printf '%s' '{"merge_base_commit":{"sha":"'"$base_sha"'"}}' + exit 0 + ;; + /repos/nkzw-tech/codiff/pulls/12/files*) + printf '%s' '[]' + exit 0 + ;; + esac done printf '%s' '{}' `, @@ -166,23 +185,31 @@ printf '%s' '{}' SHELL: undefined, }); - await submitPullRequestReview(repo, { - body: 'General feedback.', - comments: [], - event: 'COMMENT', - source: { - provider: 'github', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - }, - }); + await expect( + submitPullRequestReview(repo, { + body: 'General feedback.', + comments: [], + event: 'COMMENT', + source: { + provider: 'github', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', + }, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: [] }); const calls = (await readFile(callsPath, 'utf8')).trim().split('\n'); - expect(calls).toEqual([ - 'api /repos/nkzw-tech/codiff/pulls/12 | ', - 'api -X POST repos/nkzw-tech/codiff/pulls/12/reviews --input - | ' + - '{"body":"General feedback.","comments":[],"event":"COMMENT"}', - ]); + expect(calls[0]).toBe('api /repos/nkzw-tech/codiff/pulls/12 | '); + expect(calls.slice(1, 3)).toEqual( + expect.arrayContaining([ + `api /repos/nkzw-tech/codiff/compare/${'fedcba9876543210fedcba9876543210fedcba98'}...${'0123456789abcdef0123456789abcdef01234567'} | `, + 'api --paginate /repos/nkzw-tech/codiff/pulls/12/files?per_page=100 | ', + ]), + ); + expect(calls[3]).toBe( + 'api --method POST /repos/nkzw-tech/codiff/pulls/12/reviews --input - | ' + + '{"body":"General feedback.","commit_id":"0123456789abcdef0123456789abcdef01234567","comments":[],"event":"COMMENT"}', + ); }); test('authenticates gh from the login shell environment when the app inherited none', async () => { diff --git a/electron/__tests__/pull-request-review.test.ts b/electron/__tests__/pull-request-review.test.ts index bdf45241..29be2e20 100644 --- a/electron/__tests__/pull-request-review.test.ts +++ b/electron/__tests__/pull-request-review.test.ts @@ -8,6 +8,7 @@ import { createTemporaryDirectory, createTemporaryEnvironment, } from '../../core/__tests__/helpers/resources.ts'; +import type { GitSha, SubmitPullRequestReviewResult } from '../../core/types.ts'; const require = createRequire(import.meta.url); const { submitPullRequestReview } = require('../git-state/pull-request.cjs') as { @@ -23,7 +24,7 @@ const { submitPullRequestReview } = require('../git-state/pull-request.cjs') as url: string; }; }, - ) => Promise; + ) => Promise; }; const execFileAsync = promisify(execFile); @@ -34,6 +35,9 @@ test('submits normalized GitHub review payloads through the GitHub CLI', async ( const fakeBin = join(directory.path, 'bin'); const fakeGh = join(fakeBin, 'gh'); const callsPath = join(directory.path, 'calls.jsonl'); + const baseSha = 'a'.repeat(40); + const advancedBaseTipSha = 'c'.repeat(40); + const headSha = 'b'.repeat(40); await Promise.all([mkdir(repo), mkdir(fakeBin)]); await execFileAsync('git', ['-C', repo, 'init']); @@ -58,11 +62,24 @@ process.stdin.on('end', () => { process.env.CODIFF_GITHUB_REVIEW_TEST_CALLS, JSON.stringify({ args, input }) + '\\n', ); - process.stdout.write( - args.includes('repos/nkzw-tech/codiff/pulls/12') - ? '{"head":{"sha":"0123456789abcdef0123456789abcdef01234567"}}' - : '{}', - ); + const endpoint = args.find((argument) => argument.startsWith('/repos/')) || ''; + if (endpoint.includes('/compare/')) { + process.stdout.write(JSON.stringify({ merge_base_commit: { sha: '${baseSha}' } })); + return; + } + if (endpoint.endsWith('/files?per_page=100')) { + process.stdout.write(JSON.stringify([{ + filename: 'src/app.ts', + patch: '@@ -7 +7 @@\\n-old\\n+new\\n', + }])); + return; + } + process.stdout.write(endpoint.endsWith('/pulls/12') + ? JSON.stringify({ + base: { sha: '${advancedBaseTipSha}' }, + head: { sha: '${headSha}' }, + }) + : '{}'); }); `, ); @@ -75,6 +92,7 @@ process.stdin.on('end', () => { }); const source = { + headSha: headSha as GitSha, provider: 'github' as const, type: 'pull-request' as const, url: 'https://github.com/nkzw-tech/codiff/pull/12', @@ -86,6 +104,19 @@ process.stdin.on('end', () => { body: 'Please keep this explicit.', filePath: 'src/app.ts', lineNumber: 7, + localDraftId: 'draft-1', + position: { + range: { + base: { + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + sha: baseSha as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'bbbbbbb' }, + sha: headSha as GitSha, + }, + }, + }, side: 'additions', }, ], @@ -105,7 +136,11 @@ process.stdin.on('end', () => { event: 'COMMENT', source, }), - ).rejects.toThrow('A comment review requires an inline comment or a review comment.'); + ).resolves.toEqual({ + reason: 'A comment review requires an inline comment or a review comment.', + status: 'failed', + submittedDraftIds: [], + }); await submitPullRequestReview(repo, { comments: [], event: 'REQUEST_CHANGES', @@ -123,11 +158,19 @@ process.stdin.on('end', () => { }, ); const reviewCalls = calls.filter((call) => - call.args.includes('repos/nkzw-tech/codiff/pulls/12/reviews'), + call.args.includes('/repos/nkzw-tech/codiff/pulls/12/reviews'), + ); + const endpoints = calls.flatMap((call) => + call.args.filter((argument) => argument.startsWith('/repos/')), ); + const metadataPath = '/repos/nkzw-tech/codiff/pulls/12'; + const comparePath = `/repos/nkzw-tech/codiff/compare/${advancedBaseTipSha}...${headSha}`; + expect(endpoints).toContain(comparePath); + expect(endpoints.indexOf(metadataPath)).toBeLessThan(endpoints.indexOf(comparePath)); expect(reviewCalls).toHaveLength(3); expect(JSON.parse(reviewCalls[0].input)).toEqual({ - body: '', + body: 'Review comments.', + commit_id: headSha, comments: [ { body: 'Please keep this explicit.', @@ -140,12 +183,146 @@ process.stdin.on('end', () => { }); expect(JSON.parse(reviewCalls[1].input)).toEqual({ body: 'General feedback.', + commit_id: headSha, comments: [], event: 'COMMENT', }); expect(JSON.parse(reviewCalls[2].input)).toEqual({ body: 'Requesting changes.', + commit_id: headSha, comments: [], event: 'REQUEST_CHANGES', }); }); + +test.each([ + { + expectedReason: 'head changed', + mergeBaseSha: 'a'.repeat(40), + metadataHeadSha: 'd'.repeat(40), + name: 'changed head', + }, + { + expectedReason: 'draft range no longer matches', + mergeBaseSha: 'e'.repeat(40), + metadataHeadSha: 'b'.repeat(40), + name: 'changed merge base', + }, + { + expectedReason: 'did not return the current pull request merge base', + mergeBaseSha: null, + metadataHeadSha: 'b'.repeat(40), + name: 'missing merge base', + }, +])( + 'rejects a $name before mutating GitHub', + async ({ expectedReason, mergeBaseSha, metadataHeadSha }) => { + await using directory = await createTemporaryDirectory('codiff-pull-request-review-'); + const repo = join(directory.path, 'repo'); + const fakeBin = join(directory.path, 'bin'); + const fakeGh = join(fakeBin, 'gh'); + const callsPath = join(directory.path, 'calls.jsonl'); + const draftBaseSha = 'a'.repeat(40); + const baseTipSha = 'c'.repeat(40); + const draftHeadSha = 'b'.repeat(40); + + await Promise.all([mkdir(repo), mkdir(fakeBin)]); + await execFileAsync('git', ['-C', repo, 'init']); + await execFileAsync('git', [ + '-C', + repo, + 'remote', + 'add', + 'origin', + 'git@github.com:nkzw-tech/codiff.git', + ]); + await writeFile( + fakeGh, + `#!/usr/bin/env node +const { appendFileSync } = require('node:fs'); +const args = process.argv.slice(2); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + appendFileSync( + process.env.CODIFF_GITHUB_REVIEW_TEST_CALLS, + JSON.stringify({ args, input }) + '\\n', + ); + const endpoint = args.find((argument) => argument.startsWith('/repos/')) || ''; + if (endpoint.includes('/compare/')) { + process.stdout.write(${JSON.stringify( + JSON.stringify(mergeBaseSha ? { merge_base_commit: { sha: mergeBaseSha } } : {}), + )}); + return; + } + if (endpoint.endsWith('/files?per_page=100')) { + process.stdout.write(JSON.stringify([{ + filename: 'src/app.ts', + patch: '@@ -7 +7 @@\\n-old\\n+new\\n', + }])); + return; + } + process.stdout.write(endpoint.endsWith('/pulls/12') + ? ${JSON.stringify( + JSON.stringify({ + base: { sha: baseTipSha }, + head: { sha: metadataHeadSha }, + }), + )} + : '{}'); +}); +`, + ); + await chmod(fakeGh, 0o755); + + await using _environment = createTemporaryEnvironment({ + CODIFF_GITHUB_REVIEW_TEST_CALLS: callsPath, + PATH: `${fakeBin}:${process.env.PATH ?? ''}`, + }); + + const result = await submitPullRequestReview(repo, { + comments: [ + { + body: 'Please keep this explicit.', + filePath: 'src/app.ts', + lineNumber: 7, + localDraftId: 'draft-1', + position: { + range: { + base: { + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + sha: draftBaseSha as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'bbbbbbb' }, + sha: draftHeadSha as GitSha, + }, + }, + }, + side: 'additions', + }, + ], + event: 'COMMENT', + source: { + headSha: draftHeadSha as GitSha, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', + }, + }); + + expect(result).toMatchObject({ + reason: expect.stringContaining(expectedReason), + status: 'failed', + submittedDraftIds: [], + }); + const calls = (await readFile(callsPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { args: ReadonlyArray }); + expect( + calls.filter((call) => call.args.includes('/repos/nkzw-tech/codiff/pulls/12/reviews')), + ).toHaveLength(0); + }, +); diff --git a/electron/git-state/github-review-mutations.cjs b/electron/git-state/github-review-mutations.cjs new file mode 100644 index 00000000..e0320dfb --- /dev/null +++ b/electron/git-state/github-review-mutations.cjs @@ -0,0 +1,192 @@ +// @ts-check + +const { git } = require('./common.cjs'); +const { validateCurrentReviewCommentTarget } = require('./review-comment-target.cjs'); + +/** + * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment + * @typedef {import('../../core/types.ts').SubmitPullRequestCommentRequest} SubmitPullRequestCommentRequest + * @typedef {import('../../core/types.ts').SubmitPullRequestReviewRequest} SubmitPullRequestReviewRequest + * @typedef {{number: number, owner: string, repo: string, url: string}} PullRequestReference + * @typedef {{request: (request: {body?: unknown, method?: 'GET' | 'POST', paginate?: boolean, path: string, query?: Readonly>}) => Promise}} GitHubMutationTransport + * @typedef {{baseSha?: string, files: ReadonlyArray<{newPath: string, oldPath?: string, patch?: string}>, headSha?: string}} GitHubReviewTarget + */ + +const PENDING_REVIEW_COMMENT_ERROR = + 'You already have a pending GitHub review on this pull request. Submit or discard it on GitHub, then retry. Your comment draft is still here.'; + +/** @param {PullRequestReviewComment['side']} side */ +const toGitHubReviewSide = (side) => (side === 'deletions' ? 'LEFT' : 'RIGHT'); + +/** @param {PullRequestReviewComment} comment */ +const normalizePullRequestComment = (comment) => { + if (comment.anchor === 'file' || comment.lineNumber == null || comment.side == null) { + return { + body: comment.body, + path: comment.filePath, + subject_type: 'file', + }; + } + /** @type {{body: string, line: number, path: string, side: string, start_line?: number, start_side?: string}} */ + const payload = { + body: comment.body, + line: comment.lineNumber, + path: comment.filePath, + side: toGitHubReviewSide(comment.side), + }; + const startSide = comment.startSide ?? comment.side; + if ( + typeof comment.startLineNumber === 'number' && + comment.startLineNumber !== comment.lineNumber + ) { + payload.start_line = comment.startLineNumber; + payload.start_side = toGitHubReviewSide(startSide); + } + return payload; +}; + +/** @param {unknown} error */ +const isGitHubValidationError = (error) => + error instanceof Error && /(?:validation failed|http 422)/i.test(error.message); + +/** @param {SubmitPullRequestReviewRequest} request */ +const validatePullRequestReviewRequest = (request) => { + const body = request.body?.trim() || ''; + if (request.event === 'COMMENT' && request.comments.length === 0 && !body) { + throw new Error('A comment review requires an inline comment or a review comment.'); + } +}; + +/** @param {SubmitPullRequestReviewRequest} request @param {string} targetSha */ +const createPullRequestReviewPayload = (request, targetSha) => { + validatePullRequestReviewRequest(request); + const body = request.body?.trim() || ''; + return { + body: + body || + (request.event === 'COMMENT' + ? 'Review comments.' + : request.event === 'REQUEST_CHANGES' && request.comments.length === 0 + ? 'Requesting changes.' + : ''), + commit_id: targetSha, + comments: request.comments.map(normalizePullRequestComment), + event: request.event, + }; +}; + +/** + * @param {{ + * assertPullRequestMatchesRepository: (repoRoot: string, pullRequest: PullRequestReference) => Promise, + * createTransport: (repoRoot: string, pullRequest: PullRequestReference) => GitHubMutationTransport, + * normalizeGitHubReviewComment: (comment: any) => import('../../core/types.ts').PullRequestExistingReviewComment | null, + * parseGitHubPullRequestUrl: (value: string) => PullRequestReference, + * readCurrentTarget: (repoRoot: string, pullRequest: PullRequestReference, transport: GitHubMutationTransport) => Promise, + * }} dependencies + */ +const createGitHubReviewMutations = ({ + assertPullRequestMatchesRepository, + createTransport, + normalizeGitHubReviewComment, + parseGitHubPullRequestUrl, + readCurrentTarget, +}) => { + /** @param {PullRequestReference} pullRequest @param {GitHubMutationTransport} transport */ + const hasPendingPullRequestReview = async (pullRequest, transport) => { + const reviews = await transport.request({ + paginate: true, + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews`, + query: { per_page: 100 }, + }); + return Array.isArray(reviews) && reviews.some((review) => review?.state === 'PENDING'); + }; + + /** @param {string} launchPath @param {SubmitPullRequestCommentRequest} request */ + const submitPullRequestComment = async (launchPath, request) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const pullRequest = parseGitHubPullRequestUrl(request.source.url); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const transport = createTransport(repoRoot, pullRequest); + const target = await readCurrentTarget(repoRoot, pullRequest, transport); + validateCurrentReviewCommentTarget(request.comment, target); + const replyTo = request.comment.threadId ? Number(request.comment.threadId) : null; + if (request.comment.threadId && (!Number.isInteger(replyTo) || replyTo <= 0)) { + throw new Error('GitHub review replies require a numeric provider thread ID.'); + } + const rawComment = await transport + .request({ + body: replyTo + ? { body: request.comment.body, in_reply_to: replyTo } + : { + ...normalizePullRequestComment(request.comment), + commit_id: target.headSha, + }, + method: 'POST', + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/comments`, + }) + .catch(async (error) => { + if (isGitHubValidationError(error)) { + const hasPendingReview = await hasPendingPullRequestReview(pullRequest, transport).catch( + () => false, + ); + if (hasPendingReview) { + throw new Error(PENDING_REVIEW_COMMENT_ERROR); + } + } + throw error; + }); + const comment = normalizeGitHubReviewComment(rawComment); + if (!comment) { + throw new Error('GitHub accepted the comment but did not return line metadata.'); + } + return comment; + }; + + /** @param {string} launchPath @param {SubmitPullRequestReviewRequest} request */ + const submitPullRequestReview = async (launchPath, request) => { + /** @type {Array} */ + const submittedDraftIds = []; + try { + validatePullRequestReviewRequest(request); + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const pullRequest = parseGitHubPullRequestUrl(request.source.url); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const transport = createTransport(repoRoot, pullRequest); + const target = await readCurrentTarget(repoRoot, pullRequest, transport); + if (!target.headSha) { + throw new Error('GitHub did not return the current pull request head.'); + } + if (request.source.headSha && target.headSha !== request.source.headSha) { + throw new Error('The pull request head changed. Refresh before submitting.'); + } + for (const comment of request.comments) { + validateCurrentReviewCommentTarget(comment, target); + } + await transport.request({ + body: createPullRequestReviewPayload(request, target.headSha), + method: 'POST', + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews`, + }); + submittedDraftIds.push( + ...request.comments + .map((comment) => comment.localDraftId) + .filter((id) => typeof id === 'string'), + ); + return { status: /** @type {const} */ ('submitted'), submittedDraftIds }; + } catch (error) { + return { + reason: error instanceof Error ? error.message : String(error), + status: /** @type {const} */ ('failed'), + submittedDraftIds, + }; + } + }; + + return { submitPullRequestComment, submitPullRequestReview }; +}; + +module.exports = { + PENDING_REVIEW_COMMENT_ERROR, + createGitHubReviewMutations, + normalizePullRequestComment, +}; diff --git a/electron/git-state/gitlab-review-mutations.cjs b/electron/git-state/gitlab-review-mutations.cjs new file mode 100644 index 00000000..c2da42a5 --- /dev/null +++ b/electron/git-state/gitlab-review-mutations.cjs @@ -0,0 +1,338 @@ +// @ts-check + +const { createHash } = require('node:crypto'); +const { git } = require('./common.cjs'); +const { + targetResolutionError, + validateCurrentReviewCommentTarget, +} = require('./review-comment-target.cjs'); + +/** + * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment + * @typedef {{request: (request: {body?: unknown, method?: 'DELETE' | 'GET' | 'POST', path: string}) => Promise}} GitLabMutationTransport + */ + +/** @param {string} path @param {number | undefined} oldLine @param {number | undefined} newLine */ +const getGitLabLineCode = (path, oldLine, newLine) => + `${createHash('sha1').update(path).digest('hex')}_${oldLine || 0}_${newLine || 0}`; + +/** @param {string} diff */ +const createGitLabDiffLineMap = (diff) => { + const lines = new Map(); + let oldLine = 0; + let newLine = 0; + for (const line of diff.split('\n')) { + const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + oldLine = Number(hunk[1]); + newLine = Number(hunk[2]); + } else if (line.startsWith('+') && !line.startsWith('+++')) { + lines.set(`additions:${newLine}`, { newLine }); + newLine += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + lines.set(`deletions:${oldLine}`, { oldLine }); + oldLine += 1; + } else if (oldLine > 0 && newLine > 0 && line.startsWith(' ')) { + const value = { newLine, oldLine }; + lines.set(`additions:${newLine}`, value); + lines.set(`deletions:${oldLine}`, value); + oldLine += 1; + newLine += 1; + } + } + return lines; +}; + +/** @param {PullRequestReviewComment} comment @param {any} metadata @param {any} diff */ +const createGitLabPosition = (comment, metadata, diff) => { + const oldPath = diff?.old_path || comment.filePath; + const newPath = diff?.new_path || comment.filePath; + const targetRange = comment.position?.range; + const targetBaseSha = + targetRange?.base && 'sha' in targetRange.base + ? targetRange.base.sha + : metadata.diff_refs?.base_sha; + const targetHeadSha = + targetRange?.head && 'sha' in targetRange.head + ? targetRange.head.sha + : metadata.diff_refs?.head_sha || metadata.sha; + if (comment.anchor === 'file' || comment.lineNumber == null || comment.side == null) { + return { + base_sha: targetBaseSha, + head_sha: targetHeadSha, + new_path: newPath, + old_path: oldPath, + position_type: 'file', + start_sha: metadata.diff_refs?.start_sha, + }; + } + const lineMap = createGitLabDiffLineMap(diff?.diff || ''); + const endLines = lineMap.get(`${comment.side}:${comment.lineNumber}`) || { + ...(comment.side === 'deletions' + ? { oldLine: comment.lineNumber } + : { newLine: comment.lineNumber }), + }; + const position = { + base_sha: targetBaseSha, + head_sha: targetHeadSha, + new_path: newPath, + old_path: oldPath, + position_type: 'text', + start_sha: metadata.diff_refs?.start_sha, + ...(endLines.oldLine ? { old_line: endLines.oldLine } : {}), + ...(endLines.newLine ? { new_line: endLines.newLine } : {}), + }; + if (typeof comment.startLineNumber === 'number') { + const startSide = comment.startSide ?? comment.side; + const startLines = lineMap.get(`${startSide}:${comment.startLineNumber}`) || { + ...(startSide === 'deletions' + ? { oldLine: comment.startLineNumber } + : { newLine: comment.startLineNumber }), + }; + position.line_range = { + end: { + line_code: getGitLabLineCode(newPath, endLines.oldLine, endLines.newLine), + ...(endLines.oldLine ? { old_line: endLines.oldLine } : {}), + ...(endLines.newLine ? { new_line: endLines.newLine } : {}), + type: comment.side === 'deletions' ? 'old' : 'new', + }, + start: { + line_code: getGitLabLineCode(newPath, startLines.oldLine, startLines.newLine), + ...(startLines.oldLine ? { old_line: startLines.oldLine } : {}), + ...(startLines.newLine ? { new_line: startLines.newLine } : {}), + type: startSide === 'deletions' ? 'old' : 'new', + }, + }; + } + return position; +}; + +const findGitLabTargetDiff = (diffs, filePath) => + diffs.find((candidate) => candidate.new_path === filePath || candidate.old_path === filePath); + +/** @param {PullRequestReviewComment} comment @param {any} metadata @param {ReadonlyArray} diffs */ +const resolveGitLabCommentTarget = async (comment, metadata, diffs) => { + const target = { + baseSha: metadata.diff_refs?.base_sha, + files: diffs.map((diff) => ({ + newPath: diff.new_path, + oldPath: diff.old_path, + patch: diff.diff, + })), + headSha: metadata.diff_refs?.head_sha || metadata.sha, + }; + validateCurrentReviewCommentTarget(comment, target); + const diff = findGitLabTargetDiff(diffs, comment.filePath); + if (!diff) { + throw targetResolutionError(`File ${comment.filePath} is not in the target diff.`); + } + return { diff, metadata }; +}; + +/** @param {unknown} event */ +const getGitLabReviewQuickAction = (event) => { + if (event === 'APPROVE') return '/submit_review approve'; + if (event === 'COMMENT') return null; + if (event === 'REQUEST_CHANGES') return '/submit_review request_changes'; + throw new Error(`GitLab merge request reviews do not support ${String(event)}.`); +}; + +/** + * @param {{ + * createTransport: (repoRoot: string, mergeRequest: any) => GitLabMutationTransport, + * getDiscussionReplyEndpoint: (mergeRequest: any, threadId: string) => string, + * mergeRequestEndpoint: (mergeRequest: any, suffix?: string) => string, + * normalizeSubmittedGitLabReviewComment: (note: any, submittedComment: PullRequestReviewComment, url: string, threadId?: string) => import('../../core/types.ts').PullRequestExistingReviewComment | null, + * parseGitLabMergeRequestUrl: (value: string) => any, + * readMergeRequestDiffs: (repoRoot: string, mergeRequest: any, transport?: GitLabMutationTransport) => Promise>, + * readMergeRequestMetadata: (repoRoot: string, mergeRequest: any, transport?: GitLabMutationTransport) => Promise, + * selectMergeRequestRemote: (repoRoot: string, mergeRequest: any) => any, + * }} dependencies + */ +const createGitLabReviewMutations = ({ + createTransport, + getDiscussionReplyEndpoint, + mergeRequestEndpoint, + normalizeSubmittedGitLabReviewComment, + parseGitLabMergeRequestUrl, + readMergeRequestDiffs, + readMergeRequestMetadata, + selectMergeRequestRemote, +}) => { + /** @param {string} launchPath @param {any} request */ + const submitMergeRequestComment = async (launchPath, request) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const mergeRequest = parseGitLabMergeRequestUrl(request.source.url); + selectMergeRequestRemote(repoRoot, mergeRequest); + const transport = createTransport(repoRoot, mergeRequest); + if (request.comment.threadId) { + const note = await transport.request({ + body: { body: request.comment.body }, + method: 'POST', + path: getDiscussionReplyEndpoint(mergeRequest, request.comment.threadId), + }); + const comment = normalizeSubmittedGitLabReviewComment( + note, + request.comment, + mergeRequest.url, + ); + if (!comment) { + throw new Error('GitLab accepted the reply but did not return comment metadata.'); + } + return comment; + } + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest, transport); + const target = await resolveGitLabCommentTarget(request.comment, metadata, diffs); + const discussion = await transport.request({ + body: { + body: request.comment.body, + position: createGitLabPosition(request.comment, target.metadata, target.diff), + }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/discussions'), + }); + const comment = normalizeSubmittedGitLabReviewComment( + discussion.notes?.[0], + request.comment, + mergeRequest.url, + discussion.id, + ); + if (!comment) { + throw new Error('GitLab accepted the comment but did not return comment metadata.'); + } + return comment; + }; + + /** @param {string} launchPath @param {any} request */ + const submitMergeRequestReview = async (launchPath, request) => { + /** @type {Array<{localDraftId: string | null, remoteDraftId: string | null}>} */ + const createdDrafts = []; + /** @type {Array} */ + const submittedDraftIds = []; + /** @type {Array} */ + const outcomeUnknownDraftIds = []; + /** @type {GitLabMutationTransport | null} */ + let transport = null; + let mergeRequest = null; + try { + const summary = typeof request.body === 'string' ? request.body.trim() : ''; + if (request.event === 'COMMENT' && request.comments.length === 0 && !summary) { + throw new Error('A neutral review requires an inline comment or summary.'); + } + const quickAction = getGitLabReviewQuickAction(request.event); + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + mergeRequest = parseGitLabMergeRequestUrl(request.source.url); + selectMergeRequestRemote(repoRoot, mergeRequest); + transport = createTransport(repoRoot, mergeRequest); + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const currentHead = metadata.diff_refs?.head_sha || metadata.sha; + if (!currentHead) { + throw targetResolutionError('GitLab did not return the current merge request head.'); + } + if (request.source.headSha && currentHead !== request.source.headSha) { + throw targetResolutionError('The merge request head changed. Refresh before submitting.'); + } + const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest, transport); + const targets = await Promise.all( + request.comments.map(async (comment) => ({ + comment, + target: await resolveGitLabCommentTarget(comment, metadata, diffs), + })), + ); + for (const { comment, target } of targets) { + const localDraftId = typeof comment.localDraftId === 'string' ? comment.localDraftId : null; + let createdDraft; + try { + createdDraft = await transport.request({ + body: { + note: comment.body, + position: createGitLabPosition(comment, target.metadata, target.diff), + }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/draft_notes'), + }); + } catch (error) { + if (localDraftId) outcomeUnknownDraftIds.push(localDraftId); + throw error; + } + const remoteDraftId = + typeof createdDraft?.id === 'string' || typeof createdDraft?.id === 'number' + ? String(createdDraft.id) + : null; + createdDrafts.push({ localDraftId, remoteDraftId }); + if (!remoteDraftId) { + if (localDraftId) outcomeUnknownDraftIds.push(localDraftId); + throw new Error('GitLab accepted a draft note but did not return its ID.'); + } + } + if (request.event === 'COMMENT') { + await transport.request({ + body: { + ...(summary ? { note: summary } : {}), + reviewer_state: 'reviewed', + }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/draft_notes/bulk_publish'), + }); + } else { + const finalBody = `${summary ? `${summary}\n\n` : ''}${quickAction}`; + await transport.request({ + body: { body: finalBody }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/notes'), + }); + } + submittedDraftIds.push( + ...createdDrafts.flatMap(({ localDraftId }) => (localDraftId ? [localDraftId] : [])), + ); + return { status: /** @type {const} */ ('submitted'), submittedDraftIds }; + } catch (error) { + /** @type {Array} */ + const cleanupErrors = []; + if (transport && mergeRequest) { + for (const { localDraftId, remoteDraftId } of [...createdDrafts].reverse()) { + if (!remoteDraftId) { + if (localDraftId) outcomeUnknownDraftIds.push(localDraftId); + continue; + } + try { + await transport.request({ + method: 'DELETE', + path: mergeRequestEndpoint( + mergeRequest, + `/draft_notes/${encodeURIComponent(remoteDraftId)}`, + ), + }); + } catch (cleanupError) { + cleanupErrors.push( + cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + ); + if (localDraftId) outcomeUnknownDraftIds.push(localDraftId); + } + } + } + const reason = error instanceof Error ? error.message : String(error); + const uniqueOutcomeUnknownDraftIds = [...new Set(outcomeUnknownDraftIds)]; + return { + ...(uniqueOutcomeUnknownDraftIds.length > 0 + ? { outcomeUnknownDraftIds: uniqueOutcomeUnknownDraftIds } + : {}), + reason: + cleanupErrors.length > 0 + ? `${reason} GitLab draft cleanup also failed: ${cleanupErrors.join('; ')}` + : reason, + status: /** @type {const} */ ('failed'), + submittedDraftIds: [], + }; + } + }; + + return { submitMergeRequestComment, submitMergeRequestReview }; +}; + +module.exports = { + createGitLabPosition, + createGitLabReviewMutations, + resolveGitLabCommentTarget, +}; diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index eab58d67..34d18670 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -1,6 +1,5 @@ // @ts-check -const { createHash } = require('node:crypto'); const { getCurrentCommandSignal, git, @@ -9,6 +8,11 @@ const { validateRepositoryPath, } = require('./common.cjs'); const { readGitFiles } = require('./git-files.cjs'); +const { + createGitLabPosition, + createGitLabReviewMutations, + resolveGitLabCommentTarget, +} = require('./gitlab-review-mutations.cjs'); const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); const { normalizeGitHubCommit } = require('./pull-request.cjs'); @@ -397,7 +401,7 @@ const hydrateMergeRequestSections = async ( file, oldFiles.get(oldPath), newFiles.get(file.path), - { base: range.baseSha, contentAttempted: true, head: range.headSha }, + { base: range.baseSha, head: range.headSha }, ), }; }); @@ -408,6 +412,9 @@ const hydrateMergeRequestSections = async ( * @param {ReturnType} mergeRequest * @param {any} metadata * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range + * @param {ArtifactFile} file + * @param {{force?: boolean}} [options] + */ const hydrateMergeRequestSection = async ( repoRoot, mergeRequest, @@ -432,9 +439,6 @@ const hydrateMergeRequestSection = async ( }) ); }; - options = {}, -) => (await hydrateMergeRequestSections(repoRoot, mergeRequest, metadata, range, [file], options))[0] - ?.section; /** @param {string} launchPath @param {Extract} source */ const readMergeRequestSectionsContent = async (launchPath, source) => { @@ -595,178 +599,16 @@ const listMergeRequestHistory = async (launchPath, source, limit = 200) => { }; }; -/** @param {string} path @param {number | undefined} oldLine @param {number | undefined} newLine */ -const getGitLabLineCode = (path, oldLine, newLine) => - `${createHash('sha1').update(path).digest('hex')}_${oldLine || 0}_${newLine || 0}`; - -/** @param {string} diff */ -const createGitLabDiffLineMap = (diff) => { - const lines = new Map(); - let oldLine = 0; - let newLine = 0; - for (const line of diff.split('\n')) { - const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (hunk) { - oldLine = Number(hunk[1]); - newLine = Number(hunk[2]); - } else if (line.startsWith('+') && !line.startsWith('+++')) { - lines.set(`additions:${newLine}`, { newLine }); - newLine += 1; - } else if (line.startsWith('-') && !line.startsWith('---')) { - lines.set(`deletions:${oldLine}`, { oldLine }); - oldLine += 1; - } else if (oldLine > 0 && newLine > 0 && !line.startsWith('\\')) { - const value = { newLine, oldLine }; - lines.set(`additions:${newLine}`, value); - lines.set(`deletions:${oldLine}`, value); - oldLine += 1; - newLine += 1; - } - } - return lines; -}; - -/** @param {PullRequestReviewComment} comment @param {any} metadata @param {any} [diff] */ -const createGitLabPosition = (comment, metadata, diff) => { - const oldPath = diff?.old_path || comment.filePath; - const newPath = diff?.new_path || comment.filePath; - if (comment.anchor === 'file' || comment.lineNumber == null || comment.side == null) { - return { - base_sha: metadata.diff_refs?.base_sha, - head_sha: metadata.diff_refs?.head_sha || metadata.sha, - new_path: newPath, - old_path: oldPath, - position_type: 'file', - start_sha: metadata.diff_refs?.start_sha, - }; - } - - const lineMap = createGitLabDiffLineMap(diff?.diff || ''); - const endLines = lineMap.get(`${comment.side}:${comment.lineNumber}`) || { - ...(comment.side === 'deletions' - ? { oldLine: comment.lineNumber } - : { newLine: comment.lineNumber }), - }; - const position = { - base_sha: metadata.diff_refs?.base_sha, - head_sha: metadata.diff_refs?.head_sha || metadata.sha, - new_path: newPath, - old_path: oldPath, - position_type: 'text', - start_sha: metadata.diff_refs?.start_sha, - ...(endLines.oldLine ? { old_line: endLines.oldLine } : {}), - ...(endLines.newLine ? { new_line: endLines.newLine } : {}), - }; - if (typeof comment.startLineNumber === 'number') { - const startSide = comment.startSide ?? comment.side; - const startLines = lineMap.get(`${startSide}:${comment.startLineNumber}`) || { - ...(startSide === 'deletions' - ? { oldLine: comment.startLineNumber } - : { newLine: comment.startLineNumber }), - }; - position.line_range = { - end: { - line_code: getGitLabLineCode(newPath, endLines.oldLine, endLines.newLine), - ...(endLines.oldLine ? { old_line: endLines.oldLine } : {}), - ...(endLines.newLine ? { new_line: endLines.newLine } : {}), - type: comment.side === 'deletions' ? 'old' : 'new', - }, - start: { - line_code: getGitLabLineCode(newPath, startLines.oldLine, startLines.newLine), - ...(startLines.oldLine ? { old_line: startLines.oldLine } : {}), - ...(startLines.newLine ? { new_line: startLines.newLine } : {}), - type: startSide === 'deletions' ? 'old' : 'new', - }, - }; - } - return position; -}; - -/** @param {unknown} event */ -const getGitLabReviewQuickAction = (event) => { - if (event === 'APPROVE') { - return '/submit_review approve'; - } - if (event === 'REQUEST_CHANGES') { - return '/submit_review request_changes'; - } - throw new Error(`GitLab merge request reviews do not support ${String(event)}.`); -}; - -/** @param {string} launchPath @param {any} request */ -const submitMergeRequestComment = async (launchPath, request) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const mergeRequest = parseGitLabMergeRequestUrl(request.source.url); - selectMergeRequestRemote(repoRoot, mergeRequest); - const transport = createMergeRequestTransport(repoRoot, mergeRequest); - if (request.comment.threadId) { - const note = await transport.request({ - body: { body: request.comment.body }, - method: 'POST', - path: getGitLabDiscussionReplyEndpoint(mergeRequest, request.comment.threadId), - }); - const comment = normalizeSubmittedGitLabReviewComment( - note, - request.comment, - mergeRequest.url, - request.comment.threadId, - ); - if (!comment) { - throw new Error('GitLab accepted the reply but did not return comment metadata.'); - } - return comment; - } - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); - const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest, transport); - const diff = diffs.find((candidate) => candidate.new_path === request.comment.filePath); - const discussion = await transport.request({ - body: { - body: request.comment.body, - position: createGitLabPosition(request.comment, metadata, diff), - }, - method: 'POST', - path: mergeRequestEndpoint(mergeRequest, '/discussions'), - }); - const comment = normalizeSubmittedGitLabReviewComment( - discussion.notes?.[0], - request.comment, - mergeRequest.url, - discussion.id, - ); - if (!comment) { - throw new Error('GitLab accepted the comment but did not return comment metadata.'); - } - return comment; -}; - -/** @param {string} launchPath @param {any} request */ -const submitMergeRequestReview = async (launchPath, request) => { - const quickAction = getGitLabReviewQuickAction(request.event); - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const mergeRequest = parseGitLabMergeRequestUrl(request.source.url); - selectMergeRequestRemote(repoRoot, mergeRequest); - const transport = createMergeRequestTransport(repoRoot, mergeRequest); - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); - const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest, transport); - for (const comment of request.comments) { - const diff = diffs.find((candidate) => candidate.new_path === comment.filePath); - await transport.request({ - body: { - note: comment.body, - position: createGitLabPosition(comment, metadata, diff), - }, - method: 'POST', - path: mergeRequestEndpoint(mergeRequest, '/draft_notes'), - }); - } - await transport.request({ - body: { - body: `${request.body ? `${request.body}\n\n` : ''}${quickAction}`, - }, - method: 'POST', - path: mergeRequestEndpoint(mergeRequest, '/notes'), - }); -}; +const { submitMergeRequestComment, submitMergeRequestReview } = createGitLabReviewMutations({ + createTransport: createMergeRequestTransport, + getDiscussionReplyEndpoint: getGitLabDiscussionReplyEndpoint, + mergeRequestEndpoint, + normalizeSubmittedGitLabReviewComment, + parseGitLabMergeRequestUrl, + readMergeRequestDiffs, + readMergeRequestMetadata, + selectMergeRequestRemote, +}); /** @param {string} launchPath @param {Extract} source @param {string} requestedPath */ const readMergeRequestImageContent = async (launchPath, source, requestedPath) => { @@ -804,6 +646,7 @@ module.exports = { listMergeRequestHistory, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, + resolveGitLabCommentTarget, readMergeRequestImageContent, readMergeRequestReviewComments, readMergeRequestSectionContent, diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index d7e7f4f5..e3af7a0a 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -22,6 +22,11 @@ const { runGhApi, runGhApiBuffer, } = require('./github-history/gh-github-transport.cjs'); +const { + PENDING_REVIEW_COMMENT_ERROR, + createGitHubReviewMutations, + normalizePullRequestComment, +} = require('./github-review-mutations.cjs'); const { loadGitHubHistory } = require('../github-history-bridge.cjs'); const { parseReviewUrl } = require('../review-source.cjs'); @@ -29,11 +34,8 @@ const { parseReviewUrl } = require('../review-source.cjs'); * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('../../core/types.ts').HistoryEntry} HistoryEntry - * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource - * @typedef {import('../../core/types.ts').SubmitPullRequestCommentRequest} SubmitPullRequestCommentRequest - * @typedef {import('../../core/types.ts').SubmitPullRequestReviewRequest} SubmitPullRequestReviewRequest * @typedef {import('../../core/lib/review-artifacts.ts').ArtifactFile} ArtifactFile * @typedef {{owner: string; repo: string}} GitHubRepositoryReference * @typedef {{name: string; url: string}} LocalGitRemote @@ -42,6 +44,7 @@ const { parseReviewUrl } = require('../review-source.cjs'); * @typedef {{direction: 'fetch' | 'push'; name: string; owner: string; repo: string}} GitHubRemote * @typedef {{base?: {ref?: string; repo?: GitHubRepositoryMetadata | null; sha?: string}; body?: string | null; head?: {ref?: string; repo?: GitHubRepositoryMetadata | null; sha?: string}; title?: string; user?: {avatar_url?: string; html_url?: string; login?: string}}} GitHubPullRequestMetadata * @typedef {{author?: {avatar_url?: string}; commit?: {author?: {date?: string; email?: string; name?: string}; message?: string}; parents?: ReadonlyArray<{sha?: string}>; sha?: string}} GitHubCommit + * @typedef {{merge_base_commit?: {sha?: string}} | null} GitHubComparison * @typedef {{[key: string]: any}} GitHubReviewComment * @typedef {{comments?: {nodes?: ReadonlyArray<{databaseId?: number | null}>} | null; isResolved?: boolean}} GitHubReviewThread */ @@ -156,22 +159,6 @@ const readLocalGitRemotes = async (repoRoot) => { return remotes.filter((remote) => remote != null); }; -/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ -const assertPullRequestMatchesRepository = async (repoRoot, pullRequest) => { - const matchesRepository = (await readLocalGitRemotes(repoRoot)).some(({ url }) => { - const repository = parseGitHubRemoteUrl(url) ?? parseRemoteRepositoryPath(url); - return ( - repository?.owner.toLowerCase() === pullRequest.owner.toLowerCase() && - repository.repo.toLowerCase() === pullRequest.repo.toLowerCase() - ); - }); - if (!matchesRepository) { - throw new Error( - `Pull request ${pullRequest.owner}/${pullRequest.repo} does not match a GitHub remote in this repository.`, - ); - } -}; - /** @param {LocalGitRemote} remote */ const getRemotePriority = (remote) => (remote.name === 'origin' ? 0 : 1); @@ -241,6 +228,11 @@ const selectPullRequestRemote = async (repoRoot, pullRequest, expectedHeadSha) = ); }; +/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ +const assertPullRequestMatchesRepository = async (repoRoot, pullRequest) => { + await selectPullRequestRemote(repoRoot, pullRequest); +}; + /** @param {PullRequestReference} pullRequest @param {GitHubPullRequestMetadata} metadata */ const createPullRequestHistoryFetchRefspecs = (pullRequest, metadata) => [ `+refs/pull/${pullRequest.number}/head:refs/codiff/pull-requests/${pullRequest.number}/head`, @@ -296,6 +288,45 @@ const readPullRequestMetadata = (repoRoot, pullRequest, transport) => path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, }); +/** + * @param {string} repoRoot + * @param {PullRequestReference} pullRequest + * @param {ReturnType} transport + */ +const readCurrentPullRequestTarget = async (repoRoot, pullRequest, transport) => { + const metadata = await readPullRequestMetadata(repoRoot, pullRequest, transport); + const baseSha = metadata.base?.sha; + const headSha = metadata.head?.sha; + if (!baseSha || !headSha) { + throw new Error('GitHub did not return complete pull request range coordinates.'); + } + const [comparison, files] = await Promise.all([ + /** @type {Promise} */ ( + transport.request({ + path: `repos/${pullRequest.owner}/${pullRequest.repo}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`, + }) + ), + transport.request({ + paginate: true, + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/files`, + query: { per_page: 100 }, + }), + ]); + const mergeBaseSha = comparison?.merge_base_commit?.sha; + if (!mergeBaseSha) { + throw new Error('GitHub did not return the current pull request merge base.'); + } + return { + baseSha: mergeBaseSha, + files: files.map((file) => ({ + newPath: file.filename, + ...(file.previous_filename ? { oldPath: file.previous_filename } : {}), + ...(file.patch ? { patch: file.patch } : {}), + })), + headSha, + }; +}; + /** @param {string} repoRoot @param {PullRequestReference} pullRequest */ const pullRequestHydrationSnapshotKey = (repoRoot, pullRequest) => `${repoRoot}:${pullRequest.url}`; @@ -440,7 +471,26 @@ const firstNumber = (...values) => values.find((value) => typeof value === 'numb /** @param {GitHubReviewComment} comment */ const normalizeGitHubReviewComment = (comment) => { const lineNumber = firstNumber(comment.line, comment.original_line); - if (lineNumber == null || !comment.path || !comment.body) { + if (!comment.path || !comment.body) { + return null; + } + if (lineNumber == null && comment.subject_type === 'file') { + return { + anchor: 'file', + author: { + avatarUrl: comment.user?.avatar_url, + login: comment.user?.login || 'GitHub user', + url: comment.user?.html_url, + }, + body: comment.body, + filePath: comment.path, + id: `github:${comment.id}`, + threadId: String(comment.in_reply_to_id || comment.id), + submittedAt: comment.created_at, + url: comment.html_url, + }; + } + if (lineNumber == null) { return null; } @@ -823,7 +873,7 @@ const hydratePullRequestSections = async ( file, oldFiles.get(oldPath), newFiles.get(file.path), - { base: range.baseSha, contentAttempted: true, head: range.headSha }, + { base: range.baseSha, head: range.headSha }, ), }; }); @@ -994,130 +1044,13 @@ const readPullRequestImageContent = async (launchPath, source, requestedPath) => } }; -/** @param {PullRequestReviewComment['side']} side */ -const toGitHubReviewSide = (side) => (side === 'deletions' ? 'LEFT' : 'RIGHT'); - -/** @param {PullRequestReviewComment} comment */ -const normalizePullRequestComment = (comment) => { - /** @type {{body: string; line: number; path: string; side: string; start_line?: number; start_side?: string}} */ - const payload = { - body: comment.body, - line: comment.lineNumber, - path: comment.filePath, - side: toGitHubReviewSide(comment.side), - }; - const startSide = comment.startSide ?? comment.side; - if ( - typeof comment.startLineNumber === 'number' && - comment.startLineNumber !== comment.lineNumber - ) { - payload.start_line = comment.startLineNumber; - payload.start_side = toGitHubReviewSide(startSide); - } - return payload; -}; - -const PENDING_REVIEW_COMMENT_ERROR = - 'You already have a pending GitHub review on this pull request. Submit or discard it on GitHub, then retry. Your comment draft is still here.'; - -/** @param {unknown} error */ -const isGitHubValidationError = (error) => - error instanceof Error && /(?:validation failed|http 422)/i.test(error.message); - -/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ -const hasPendingPullRequestReview = async (repoRoot, pullRequest) => { - const pages = JSON.parse( - await ghApi(repoRoot, [ - '--paginate', - '--slurp', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews?per_page=100`, - ]), - ); - return Array.isArray(pages) && pages.flat().some((review) => review?.state === 'PENDING'); -}; - -/** @param {string} launchPath @param {SubmitPullRequestCommentRequest} request */ -const submitPullRequestComment = async (launchPath, request) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const pullRequest = parseGitHubPullRequestUrl(request.source.url); - const metadata = await readPullRequestMetadata(repoRoot, pullRequest); - await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - const replyTo = request.comment.threadId ? Number(request.comment.threadId) : null; - if (request.comment.threadId && (!Number.isInteger(replyTo) || replyTo <= 0)) { - throw new Error('GitHub review replies require a numeric provider thread ID.'); - } - const payload = replyTo - ? { body: request.comment.body, in_reply_to: replyTo } - : { - ...normalizePullRequestComment(request.comment), - commit_id: metadata.head?.sha, - }; - - const rawComment = await ghApi( - repoRoot, - [ - '-X', - 'POST', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/comments`, - '--input', - '-', - ], - payload, - ).catch(async (error) => { - if (isGitHubValidationError(error)) { - const hasPendingReview = await hasPendingPullRequestReview(repoRoot, pullRequest).catch( - () => false, - ); - if (hasPendingReview) { - throw new Error(PENDING_REVIEW_COMMENT_ERROR); - } - } - throw error; - }); - const comment = normalizeGitHubReviewComment(JSON.parse(rawComment)); - if (!comment) { - throw new Error('GitHub accepted the comment but did not return line metadata.'); - } - return comment; -}; - -/** @param {string} launchPath @param {SubmitPullRequestReviewRequest} request */ -const submitPullRequestReview = async (launchPath, request) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const pullRequest = parseGitHubPullRequestUrl(request.source.url); - const metadata = await readPullRequestMetadata(repoRoot, pullRequest); - await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - - await ghApi( - repoRoot, - [ - '-X', - 'POST', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews`, - '--input', - '-', - ], - createPullRequestReviewPayload(request), - ); -}; - -/** @param {SubmitPullRequestReviewRequest} request */ -const createPullRequestReviewPayload = (request) => { - const body = request.body?.trim() || ''; - if (request.event === 'COMMENT' && request.comments.length === 0 && !body) { - throw new Error('A comment review requires an inline comment or a review comment.'); - } - - return { - body: - body || - (request.event === 'REQUEST_CHANGES' && request.comments.length === 0 - ? 'Requesting changes.' - : ''), - comments: request.comments.map(normalizePullRequestComment), - event: request.event, - }; -}; +const { submitPullRequestComment, submitPullRequestReview } = createGitHubReviewMutations({ + assertPullRequestMatchesRepository, + createTransport: createPullRequestTransport, + normalizeGitHubReviewComment, + parseGitHubPullRequestUrl, + readCurrentTarget: readCurrentPullRequestTarget, +}); module.exports = { PENDING_REVIEW_COMMENT_ERROR, diff --git a/electron/git-state/review-comment-target.cjs b/electron/git-state/review-comment-target.cjs new file mode 100644 index 00000000..c2411337 --- /dev/null +++ b/electron/git-state/review-comment-target.cjs @@ -0,0 +1,108 @@ +// @ts-check + +/** + * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment + * @typedef {{newPath: string, oldPath?: string, patch?: string}} ReviewTargetFile + * @typedef {{baseSha?: string, files: ReadonlyArray, headSha?: string}} CurrentReviewTarget + */ + +/** @param {string} reason */ +const targetResolutionError = (reason) => + Object.assign(new Error(`target-resolution-failed: ${reason}`), { + code: 'target-resolution-failed', + }); + +/** @param {any} revision */ +const rangeCommitSha = (revision) => + revision && + (revision.kind == null || revision.kind === 'commit') && + typeof revision.sha === 'string' + ? revision.sha + : null; + +/** @param {string} patch */ +const createDiffLineMap = (patch) => { + const lines = new Set(); + let oldLine = 0; + let newLine = 0; + for (const line of patch.split('\n')) { + const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + oldLine = Number(hunk[1]); + newLine = Number(hunk[2]); + } else if (line.startsWith('+') && !line.startsWith('+++')) { + lines.add(`additions:${newLine}`); + newLine += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + lines.add(`deletions:${oldLine}`); + oldLine += 1; + } else if (oldLine > 0 && newLine > 0 && line.startsWith(' ')) { + lines.add(`additions:${newLine}`); + lines.add(`deletions:${oldLine}`); + oldLine += 1; + newLine += 1; + } + } + return lines; +}; + +/** @param {ReviewTargetFile} file @param {string} filePath */ +const targetFileMatches = (file, filePath) => + file.newPath === filePath || file.oldPath === filePath; + +/** + * Validate every immutable coordinate before a provider mutation is attempted. + * Replies target an existing provider thread and are validated by that thread + * endpoint instead of by a new diff position. + * + * @param {PullRequestReviewComment} comment + * @param {CurrentReviewTarget} target + * @returns {ReviewTargetFile | undefined} + */ +const validateCurrentReviewCommentTarget = (comment, target) => { + if (comment.threadId) { + return undefined; + } + const range = comment.position?.range; + const baseSha = rangeCommitSha(range?.base); + const headSha = rangeCommitSha(range?.head); + if (!baseSha || !headSha) { + throw targetResolutionError('The draft has no immutable review range.'); + } + if (!target.baseSha || !target.headSha) { + throw targetResolutionError('The provider did not return a complete current review range.'); + } + if (baseSha !== target.baseSha || headSha !== target.headSha) { + throw targetResolutionError( + 'The draft range no longer matches the current review. Refresh before submitting.', + ); + } + const file = target.files.find((candidate) => targetFileMatches(candidate, comment.filePath)); + if (!file) { + throw targetResolutionError(`File ${comment.filePath} is not in the target diff.`); + } + if (comment.anchor === 'file' || comment.lineNumber == null || comment.side == null) { + return file; + } + const lineMap = createDiffLineMap(file.patch || ''); + if (!lineMap.has(`${comment.side}:${comment.lineNumber}`)) { + throw targetResolutionError( + `Line ${comment.lineNumber} on the ${comment.side} side is not in the target diff.`, + ); + } + if (typeof comment.startLineNumber === 'number') { + const startSide = comment.startSide ?? comment.side; + if (!lineMap.has(`${startSide}:${comment.startLineNumber}`)) { + throw targetResolutionError( + `Range start ${comment.startLineNumber} on the ${startSide} side is not in the target diff.`, + ); + } + } + return file; +}; + +module.exports = { + createDiffLineMap, + targetResolutionError, + validateCurrentReviewCommentTarget, +}; diff --git a/electron/main.cjs b/electron/main.cjs index f204f038..c16173b5 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1984,7 +1984,15 @@ ipcMain.handle('codiff:submitPullRequestComment', async (event, request) => { ipcMain.handle('codiff:submitPullRequestReview', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return submitPullRequestReview(repositoryPath, request); + try { + return await submitPullRequestReview(repositoryPath, request); + } catch (error) { + return { + reason: error instanceof Error ? error.message : String(error), + status: 'failed', + submittedDraftIds: [], + }; + } }); ipcMain.handle('codiff:getDiffSectionContent', async (event, request) => { From 97dd139f36e78c7fceeccae94ca72fef16b6ec2b Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 13:42:06 -0500 Subject: [PATCH 12/17] Share one PR description header across Tree, Walkthrough, and Comments Build the commit or PR/MR title, body, author, and editability once for Tree, Walkthrough, and Comments. Store collapse state under `source-description:${provider}:${url}:${headSha}` so mode switches preserve it and a new PR head starts expanded. --- core/App.css | 31 ++- core/ReviewSurface.tsx | 52 +++- .../ReviewSurface-capabilities.test.tsx | 169 ++++++++++++ core/__tests__/source-description.test.ts | 106 ++++++++ core/app/components/ReviewCodeView.tsx | 257 ++++++++++-------- .../walkthrough/WalkthroughDiffSurface.tsx | 5 + core/lib/source-description.ts | 88 ++++++ 7 files changed, 573 insertions(+), 135 deletions(-) create mode 100644 core/__tests__/source-description.test.ts create mode 100644 core/lib/source-description.ts diff --git a/core/App.css b/core/App.css index dab90a47..2e9ce50c 100644 --- a/core/App.css +++ b/core/App.css @@ -2009,23 +2009,28 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 0 8px 8px 50px; } -.codiff-source-description-footer-row { - align-items: stretch; +.codiff-source-description-overview { + align-items: start; display: grid; - gap: 8px; - grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); + gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(340px, 380px); + min-width: 0; +} + +.codiff-source-description-overview-main, +.codiff-source-description-overview-aside { + min-width: 0; } -.codiff-source-description-footer-main, -.codiff-source-description-footer-aside { +.codiff-source-description-overview-aside { display: flex; flex-direction: column; - min-width: 0; + gap: 8px; + padding: 8px 8px 8px 0; } -.codiff-source-description-footer-main > *, -.codiff-source-description-footer-aside > * { - flex: 1; +.codiff-source-description-overview-aside > * { + min-width: 0; } .source-description-comment-anonymous + .codiff-source-description-footer { @@ -2235,10 +2240,14 @@ html[data-codiff-platform='darwin'] .sidebar { } @media (max-width: 1024px) { - .codiff-source-description-footer-row { + .codiff-source-description-overview { grid-template-columns: minmax(0, 1fr); } + .codiff-source-description-overview-aside { + padding: 0 8px 8px; + } + .pull-request-merge-controls, .pull-request-merge-actions { justify-content: flex-start; diff --git a/core/ReviewSurface.tsx b/core/ReviewSurface.tsx index ea0a7308..30a784cc 100644 --- a/core/ReviewSurface.tsx +++ b/core/ReviewSurface.tsx @@ -118,6 +118,7 @@ import { readSidebarWidth, writeSidebarWidth, } from './lib/sidebar-width.ts'; +import { buildSourceDescriptionModel } from './lib/source-description.ts'; import { getEmptySourceDetail, getEmptySourceTitle, @@ -1682,6 +1683,34 @@ export function ReviewSurface({ const diffLineHeight = getCodeFontLineHeight( normalizeCodeFontSizePreference(snapshot.preferences.codeFontSize), ); + const source = snapshot.repository.source; + const sourceDescriptionModel = useMemo( + () => + buildSourceDescriptionModel({ + commitMetadata: snapshot.commitMetadata ?? null, + source, + }), + [snapshot.commitMetadata, source], + ); + const [sourceDescriptionCollapsedByIdentity, setSourceDescriptionCollapsedByIdentity] = useState< + Readonly> + >({}); + const sourceDescriptionCollapsed = sourceDescriptionModel + ? (sourceDescriptionCollapsedByIdentity[sourceDescriptionModel.identity] ?? + sourceDescriptionModel.defaultCollapsed) + : false; + const changeSourceDescriptionCollapsed = useCallback( + (collapsed: boolean) => { + if (!sourceDescriptionModel) { + return; + } + setSourceDescriptionCollapsedByIdentity((current) => ({ + ...current, + [sourceDescriptionModel.identity]: collapsed, + })); + }, + [sourceDescriptionModel], + ); const commonReviewProps = { activeSearchMatch: activeDiffSearchMatch, agentId: snapshot.walkthrough.agent, @@ -1719,6 +1748,7 @@ export function ReviewSurface({ onResolveThread: resolveDiscussion ?? noop, onSaveCommentEdit: updateExistingReviewComment, onSelectPathFromScroll: noop, + onSourceDescriptionCollapsedChange: changeSourceDescriptionCollapsed, onSubmitComment: submitComment, onToggleCollapsed: toggleCollapsed, onToggleViewed: toggleViewed, @@ -1730,12 +1760,12 @@ export function ReviewSurface({ searchQuery: diffSearchQuery, showWhitespace: snapshot.preferences.showWhitespace, source: snapshot.repository.source, + sourceDescriptionCollapsed, supportsReviewCommentActions: submitReviewComment != null, theme: snapshot.preferences.theme, viewed, wordWrap, }; - const source = snapshot.repository.source; const showDesktopCommitButton = sidebarMode === 'tree' && source.type === 'working-tree' && @@ -1794,21 +1824,15 @@ export function ReviewSurface({ onMergePullRequest={sourceNavigation?.onMergePullRequest ? mergePullRequest : undefined} /> ) : undefined; - const sourceDescriptionFooter = - sourceDescriptionFooterMain && sourceDescriptionFooterAside ? ( -
-
{sourceDescriptionFooterMain}
-
{sourceDescriptionFooterAside}
-
- ) : ( - (sourceDescriptionFooterMain ?? sourceDescriptionFooterAside) - ); const sourceDescription = source.type === 'pull-request' ? ( ); }; @@ -2226,7 +2251,8 @@ export function ReviewSurface({ scrollTarget={treeScrollTarget} selectedPath={visibleSelectedPath} sourceDescriptionActions={sourceDescriptionActions} - sourceDescriptionFooter={sourceDescriptionFooter} + sourceDescriptionFooter={sourceDescriptionFooterMain} + sourceDescriptionFooterAside={sourceDescriptionFooterAside} walkthroughNotes={emptyWalkthroughNotes} /> ) diff --git a/core/__tests__/ReviewSurface-capabilities.test.tsx b/core/__tests__/ReviewSurface-capabilities.test.tsx index 9aa20ffb..d24a66bb 100644 --- a/core/__tests__/ReviewSurface-capabilities.test.tsx +++ b/core/__tests__/ReviewSurface-capabilities.test.tsx @@ -473,6 +473,175 @@ test('dispatches neutral GitLab review sessions with pending drafts and summarie }); }); + +test('keeps title-only source footer actions reachable', async () => { + const onMergePullRequest = vi.fn(async () => {}); + const source = { + ...providerSnapshot.repository.source, + description: undefined, + mergeState: { + autoMergeEnabled: false, + canCancelAutoMerge: false, + canMerge: true, + canSetAutoMerge: false, + checks: [], + forceRemoveSourceBranch: false, + options: { removeSourceBranch: false, squash: false }, + sha: 'a'.repeat(40), + status: 'ready' as const, + statusLabel: 'Ready to merge', + }, + title: 'Title-only source', + }; + await using view = await renderSurface({ + capabilities: { sourceNavigation: { onMergePullRequest } }, + initialMode: 'tree', + snapshot: { + ...providerSnapshot, + repository: { ...providerSnapshot.repository, source }, + walkthrough: { ...providerSnapshot.walkthrough, source }, + }, + }); + + expect(view.container.textContent).toContain('Title-only source'); + expect(view.container.querySelector('button[aria-label="Expand description"]')).toBeNull(); + const merge = findButton(view.container, 'Merge'); + expect(merge).not.toBeUndefined(); + await act(async () => merge?.click()); + expect(onMergePullRequest).toHaveBeenCalledWith({ + autoMerge: false, + removeSourceBranch: false, + squash: false, + }); +}); + +test('keeps provider source-description collapse state across Tree and Comments', async () => { + const richSnapshot = { + ...providerSnapshot, + repository: { + ...providerSnapshot.repository, + source: { + ...providerSnapshot.repository.source, + author: { login: 'ada', name: 'Ada Lovelace' }, + description: 'Shared source-description body.', + headSha: 'a'.repeat(40) as HistoryEntry['sha'], + title: 'Shared source-description title', + }, + }, + walkthrough: { + ...providerSnapshot.walkthrough, + source: { + ...providerSnapshot.walkthrough.source, + author: { login: 'ada', name: 'Ada Lovelace' }, + description: 'Shared source-description body.', + headSha: 'a'.repeat(40) as HistoryEntry['sha'], + title: 'Shared source-description title', + }, + }, + } satisfies SharedWalkthroughSnapshot; + await using view = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'tree', + snapshot: richSnapshot, + }); + + const collapse = view.container.querySelector( + 'button[aria-label="Collapse description"]', + ); + expect(collapse).not.toBeNull(); + await act(async () => collapse?.click()); + expect(view.container.querySelector('button[aria-label="Expand description"]')).not.toBeNull(); + + await act(async () => findButton(view.container, 'Comments')?.click()); + expect(view.container.querySelector('button[aria-label="Expand description"]')).not.toBeNull(); + expect(view.container.textContent).toContain('Shared source-description title'); + expect(view.container.textContent).not.toContain('Shared source-description body.'); + + await act(async () => findButton(view.container, 'Tree')?.click()); + expect(view.container.querySelector('button[aria-label="Expand description"]')).not.toBeNull(); +}); + +test('renders the same source-description semantics once in Tree, Walkthrough, and Comments', async () => { + const file = providerSnapshot.files[0]!; + const source = { + ...providerSnapshot.repository.source, + author: { login: 'ada', name: 'Ada Lovelace' }, + description: 'Cross-mode source-description body.', + headSha: 'b'.repeat(40) as HistoryEntry['sha'], + title: 'Cross-mode source-description title', + }; + const walkthrough = { + ...providerSnapshot.walkthrough, + chapters: [ + { + blurb: 'Review the source overview.', + icon: 'gear' as const, + id: 'overview', + stops: [ + { + added: 1, + deleted: 1, + hunkIds: [`${file.sections[0]!.id}:h1`], + hunks: [ + { + added: 1, + anchor: { + display: file.path, + sectionId: file.sections[0]!.id, + side: 'both' as const, + }, + deleted: 1, + id: `${file.sections[0]!.id}:h1`, + path: file.path, + status: file.status, + }, + ], + id: 'overview-stop', + importance: 'critical' as const, + prose: 'Review the source overview.', + title: 'Overview stop', + }, + ], + title: 'Overview', + }, + ], + source, + } satisfies NarrativeWalkthrough; + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + reviewSession: { + drafts: { onChange: vi.fn(), value: [] }, + submit: async () => ({ status: 'submitted', submittedDraftIds: [] }), + }, + }), + }, + initialMode: 'walkthrough', + snapshot: { + ...providerSnapshot, + repository: { ...providerSnapshot.repository, source }, + walkthrough, + }, + }); + const assertSourceDescriptionOnce = () => { + expect(view.container.textContent?.match(/Cross-mode source-description title/g)).toHaveLength( + 1, + ); + expect(view.container.textContent?.match(/Cross-mode source-description body\./g)).toHaveLength( + 1, + ); + expect(view.container.textContent?.match(/Ada Lovelace/g)).toHaveLength(1); + expect(view.container.textContent?.match(/Approve/g)).toHaveLength(1); + }; + + await waitFor(assertSourceDescriptionOnce); + await act(async () => findButton(view.container, 'Tree')?.click()); + await waitFor(assertSourceDescriptionOnce); + await act(async () => findButton(view.container, 'Comments')?.click()); + await waitFor(assertSourceDescriptionOnce); +}); + +>>>>>>> conflict 1 of 1 ends test('copies local notes with the local label and Markdown heading', async () => { const file = snapshot.files[0]!; const draft = { diff --git a/core/__tests__/source-description.test.ts b/core/__tests__/source-description.test.ts new file mode 100644 index 00000000..54b62a96 --- /dev/null +++ b/core/__tests__/source-description.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from 'vite-plus/test'; +import { buildSourceDescriptionModel } from '../lib/source-description.ts'; +import type { CommitMetadata, GitSha } from '../types.ts'; + +const gitSha = (character: string) => character.repeat(40) as GitSha; +const person = { + date: '2026-08-06T00:00:00.000Z', + email: 'ada@example.com', + name: 'Ada Lovelace', +}; +const commitMetadata = { + author: person, + body: '', + committer: person, + files: [], + parentShas: [], + refs: [], + sha: gitSha('a'), + shortSha: 'aaaaaaa', + signature: { status: 'unsigned' }, + stats: { + additions: 0, + binaryFiles: 0, + deletions: 0, + files: 0, + renamedFiles: 0, + }, + subject: 'Preserve commit context', + trailers: [], +} satisfies CommitMetadata; + +test('builds commit, missing, title-only, bodyless, and editable source-description models', () => { + expect( + buildSourceDescriptionModel({ + commitMetadata, + source: { sha: gitSha('a'), type: 'commit' }, + }), + ).toMatchObject({ + author: { displayName: 'Ada Lovelace', title: 'ada@example.com' }, + body: '', + defaultCollapsed: false, + kind: 'commit', + label: 'Commit', + title: 'Preserve commit context', + }); + + expect( + buildSourceDescriptionModel({ + commitMetadata: null, + source: { + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/1', + }, + }), + ).toBeNull(); + + expect( + buildSourceDescriptionModel({ + commitMetadata: null, + source: { + provider: 'gitlab', + title: 'Title only', + type: 'pull-request', + url: 'https://gitlab.example.com/example/repo/-/merge_requests/1', + }, + }), + ).toMatchObject({ + body: '', + defaultCollapsed: false, + kind: 'pull-request', + label: 'MR description', + title: 'Title only', + }); + + expect( + buildSourceDescriptionModel({ + commitMetadata: null, + source: { + canEditDescription: true, + description: '', + provider: 'github', + title: 'Editable title', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/2', + }, + }), + ).toMatchObject({ allowsBodyEdit: true, defaultCollapsed: false }); +}); + +test('keys provider models to immutable head identity', () => { + const source = { + description: 'Body', + headSha: gitSha('a'), + provider: 'github' as const, + title: 'Title', + type: 'pull-request' as const, + url: 'https://github.com/example/repo/pull/3', + }; + const first = buildSourceDescriptionModel({ commitMetadata: null, source }); + const second = buildSourceDescriptionModel({ + commitMetadata: null, + source: { ...source, headSha: gitSha('b') }, + }); + expect(first?.identity).not.toBe(second?.identity); +}); diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index 9d0980a3..ce506dc7 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -110,6 +110,10 @@ import { import { getReviewIdentity, isReviewIdentityViewed } from '../../lib/review-identity.ts'; import { applySearchHighlights } from '../../lib/search-highlights.ts'; import { getSourceKey } from '../../lib/source.ts'; +import { + buildSourceDescriptionModel, + type SourceDescriptionAuthor, +} from '../../lib/source-description.ts'; import type { ChangedFile, CodiffPreferences, @@ -124,7 +128,6 @@ import type { PullRequestCodeQualityFinding, PullRequestExistingReviewComment, ResolvedReviewSource, - ReviewAuthor, ReviewSource, } from '../../types.ts'; import { Avatar } from './Avatar.tsx'; @@ -506,27 +509,6 @@ function ReadOnlyMarkdown({ ); } -const getPullRequestDescriptionLabel = (source: Extract) => - source.provider === 'github' - ? 'PR description' - : source.provider === 'gitlab' - ? 'MR description' - : 'Description'; -type SourceDescriptionAuthor = { - avatarUrl?: string; - displayName: string; - title?: string; -}; -const getPullRequestDescriptionAuthor = (author: ReviewAuthor): SourceDescriptionAuthor => ({ - avatarUrl: author.avatarUrl, - displayName: author.name || `@${author.login}`, - title: `@${author.login}`, -}); -const getCommitDescriptionAuthor = (author: CommitMetadata['author']): SourceDescriptionAuthor => ({ - avatarUrl: author.gravatarUrl, - displayName: author.name || author.email || 'Unknown author', - title: author.email || undefined, -}); const htmlCommentPattern = //g; const stripHtmlComments = (value: string) => value.replaceAll(htmlCommentPattern, ''); type PullRequestSource = Extract; @@ -962,62 +944,96 @@ function SourceDescriptionBody({ export function PullRequestSourceDescription({ actions, + collapsed, footer, + footerAside, keymap, + onCollapsedChange, onUpdateDescription, onUpdateTitle, onUploadDescriptionAsset, source, }: { actions?: ReactNode; + collapsed?: boolean; footer?: ReactNode; + footerAside?: ReactNode; keymap?: CodiffKeymap; + onCollapsedChange?: (collapsed: boolean) => void; onUpdateDescription?: (body: string) => Promise | void; onUpdateTitle?: (title: string) => Promise | void; onUploadDescriptionAsset?: (file: File) => Promise | string; source: PullRequestSource; }) { - const sourceDescription = source.description?.trim() ?? ''; - const sourceTitle = source.title?.trim() ?? ''; - const sourceDescriptionHasBody = sourceDescription.length > 0; - const sourceAuthor = source.author ? getPullRequestDescriptionAuthor(source.author) : undefined; - const canEditDescription = source.canEditDescription === true && onUpdateDescription != null; - const canEditTitle = - (source.canEditTitle === true || source.canEditDescription === true) && onUpdateTitle != null; - const [collapsed, setCollapsed] = useState(false); - - if (!sourceDescription && !sourceTitle) { + const model = buildSourceDescriptionModel({ commitMetadata: null, source }); + const [collapseState, setCollapseState] = useState(() => ({ + collapsed: model?.defaultCollapsed ?? false, + identity: model?.identity ?? '', + })); + if (!model) { return null; } - - const isCollapsed = (!sourceDescriptionHasBody && !canEditDescription) || collapsed; - const layoutKey = `source-description-panel:${source.provider ?? ''}:${source.url}:${sourceTitle}:${sourceDescription}:${source.author?.login ?? ''}:${source.author?.avatarUrl ?? ''}:${isCollapsed ? 'collapsed' : 'open'}`; + const canEditDescription = model.allowsBodyEdit && onUpdateDescription != null; + const canEditTitle = model.allowsTitleEdit && onUpdateTitle != null; + const uncontrolledCollapsed = + collapseState.identity === model.identity ? collapseState.collapsed : model.defaultCollapsed; + const isCollapsed = collapsed ?? uncontrolledCollapsed; + const toggleCollapsed = () => { + const next = !isCollapsed; + if (onCollapsedChange) { + onCollapsedChange(next); + } else { + setCollapseState({ collapsed: next, identity: model.identity }); + } + }; + const layoutKey = `${model.identity}:${model.title}:${model.body}:${model.author?.displayName ?? ''}:${model.author?.avatarUrl ?? ''}:${isCollapsed ? 'collapsed' : 'open'}`; + const sourceDescriptionContent = ( + {}} + onUpdateDescription={onUpdateDescription} + onUploadDescriptionAsset={onUploadDescriptionAsset} + /> + ); + const overviewAside = + footer || footerAside ? ( + + ) : null; return (
0 || canEditDescription} canEditTitle={canEditTitle} isCollapsed={isCollapsed} - label={getPullRequestDescriptionLabel(source)} - onToggleCollapsed={() => setCollapsed((current) => !current)} + label={model.label} + onToggleCollapsed={toggleCollapsed} onUpdateTitle={onUpdateTitle} - title={sourceTitle} + title={model.title} /> {!isCollapsed ? (
- {}} - onUpdateDescription={onUpdateDescription} - onUploadDescriptionAsset={onUploadDescriptionAsset} - /> - {footer ?
{footer}
: null} + {overviewAside ? ( +
+
+ {sourceDescriptionContent} +
+ {overviewAside} +
+ ) : ( + <> + {sourceDescriptionContent} + {footer ?
{footer}
: null} + + )}
) : null}
@@ -2551,6 +2567,7 @@ export function ReviewCodeView({ onResolveThread = noopResolveThread, onSaveCommentEdit, onSelectPathFromScroll, + onSourceDescriptionCollapsedChange, onSubmitComment, onToggleCollapsed, onToggleViewed, @@ -2567,7 +2584,9 @@ export function ReviewCodeView({ showWhitespace, source, sourceDescriptionActions, + sourceDescriptionCollapsed: controlledSourceDescriptionCollapsed, sourceDescriptionFooter, + sourceDescriptionFooterAside, supportsReviewCommentActions, theme = 'system', viewed, @@ -2613,6 +2632,7 @@ export function ReviewCodeView({ onResolveThread?: (threadId: string, resolved: boolean) => Promise | void; onSaveCommentEdit: (commentId: string, body: string) => Promise | void; onSelectPathFromScroll: (viewer: CodeViewInstance) => void; + onSourceDescriptionCollapsedChange?: (collapsed: boolean) => void; onSubmitComment: (commentId: string) => void; onToggleCollapsed: (file: ChangedFile, isCollapsed: boolean, reviewKey: string) => void; onToggleViewed: (file: ChangedFile, isViewed: boolean, reviewIdentity: ReviewIdentity) => void; @@ -2632,7 +2652,9 @@ export function ReviewCodeView({ showWhitespace: boolean; source: ResolvedReviewSource; sourceDescriptionActions?: ReactNode; + sourceDescriptionCollapsed?: boolean; sourceDescriptionFooter?: ReactNode; + sourceDescriptionFooterAside?: ReactNode; supportsReviewCommentActions: boolean; theme?: CodiffPreferences['theme']; viewed: Record; @@ -2699,59 +2721,52 @@ export function ReviewCodeView({ setDefinitionLookup(null); } const selectedLinesRef = useRef(null); - const commitMessageMetadata = source.type === 'commit' ? commitMetadata : null; - const shouldShowCommitMessage = commitMessageMetadata != null; - const shouldShowSourceDescription = showSourceDescription && source.type === 'pull-request'; - const sourceDescription = shouldShowCommitMessage - ? commitMessageMetadata.body.trim() - : shouldShowSourceDescription - ? (source.description?.trim() ?? '') - : ''; - const sourceDescriptionHasBody = sourceDescription.length > 0; - const sourceDescriptionHasContent = sourceDescriptionHasBody || shouldShowCommitMessage; + const sourceDescriptionModel = buildSourceDescriptionModel({ + commitMetadata, + showPullRequestDescription: showSourceDescription, + source, + }); + const shouldShowCommitMessage = sourceDescriptionModel?.kind === 'commit'; + const sourceDescription = sourceDescriptionModel?.body ?? ''; + const sourceDescriptionHasContent = sourceDescriptionModel != null; const canEditSourceDescription = - shouldShowSourceDescription && - source.canEditDescription === true && + sourceDescriptionModel?.kind === 'pull-request' && + sourceDescriptionModel.allowsBodyEdit && onUpdateSourceDescription != null; const canEditSourceTitle = - shouldShowSourceDescription && - (source.canEditTitle === true || source.canEditDescription === true) && + sourceDescriptionModel?.kind === 'pull-request' && + sourceDescriptionModel.allowsTitleEdit && onUpdateSourceTitle != null; - const sourceAuthor = shouldShowCommitMessage - ? getCommitDescriptionAuthor(commitMessageMetadata.author) - : shouldShowSourceDescription && source.author - ? getPullRequestDescriptionAuthor(source.author) - : undefined; - const sourceTitle = shouldShowCommitMessage - ? commitMessageMetadata.subject.trim() || commitMessageMetadata.shortSha - : shouldShowSourceDescription - ? (source.title?.trim() ?? '') - : ''; - const sourceDescriptionItemId = - shouldShowCommitMessage && source.type === 'commit' - ? `commit-message:${source.sha}` - : shouldShowSourceDescription && (sourceDescription || sourceTitle) - ? `source-description:${source.provider ?? ''}:${source.url}` - : null; - const sourceDescriptionLabel = shouldShowCommitMessage - ? 'Commit' - : source.type === 'pull-request' - ? getPullRequestDescriptionLabel(source) - : ''; - const sourceDescriptionAriaLabel = shouldShowCommitMessage - ? 'Preview commit message' - : 'Preview source description'; - const [collapsedSourceDescriptionItemId, setCollapsedSourceDescriptionItemId] = useState< - string | null - >(null); + const sourceAuthor = sourceDescriptionModel?.author; + const sourceTitle = sourceDescriptionModel?.title ?? ''; + const sourceDescriptionItemId = sourceDescriptionModel?.identity ?? null; + const sourceDescriptionLabel = sourceDescriptionModel?.label ?? ''; + const sourceDescriptionAriaLabel = sourceDescriptionModel?.ariaLabel ?? ''; + const [sourceDescriptionCollapseState, setSourceDescriptionCollapseState] = useState(() => ({ + collapsed: sourceDescriptionModel?.defaultCollapsed ?? false, + identity: sourceDescriptionModel?.identity ?? '', + })); + const uncontrolledSourceDescriptionCollapsed = sourceDescriptionModel + ? sourceDescriptionCollapseState.identity === sourceDescriptionModel.identity + ? sourceDescriptionCollapseState.collapsed + : sourceDescriptionModel.defaultCollapsed + : true; const sourceDescriptionCollapsed = - (!sourceDescriptionHasContent && !canEditSourceDescription) || - collapsedSourceDescriptionItemId === sourceDescriptionItemId; + controlledSourceDescriptionCollapsed ?? uncontrolledSourceDescriptionCollapsed; const toggleSourceDescriptionCollapsed = useCallback(() => { - setCollapsedSourceDescriptionItemId((current) => - current === sourceDescriptionItemId ? null : sourceDescriptionItemId, - ); - }, [sourceDescriptionItemId]); + if (!sourceDescriptionModel) { + return; + } + const next = !sourceDescriptionCollapsed; + if (onSourceDescriptionCollapsedChange) { + onSourceDescriptionCollapsedChange(next); + } else { + setSourceDescriptionCollapseState({ + collapsed: next, + identity: sourceDescriptionModel.identity, + }); + } + }, [onSourceDescriptionCollapsedChange, sourceDescriptionCollapsed, sourceDescriptionModel]); const stickyHeaderFrameRef = useRef(null); const reviewBlocks = useMemo(() => blocks ?? createFileReviewBlocks(files), [blocks, files]); @@ -4225,20 +4240,39 @@ export function ReviewCodeView({ {!sourceDescriptionCollapsed && (sourceDescriptionHasContent || canEditSourceDescription) ? (
- - {sourceDescriptionFooter ? ( -
{sourceDescriptionFooter}
- ) : null} + {sourceDescriptionFooter || sourceDescriptionFooterAside ? ( +
+
+ +
+ +
+ ) : ( + + )}
) : null}
@@ -4256,6 +4290,7 @@ export function ReviewCodeView({ sourceDescriptionAriaLabel, sourceDescriptionCollapsed, sourceDescriptionFooter, + sourceDescriptionFooterAside, sourceDescriptionHasContent, sourceDescriptionLabel, sourceTitle, diff --git a/core/app/components/walkthrough/WalkthroughDiffSurface.tsx b/core/app/components/walkthrough/WalkthroughDiffSurface.tsx index 29a7b32f..b0513709 100644 --- a/core/app/components/walkthrough/WalkthroughDiffSurface.tsx +++ b/core/app/components/walkthrough/WalkthroughDiffSurface.tsx @@ -35,6 +35,7 @@ export function WalkthroughDiffSurface({ scrollTarget, sourceDescriptionActions, sourceDescriptionFooter, + sourceDescriptionFooterAside, }: { allowViewedToggle?: boolean; blocks: ReadonlyArray; @@ -44,6 +45,7 @@ export function WalkthroughDiffSurface({ scrollTarget: WalkthroughBlockScrollTarget | null; sourceDescriptionActions?: ReviewCodeViewProps['sourceDescriptionActions']; sourceDescriptionFooter?: ReviewCodeViewProps['sourceDescriptionFooter']; + sourceDescriptionFooterAside?: ReviewCodeViewProps['sourceDescriptionFooterAside']; }) { return (
@@ -62,6 +64,9 @@ export function WalkthroughDiffSurface({ showSourceDescription sourceDescriptionActions={sourceDescriptionActions ?? reviewProps.sourceDescriptionActions} sourceDescriptionFooter={sourceDescriptionFooter ?? reviewProps.sourceDescriptionFooter} + sourceDescriptionFooterAside={ + sourceDescriptionFooterAside ?? reviewProps.sourceDescriptionFooterAside + } walkthroughNotes={emptyWalkthroughNotes} />
diff --git a/core/lib/source-description.ts b/core/lib/source-description.ts new file mode 100644 index 00000000..18a9c37f --- /dev/null +++ b/core/lib/source-description.ts @@ -0,0 +1,88 @@ +import type { CommitMetadata, ResolvedReviewSource, ReviewAuthor } from '../types.ts'; + +export type SourceDescriptionAuthor = { + avatarUrl?: string; + displayName: string; + title?: string; +}; + +export type SourceDescriptionModel = { + allowsBodyEdit: boolean; + allowsTitleEdit: boolean; + ariaLabel: string; + author?: SourceDescriptionAuthor; + body: string; + defaultCollapsed: boolean; + identity: string; + kind: 'commit' | 'pull-request'; + label: string; + title: string; +}; + +const getPullRequestDescriptionLabel = ( + source: Extract, +) => + source.provider === 'github' + ? 'PR description' + : source.provider === 'gitlab' + ? 'MR description' + : 'Description'; + +const getPullRequestDescriptionAuthor = (author: ReviewAuthor): SourceDescriptionAuthor => ({ + avatarUrl: author.avatarUrl, + displayName: author.name || `@${author.login}`, + title: `@${author.login}`, +}); + +const getCommitDescriptionAuthor = (author: CommitMetadata['author']): SourceDescriptionAuthor => ({ + avatarUrl: author.gravatarUrl, + displayName: author.name || author.email || 'Unknown author', + title: author.email || undefined, +}); + +export const buildSourceDescriptionModel = ({ + commitMetadata, + showPullRequestDescription = true, + source, +}: { + commitMetadata: CommitMetadata | null; + showPullRequestDescription?: boolean; + source: ResolvedReviewSource; +}): SourceDescriptionModel | null => { + if (source.type === 'commit' && commitMetadata) { + return { + allowsBodyEdit: false, + allowsTitleEdit: false, + ariaLabel: 'Preview commit message', + author: getCommitDescriptionAuthor(commitMetadata.author), + body: commitMetadata.body.trim(), + defaultCollapsed: false, + identity: `commit-message:${source.sha}`, + kind: 'commit', + label: 'Commit', + title: commitMetadata.subject.trim() || commitMetadata.shortSha, + }; + } + + if (source.type !== 'pull-request' || !showPullRequestDescription) { + return null; + } + const body = source.description?.trim() ?? ''; + const title = source.title?.trim() ?? ''; + if (!body && !title) { + return null; + } + const allowsBodyEdit = source.canEditDescription === true; + return { + allowsBodyEdit, + allowsTitleEdit: source.canEditTitle === true || allowsBodyEdit, + ariaLabel: 'Preview source description', + ...(source.author ? { author: getPullRequestDescriptionAuthor(source.author) } : {}), + body, + defaultCollapsed: false, + identity: `source-description:${source.provider ?? ''}:${source.url}:${source.headSha ?? 'unresolved-head'}`, + kind: 'pull-request', + label: getPullRequestDescriptionLabel(source), + title, + }; +}; From 6ad2eb85e63e9a653ce7b92d073927764ced79fb Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 13:45:49 -0500 Subject: [PATCH 13/17] Filter diff search by line type Search additions, deletions, and visible unchanged context independently, with additions and deletions enabled by default. Restrict unchanged search to context already displayed in the hunk rather than hidden full-file contents loaded for rendering. --- core/App.css | 29 +++++- core/ReviewSurface.tsx | 41 +------- core/__tests__/App.test.tsx | 89 ++++++++++++++--- .../ReviewSurface-capabilities.test.tsx | 99 ++----------------- core/app/components/Panels.tsx | 25 +++++ core/app/hooks/useDiffSearch.ts | 13 ++- core/lib/diff-search.ts | 47 ++++++--- core/lib/diff.ts | 6 -- core/lib/source.ts | 11 --- 9 files changed, 182 insertions(+), 178 deletions(-) diff --git a/core/App.css b/core/App.css index 2e9ce50c..85d5e9f8 100644 --- a/core/App.css +++ b/core/App.css @@ -1179,7 +1179,7 @@ a.review-top-bar-source:focus-visible { corner-shape: squircle; display: grid; gap: 4px; - grid-template-columns: minmax(140px, 240px) 54px repeat(3, 28px); + grid-template-columns: minmax(140px, 240px) 54px auto repeat(3, 28px); min-height: 38px; opacity: 0; padding: 4px; @@ -1222,6 +1222,26 @@ a.review-top-bar-source:focus-visible { color: var(--muted); } +.diff-search-filters { + align-items: center; + display: flex; + gap: 6px; + white-space: nowrap; +} + +.diff-search-filters label { + align-items: center; + color: var(--muted); + display: inline-flex; + font: 11px/1 var(--font-sans); + gap: 3px; +} + +.diff-search-filters input { + accent-color: var(--tree-selection-focus); + margin: 0; +} + .diff-search-count { font: 12px/1 var(--font-mono); text-align: center; @@ -4213,11 +4233,16 @@ diffs-container .review-comment-thread { @media (max-width: 880px) { .diff-search-panel { - grid-template-columns: minmax(120px, 1fr) 48px 28px 28px 28px; + grid-template-columns: minmax(120px, 1fr) 48px minmax(0, auto) repeat(3, 28px); left: 230px; right: 10px; } + .diff-search-filters { + min-width: 0; + overflow-x: auto; + } + .code-view { font-size: 12px; } diff --git a/core/ReviewSurface.tsx b/core/ReviewSurface.tsx index 30a784cc..28dae25d 100644 --- a/core/ReviewSurface.tsx +++ b/core/ReviewSurface.tsx @@ -88,7 +88,6 @@ import { getDiffLineCount, getTotalDiffLineCount, isMarkdownFilePath, - shouldPreloadSectionContentsForSearch, } from './lib/diff.ts'; import { abbreviateHomePath, sortFiles } from './lib/files.ts'; import { isNativeInputTarget } from './lib/keyboard.ts'; @@ -124,7 +123,6 @@ import { getEmptySourceTitle, getSourceLabel, getSourceKey, - supportsDiffSearchContentPreload, } from './lib/source.ts'; import type { ChangedFile, @@ -911,12 +909,14 @@ export function ReviewSurface({ activeMatchIndex: activeDiffSearchMatchIndex, closeSearch: closeDiffSearch, fileFilteredFiles, + filters: diffSearchFilters, focusRequest: diffSearchFocusRequest, matches: diffSearchMatches, matchPathSet: diffSearchMatchPathSet, moveMatch: moveDiffSearchMatch, openSearch: openDiffSearch, query: diffSearchQuery, + updateFilters: updateDiffSearchFilters, updateQuery: updateDiffSearchQuery, visible: diffSearchVisible, visibleFiles, @@ -925,41 +925,6 @@ export function ReviewSurface({ fileSearchQuery, showWhitespace: snapshot.preferences.showWhitespace, }); - useEffect(() => { - if ( - !content?.onLoadSection || - !supportsDiffSearchContentPreload(snapshot.repository.source) || - !diffSearchQuery.trim() - ) { - return; - } - - const requests = fileFilteredFiles.flatMap((file) => - file.sections - .filter(shouldPreloadSectionContentsForSearch) - .map((section) => ({ file, section })), - ); - if (requests.length === 0) { - return; - } - - let canceled = false; - let cursor = 0; - const loadNext = async () => { - while (!canceled) { - const request = requests[cursor]; - cursor += 1; - if (!request) { - return; - } - await content.onLoadSection!(request.file, request.section); - } - }; - void Promise.all(Array.from({ length: Math.min(3, requests.length) }, () => loadNext())); - return () => { - canceled = true; - }; - }, [content, diffSearchQuery, fileFilteredFiles, snapshot.repository.source]); const forceExpandedPaths = useMemo( () => new Set([...diffSearchMatchPathSet, ...(content?.forceExpandedPaths ?? emptyPaths)]), [content?.forceExpandedPaths, diffSearchMatchPathSet], @@ -2069,11 +2034,13 @@ export function ReviewSurface({ ) : null} moveDiffSearchMatch(1)} onPrevious={() => moveDiffSearchMatch(-1)} query={diffSearchQuery} diff --git a/core/__tests__/App.test.tsx b/core/__tests__/App.test.tsx index 4c7baebb..99d9dd90 100644 --- a/core/__tests__/App.test.tsx +++ b/core/__tests__/App.test.tsx @@ -11,7 +11,6 @@ import { fileHasVisibleDiff, loadSectionContents, shouldLoadDiffSectionContents, - shouldPreloadSectionContentsForSearch, } from '../lib/diff.ts'; import { isDiffSearchShortcut } from '../lib/keyboard.ts'; import { renderInlineMarkdown, sanitizeMarkdownImages } from '../lib/markdown.tsx'; @@ -82,10 +81,8 @@ test('patch-only text sections hydrate lazily instead of eager loading', () => { patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', } as const; - // Patch-only sections expand via `loadDiffFiles` hydration, not the eager - // Load flow, but diff search still preloads their full contents. + // Patch-only sections expand through explicit unchanged-context requests. expect(shouldLoadDiffSectionContents(patchOnlySection)).toBe(false); - expect(shouldPreloadSectionContentsForSearch(patchOnlySection)).toBe(true); expect( shouldLoadDiffSectionContents({ @@ -94,16 +91,6 @@ test('patch-only text sections hydrate lazily instead of eager loading', () => { }), ).toBe(true); - expect( - shouldPreloadSectionContentsForSearch({ - ...patchOnlySection, - summary: { - canLoad: false, - reason: 'Codiff could not load full file context.', - }, - }), - ).toBe(false); - expect( shouldLoadDiffSectionContents({ binary: false, @@ -324,6 +311,80 @@ test('diff search finds content matches across sides', () => { expect(result?.matchCount).toBe(1); }); +test('diff search independently filters new, old, and unchanged lines', () => { + const file = { + fingerprint: 'search-filters', + path: 'src/search.ts', + sections: [ + { + binary: false, + id: 'src/search.ts:unstaged', + kind: 'unstaged', + patch: + 'diff --git a/src/search.ts b/src/search.ts\n@@ -1,3 +1,3 @@\n needle context\n-old needle\n+new needle\n needle context\n', + }, + ], + status: 'modified', + } satisfies ChangedFile; + + expect(getDiffSearchResult(file, false, 'needle')?.matchCount).toBe(2); + expect( + getDiffSearchResult(file, false, 'needle', { + additions: false, + deletions: false, + unchanged: true, + })?.matchCount, + ).toBe(2); + expect( + getDiffSearchResult(file, false, 'needle', { + additions: true, + deletions: false, + unchanged: false, + })?.matchCount, + ).toBe(1); + expect( + getDiffSearchResult(file, false, 'needle', { + additions: false, + deletions: true, + unchanged: false, + })?.matchCount, + ).toBe(1); +}); + +test('diff search does not search hidden unchanged lines from complete file contents', () => { + const file = { + fingerprint: 'search-unloaded-context', + path: 'src/search.ts', + sections: [ + { + binary: false, + id: 'src/search.ts:unstaged', + kind: 'unstaged', + newFile: { + contents: + 'before\nneedle outside the patch\nafter\nline four\nline five\nline six\nline seven\nline eight\nline nine\nline ten\nnew value\n', + name: 'src/search.ts', + }, + oldFile: { + contents: + 'before\nneedle outside the patch\nafter\nline four\nline five\nline six\nline seven\nline eight\nline nine\nline ten\nold value\n', + name: 'src/search.ts', + }, + patch: 'diff --git a/src/search.ts b/src/search.ts\n@@ -4 +4 @@\n-old value\n+new value\n', + }, + ], + status: 'modified', + } satisfies ChangedFile; + + expect( + getDiffSearchResult(file, false, 'needle', { + additions: false, + deletions: false, + unchanged: true, + }), + ).toBeNull(); +}); + test('diff search includes file path matches', () => { const file = { fingerprint: 'path-search', diff --git a/core/__tests__/ReviewSurface-capabilities.test.tsx b/core/__tests__/ReviewSurface-capabilities.test.tsx index d24a66bb..defd1d72 100644 --- a/core/__tests__/ReviewSurface-capabilities.test.tsx +++ b/core/__tests__/ReviewSurface-capabilities.test.tsx @@ -473,7 +473,6 @@ test('dispatches neutral GitLab review sessions with pending drafts and summarie }); }); - test('keeps title-only source footer actions reachable', async () => { const onMergePullRequest = vi.fn(async () => {}); const source = { @@ -641,7 +640,6 @@ test('renders the same source-description semantics once in Tree, Walkthrough, a await waitFor(assertSourceDescriptionOnce); }); ->>>>>>> conflict 1 of 1 ends test('copies local notes with the local label and Markdown heading', async () => { const file = snapshot.files[0]!; const draft = { @@ -1132,7 +1130,7 @@ test('forwards controlled draft updates atomically across an asynchronous submis container.remove(); }); -test('preloads deferred files before applying diff-content search results', async () => { +test('search filters never load hidden full-file content', async () => { const deferredFile = { ...createChangedFile('src/lazy.ts', { kind: 'pull-request', patch: '' }), sections: [ @@ -1180,96 +1178,17 @@ test('preloads deferred files before applying diff-content search results', asyn setInputValue?.call(input, 'needle'); input?.dispatchEvent(new Event('input', { bubbles: true })); }); - await waitFor(() => - expect(onLoadSection).toHaveBeenCalledWith(deferredFile, deferredFile.sections[0]), - ); + const filters = [ + ...view.container.querySelectorAll('.diff-search-filters input'), + ]; + for (const filter of filters) { + await act(async () => filter.click()); + await act(async () => filter.click()); + } + expect(onLoadSection).not.toHaveBeenCalled(); expect(view.container.querySelector('.empty-panel')?.textContent).toContain( 'No matches in diffs', ); - - const loadedFile = { - ...deferredFile, - sections: [ - { - ...deferredFile.sections[0], - loadState: 'ready', - newFile: { contents: 'const needle = true;\n', name: deferredFile.path }, - oldFile: { contents: 'const value = false;\n', name: deferredFile.path }, - patch: '@@ -1 +1 @@\n-const value = false;\n+const needle = true;\n', - }, - ], - } satisfies SharedWalkthroughSnapshot['files'][number]; - await view.render({ - capabilities: { content: { onLoadSection } }, - initialMode: 'tree', - onCommandBridgeChange: (value) => { - bridge.current = value; - }, - snapshot: { ...lazySnapshot, files: [loadedFile] }, - }); - await waitFor(() => expect(view.container.querySelector('.empty-panel')).toBeNull()); - expect(view.container.textContent).toContain('src/lazy.ts'); -}); - -test('bounds deferred diff-search preloads to three concurrent section loads', async () => { - const files = Array.from({ length: 100 }, (_, index) => { - const path = `src/lazy-${index}.ts`; - return { - ...createChangedFile(path, { kind: 'pull-request', patch: '' }), - sections: [ - { - binary: false, - id: `${path}:pull-request`, - kind: 'pull-request' as const, - loadState: 'deferred' as const, - patch: '', - summary: { canLoad: true, reason: 'Load exact contents.' }, - }, - ], - }; - }) satisfies ReadonlyArray; - const lazySnapshot = { - ...snapshot, - files, - repository: { - root: '/repo', - source: { - number: 7, - owner: 'cloudflare', - provider: 'github', - repo: 'codiff', - type: 'pull-request', - url: 'https://github.com/cloudflare/codiff/pull/7', - }, - }, - } satisfies SharedWalkthroughSnapshot; - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const onLoadSection = vi.fn(() => gate); - const bridge = { current: null as ReviewSurfaceCommandBridge | null }; - await using view = await renderSurface({ - capabilities: { content: { onLoadSection } }, - initialMode: 'tree', - onCommandBridgeChange: (value) => { - bridge.current = value; - }, - snapshot: lazySnapshot, - }); - - await act(async () => bridge.current?.openDiffSearch()); - const input = view.container.querySelector('.diff-search-input'); - const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - await act(async () => { - setInputValue?.call(input, 'needle'); - input?.dispatchEvent(new Event('input', { bubbles: true })); - }); - await waitFor(() => expect(onLoadSection).toHaveBeenCalledTimes(3)); - expect(onLoadSection).toHaveBeenCalledTimes(3); - - await act(async () => release()); - await waitFor(() => expect(onLoadSection).toHaveBeenCalledTimes(100)); }); test('tracks controlled collapsed and viewed state across same-source rerenders', async () => { diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index d99ec396..9942d8d7 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -22,6 +22,7 @@ import { import { matchesShortcut } from '../../config/keymap.ts'; import type { CodiffKeymap } from '../../config/types.ts'; import type { RepositoryLoadError, ReviewComment } from '../../lib/app-types.ts'; +import type { DiffSearchFilters } from '../../lib/diff-search.ts'; import { buildReviewCommentsMarkdown } from '../../lib/review-comments.ts'; import type { ChangedFile, @@ -365,22 +366,26 @@ export function AgentUnavailablePanel({ export function DiffSearchPanel({ activeIndex, + filters, focusRequest, keymap, matchCount, onChange, onClose, + onFiltersChange, onNext, onPrevious, query, visible, }: { activeIndex: number; + filters: DiffSearchFilters; focusRequest: number; keymap: CodiffKeymap; matchCount: number; onChange: (query: string) => void; onClose: () => void; + onFiltersChange: (filters: DiffSearchFilters) => void; onNext: () => void; onPrevious: () => void; query: string; @@ -437,6 +442,26 @@ export function DiffSearchPanel({ {query.trim() ? (matchCount > 0 ? `${activeIndex + 1}/${matchCount}` : '0/0') : ''} +
+ {( + [ + ['additions', 'New lines'], + ['deletions', 'Old lines'], + ['unchanged', 'Unchanged lines'], + ] as const + ).map(([key, label]) => ( + + ))} +
); @@ -109,7 +121,10 @@ function SupportingFilesStop({ const current = navigation.mode === 'support'; const isDone = navigation.supportVisited && !current; const fileRows = formatWalkthroughFileLineRows([ - ...walkthroughView.support.flatMap((item) => item.hunks), + ...resolveWalkthroughFileLineItems( + walkthroughView.support.flatMap((item) => item.hunks), + files, + ), ...uncoveredFiles, ]); return ( @@ -200,6 +215,7 @@ export function NarrativeSidebar({ {chapter.stops.map((stop) => ( Promise; getAgentSkillStatus: () => Promise; getConfig: () => Promise; - getDiffImageContent: (request: DiffImageContentRequest) => Promise; - getDiffSectionContent: (request: DiffSectionContentRequest) => Promise; - getDiffSectionsContent: ( - request: DiffSectionsContentRequest, - ) => Promise; getFeatureFlags: () => Promise; getGitIdentity: () => Promise; getKeyboardLayout: () => Promise; @@ -117,6 +114,9 @@ declare global { openFile: (path: string, lineNumber?: number) => Promise; openReleasePage: () => Promise; openRepositoryFolder: () => Promise; + readRevisionContent: ( + request: RevisionContentBatchRequest, + ) => Promise; reportInitialLoadMilestone: ( name: 'deferred-review-data-complete' | 'first-usable-review-rendered', ) => void; diff --git a/core/index.ts b/core/index.ts index 7788bce7..cd94f5f3 100644 --- a/core/index.ts +++ b/core/index.ts @@ -13,6 +13,13 @@ export { type ReviewCommitStack, type ReviewCommitStackItem, } from './lib/review-commit-stack.ts'; +export { + getReviewContextExpansionState, + reviewContextExpansionDigest, + reviewContextExpansionProjectionKey, + type ReviewContextExpansionRegion, + type ReviewContextExpansionState, +} from './lib/review-context-expansion.ts'; export { createCommitArtifactRequestKey, createFileBlobArtifactRequestKey, diff --git a/core/lib/code-view-options.ts b/core/lib/code-view-options.ts index d5ea93b6..bfaeeaec 100644 --- a/core/lib/code-view-options.ts +++ b/core/lib/code-view-options.ts @@ -177,6 +177,28 @@ export const codeViewUnsafeCSS = ` text-decoration-style: solid; } + :host(.codiff-loading-context-item) [data-expand-index] { + cursor: progress; + opacity: 0.72; + pointer-events: none; + } + + :host(.codiff-loading-context-item) [data-expand-index]::after { + animation: codiff-context-spin 0.8s linear infinite; + border: 2px solid color-mix(in srgb, currentColor 24%, transparent); + border-radius: 50%; + border-top-color: currentColor; + content: ''; + display: inline-block; + height: 10px; + margin-left: 6px; + vertical-align: -1px; + width: 10px; + } + + @keyframes codiff-context-spin { + to { transform: rotate(360deg); } + } .codiff-search-mark { background: var(--diffs-find-highlight-bg, rgb(255 216 92 / 0.65)); border-radius: 3px; diff --git a/core/lib/diff.ts b/core/lib/diff.ts index dbde5bb1..e94386d5 100644 --- a/core/lib/diff.ts +++ b/core/lib/diff.ts @@ -186,6 +186,14 @@ export const getDiffLineCountFromVisibleSections = ( export const getDiffLineCount = (file: ChangedFile, showWhitespace: boolean): DiffLineCount => getDiffLineCountFromVisibleSections(getVisibleDiffSections(file, showWhitespace)); +export const getDiffSectionLineCount = (file: ChangedFile, section: DiffSection): DiffLineCount => + getDiffLineCountFromVisibleSections([ + { + fileDiff: parseSectionDiffWithOptions(file, section, true), + section, + }, + ]); + export const getTotalDiffLineCount = (lineCounts: Iterable): DiffLineCount => { let additions = 0; let countable = false; @@ -319,13 +327,21 @@ export const parseSectionDiffWithOptions = ( return cached; } + const oldFile = + section.oldFile ?? + ((file.status === 'added' || file.status === 'untracked') && section.newFile + ? { contents: '', name: file.oldPath ?? file.path } + : undefined); + const newFile = + section.newFile ?? + (file.status === 'deleted' && section.oldFile ? { contents: '', name: file.path } : undefined); let fileDiff: FileDiffMetadata; if (section.binary || (section.loadState != null && section.loadState !== 'ready')) { fileDiff = createBinaryFileDiff(file, section); - } else if (section.oldFile && section.newFile) { + } else if (oldFile && newFile) { try { fileDiff = { - ...parseDiffFromFile(section.oldFile, section.newFile, { + ...parseDiffFromFile(oldFile, newFile, { ignoreWhitespace: !showWhitespace, }), cacheKey, diff --git a/core/lib/narrative-walkthrough-diff.cjs b/core/lib/narrative-walkthrough-diff.cjs index 293d3e4a..0d7ba7cc 100644 --- a/core/lib/narrative-walkthrough-diff.cjs +++ b/core/lib/narrative-walkthrough-diff.cjs @@ -220,7 +220,7 @@ const fileHasRenameMetadata = (file) => /** * @param {{oldPath?: string; path: string; status?: string}} file - * @param {{binary?: boolean; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const shouldCreateSyntheticHunk = (file, section) => { if (section.binary) { @@ -232,13 +232,16 @@ const shouldCreateSyntheticHunk = (file, section) => { if (fileHasRenameMetadata(file)) { return true; } + if (section.lineCount != null) { + return true; + } return typeof section.patch === 'string' && section.patch.trim().length > 0; }; /** * @param {{generated?: boolean; oldPath?: string; path: string; status: string}} file - * @param {{binary?: boolean; id: string; kind: string; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; id: string; kind: string; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const createSyntheticSectionHunk = (file, section, lineCount = { added: 0, deleted: 0 }) => ({ added: lineCount.added, @@ -262,7 +265,7 @@ const createSyntheticSectionHunk = (file, section, lineCount = { added: 0, delet * synthetic hunk so walkthroughs remain hunk-based for every visible change. * * @param {{generated?: boolean; oldPath?: string; path: string; status: string}} file - * @param {{binary?: boolean; id: string; kind: string; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; id: string; kind: string; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const getSectionWalkthroughHunks = (file, section) => { const patchHunks = extractPatchHunks(section.patch || ''); @@ -285,7 +288,18 @@ const getSectionWalkthroughHunks = (file, section) => { } return shouldCreateSyntheticHunk(file, section) - ? [createSyntheticSectionHunk(file, section)] + ? [ + createSyntheticSectionHunk( + file, + section, + section.lineCount == null + ? undefined + : { + added: section.lineCount.additions, + deleted: section.lineCount.deletions, + }, + ), + ] : []; }; diff --git a/core/lib/narrative-walkthrough-diff.js b/core/lib/narrative-walkthrough-diff.js index a7fc1f4c..8d22076d 100644 --- a/core/lib/narrative-walkthrough-diff.js +++ b/core/lib/narrative-walkthrough-diff.js @@ -220,7 +220,7 @@ const fileHasRenameMetadata = (file) => /** * @param {{oldPath?: string; path: string; status?: string}} file - * @param {{binary?: boolean; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const shouldCreateSyntheticHunk = (file, section) => { if (section.binary) { @@ -232,13 +232,16 @@ const shouldCreateSyntheticHunk = (file, section) => { if (fileHasRenameMetadata(file)) { return true; } + if (section.lineCount != null) { + return true; + } return typeof section.patch === 'string' && section.patch.trim().length > 0; }; /** * @param {{generated?: boolean; oldPath?: string; path: string; status: string}} file - * @param {{binary?: boolean; id: string; kind: string; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; id: string; kind: string; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const createSyntheticSectionHunk = (file, section, lineCount = { added: 0, deleted: 0 }) => ({ added: lineCount.added, @@ -262,7 +265,7 @@ const createSyntheticSectionHunk = (file, section, lineCount = { added: 0, delet * synthetic hunk so walkthroughs remain hunk-based for every visible change. * * @param {{generated?: boolean; oldPath?: string; path: string; status: string}} file - * @param {{binary?: boolean; id: string; kind: string; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; id: string; kind: string; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const getSectionWalkthroughHunks = (file, section) => { const patchHunks = extractPatchHunks(section.patch || ''); @@ -285,7 +288,18 @@ const getSectionWalkthroughHunks = (file, section) => { } return shouldCreateSyntheticHunk(file, section) - ? [createSyntheticSectionHunk(file, section)] + ? [ + createSyntheticSectionHunk( + file, + section, + section.lineCount == null + ? undefined + : { + added: section.lineCount.additions, + deleted: section.lineCount.deletions, + }, + ), + ] : []; }; diff --git a/core/lib/narrative-walkthrough.ts b/core/lib/narrative-walkthrough.ts index 000c29e1..02fdf0e5 100644 --- a/core/lib/narrative-walkthrough.ts +++ b/core/lib/narrative-walkthrough.ts @@ -10,7 +10,7 @@ import type { WalkthroughSupportGroup, WalkthroughStop, } from '../types.ts'; -import { getDiffLineCount, getVisibleDiffSections } from './diff.ts'; +import { getDiffLineCount, getDiffSectionLineCount, getVisibleDiffSections } from './diff.ts'; import { filterPatchToHunkIds, getSectionWalkthroughHunks, @@ -20,6 +20,7 @@ import { type NarrativeLineCount = { added: number; deleted: number; + diffAvailable?: false; }; /** A stop with a global position in the walkthrough. */ @@ -55,6 +56,7 @@ export type WalkthroughFileList = { export type WalkthroughFileLineRow = { added: number; deleted: number; + diffAvailable?: false; label: string; path?: string; title: string; @@ -119,7 +121,12 @@ export const formatWalkthroughFileList = ( }; export const formatWalkthroughFileLineRows = ( - items: ReadonlyArray<{ added: number; deleted: number; path: string }>, + items: ReadonlyArray<{ + added: number; + deleted: number; + diffAvailable?: false; + path: string; + }>, maxVisibleFiles = 5, ): ReadonlyArray => { const order: Array = []; @@ -133,6 +140,9 @@ export const formatWalkthroughFileLineRows = ( totalsByPath.set(item.path, { added: current.added + item.added, deleted: current.deleted + item.deleted, + ...(current.diffAvailable === false || item.diffAvailable === false + ? { diffAvailable: false as const } + : {}), }); } @@ -143,8 +153,11 @@ export const formatWalkthroughFileLineRows = ( if (order.length > maxVisibleFiles) { const totals = [...totalsByPath.values()].reduce( (sum, item) => ({ added: sum.added + item.added, deleted: sum.deleted + item.deleted }), - { added: 0, deleted: 0 }, + { added: 0, deleted: 0 } as NarrativeLineCount, ); + if ([...totalsByPath.values()].some((item) => item.diffAvailable === false)) { + totals.diffAvailable = false; + } return [ { ...totals, @@ -162,6 +175,56 @@ export const formatWalkthroughFileLineRows = ( })); }; +export const resolveWalkthroughFileLineItems = ( + hunks: ReadonlyArray, + files: ReadonlyArray, +): ReadonlyArray<{ added: number; deleted: number; diffAvailable?: false; path: string }> => + hunks.map((hunk) => { + if (!isSyntheticWalkthroughHunk(hunk)) { + return { added: hunk.added, deleted: hunk.deleted, path: hunk.path }; + } + + const resolved = resolveWalkthroughHunkFile(hunk, files); + if (!resolved) { + return { added: 0, deleted: 0, diffAvailable: false, path: hunk.path }; + } + const { file, section } = resolved; + const hasExactContents = + (section.newFile != null && + (section.oldFile != null || file.status === 'added' || file.status === 'untracked')) || + (section.oldFile != null && file.status === 'deleted'); + if (hasExactContents) { + const lineCount = getDiffSectionLineCount(file, section); + if (lineCount.countable) { + return { + added: lineCount.additions, + deleted: lineCount.deletions, + path: hunk.path, + }; + } + } + if (section.patch.trim().length > 0) { + const materialized = getSectionWalkthroughHunks(file, section).find( + (candidate: { id: string }) => candidate.id === hunk.id, + ) as { added: number; deleted: number } | undefined; + if (materialized) { + return { + added: materialized.added, + deleted: materialized.deleted, + path: hunk.path, + }; + } + } + if (section.lineCount != null) { + return { + added: section.lineCount.additions, + deleted: section.lineCount.deletions, + path: hunk.path, + }; + } + return { added: 0, deleted: 0, diffAvailable: false, path: hunk.path }; + }); + const walkthroughCoveredHunkIds = (view: WalkthroughView): ReadonlySet => new Set([...view.sequence, ...view.support].flatMap((item) => item.hunkIds)); @@ -263,12 +326,13 @@ export const getUncoveredWalkthroughFileLineItems = ( files: ReadonlyArray, view: WalkthroughView, showWhitespace: boolean, -): ReadonlyArray<{ added: number; deleted: number; path: string }> => +): ReadonlyArray<{ added: number; deleted: number; diffAvailable?: false; path: string }> => getUncoveredWalkthroughFiles(files, view, showWhitespace).map((file) => { const lineCount = getDiffLineCount(file, showWhitespace); return { added: lineCount.countable ? lineCount.additions : 0, deleted: lineCount.countable ? lineCount.deletions : 0, + ...(!lineCount.countable ? { diffAvailable: false as const } : {}), path: file.path, }; }); diff --git a/core/lib/review-content.ts b/core/lib/review-content.ts new file mode 100644 index 00000000..7da2ea9e --- /dev/null +++ b/core/lib/review-content.ts @@ -0,0 +1,352 @@ +import type { FileDiffLoadedFiles } from '@pierre/diffs'; +import type { + ChangedFile, + DiffImageContentResult, + DiffImageRevision, + DiffRange, + DiffSection, + ResolvedReviewSource, + ResolvedRevisionBytes, + Revision, + RevisionContentBatchRequest, + RevisionContentBatchResult, + RevisionContentItemResult, + RevisionContentRequest, +} from '../types.ts'; +import { getSourceRevisionKey } from './source.ts'; + +export const manualTextFileLimit = 2 * 1024 * 1024; +export const imageFileLimit = 32 * 1024 * 1024; + +type WithoutKey = Result extends unknown ? Omit : never; +type RevisionReadResult = WithoutKey; + +export type ReviewContentTransport = ( + request: RevisionContentBatchRequest, +) => Promise; + +export type ReviewContentRunDiagnostics = { + cacheHits: number; + sourceCalls: number; + sourceReads: Readonly>; +}; + +export type ReviewContentRun = { + abort(reason?: unknown): void; + diagnostics(): ReviewContentRunDiagnostics; + readDiffBytes( + file: Pick, + section: Pick, + maxBytes?: number, + ): Promise<{ newBytes: ResolvedRevisionBytes | null; oldBytes: ResolvedRevisionBytes | null }>; + readDiffBytesBatch( + requests: ReadonlyArray<{ + file: Pick; + section: Pick; + }>, + maxBytes?: number, + ): Promise< + ReadonlyArray<{ + newBytes: ResolvedRevisionBytes | null; + oldBytes: ResolvedRevisionBytes | null; + }> + >; + resolveImage( + file: Pick, + section: Pick, + ): Promise; + resolveSectionContents(file: ChangedFile, section: DiffSection): Promise; + resolveSectionContentsBatch( + requests: ReadonlyArray<{ file: ChangedFile; section: DiffSection }>, + ): Promise>; +}; + +const revisionKind = (revision: Revision) => revision.kind ?? 'commit'; + +const revisionCoordinate = (revision: Revision) => + revisionKind(revision) === 'commit' + ? `commit:${'sha' in revision ? revision.sha.toLowerCase() : ''}` + : revision.kind === 'index' + ? `index:${revision.stage ?? 0}` + : 'working-copy'; + +const contentKey = ( + source: ResolvedReviewSource, + generation: string, + revision: Revision, + path: string, + maxBytes: number, +) => { + const kind = revisionKind(revision); + const mutable = kind === 'commit' ? '' : `:${generation}`; + return `${getSourceRevisionKey(source)}:${revisionCoordinate(revision)}:${path}:${maxBytes}${mutable}`; +}; + +const getEffectiveRange = ( + file: Pick, + range: DiffRange | undefined, +): DiffRange => { + if (!range) { + throw new Error('Exact contents are unavailable because this section has no revision range.'); + } + return { + base: file.status === 'added' || file.status === 'untracked' ? null : range.base, + head: file.status === 'deleted' ? null : range.head, + }; +}; + +const getImageMimeType = (path: string) => { + const extension = path.slice(path.lastIndexOf('.')).toLowerCase(); + return new Map([ + ['.apng', 'image/apng'], + ['.avif', 'image/avif'], + ['.bmp', 'image/bmp'], + ['.gif', 'image/gif'], + ['.ico', 'image/x-icon'], + ['.jpeg', 'image/jpeg'], + ['.jpg', 'image/jpeg'], + ['.png', 'image/png'], + ['.webp', 'image/webp'], + ]).get(extension); +}; + +const bytesToBase64 = (bytes: Uint8Array) => { + let binary = ''; + const chunkSize = 0x80_00; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +}; + +export const decodeImageRevision = ( + value: ResolvedRevisionBytes | null, + path: string, +): DiffImageRevision | null => { + if (!value) { + return null; + } + const mimeType = getImageMimeType(path); + if (!mimeType) { + throw new Error('Unsupported image file type.'); + } + if (value.size > imageFileLimit) { + throw new Error( + `Image is larger than ${imageFileLimit} bytes, so Codiff skipped rendering it.`, + ); + } + return { + dataUrl: `data:${mimeType};base64,${bytesToBase64(value.bytes)}`, + mimeType, + name: path, + size: value.size, + }; +}; + +export const decodeTextRevision = ( + value: ResolvedRevisionBytes | null, + path: string, + emptyCacheKey: string, +) => { + if (!value) { + return { cacheKey: emptyCacheKey, contents: '', name: path }; + } + if (value.bytes.includes(0)) { + throw new Error(`Full review context is unavailable for '${path}': the file is binary.`); + } + return { + cacheKey: value.cacheKey, + contents: new TextDecoder().decode(value.bytes), + name: path, + }; +}; + +export const createReviewContentRun = ({ + generation, + source, + transport, +}: { + generation: string; + source: ResolvedReviewSource; + transport: ReviewContentTransport; +}): ReviewContentRun => { + const controller = new AbortController(); + const values = new Map(); + const pending = new Map>(); + const sourceReads = new Map(); + let cacheHits = 0; + let sourceCalls = 0; + + const readRevisionBytesBatch = async ( + requests: ReadonlyArray>, + ) => { + controller.signal.throwIfAborted(); + const keyed = requests.map((request) => ({ + ...request, + key: contentKey(source, generation, request.revision, request.path, request.maxBytes), + })); + const misses = [ + ...new Map( + keyed + .filter((request) => { + if (values.has(request.key) || pending.has(request.key)) { + cacheHits += 1; + return false; + } + return true; + }) + .map((request) => [request.key, request]), + ).values(), + ]; + + if (misses.length > 0) { + sourceCalls += 1; + for (const request of misses) { + sourceReads.set(request.key, (sourceReads.get(request.key) ?? 0) + 1); + } + const batch = transport({ generation, requests: misses, source }).then((result) => { + controller.signal.throwIfAborted(); + return new Map(result.results.map((item) => [item.key, item])); + }); + for (const request of misses) { + const promise = batch + .then((results) => { + const result = results.get(request.key) ?? { + key: request.key, + reason: 'The content reader did not return this revision coordinate.', + status: 'unavailable' as const, + }; + const normalized = + result.status === 'ready' + ? { status: result.status, value: result.value } + : result.status === 'missing' + ? { status: result.status } + : { reason: result.reason, status: result.status }; + values.set(request.key, normalized); + return normalized; + }) + .finally(() => pending.delete(request.key)); + pending.set(request.key, promise); + } + } + + const results = await Promise.all( + keyed.map(async (request) => { + const result = values.get(request.key) ?? (await pending.get(request.key)); + if (!result) { + throw new Error('Revision content cache entry is unavailable.'); + } + if (result.status === 'unavailable') { + throw new Error(result.reason); + } + return result.status === 'ready' ? result.value : null; + }), + ); + controller.signal.throwIfAborted(); + return results; + }; + + const readDiffBytesBatch: ReviewContentRun['readDiffBytesBatch'] = async ( + requests, + maxBytes = manualTextFileLimit, + ) => { + const descriptors = requests.map(({ file, section }) => { + const range = getEffectiveRange(file, section.range); + return { + newRequest: range.head ? { maxBytes, path: file.path, revision: range.head } : null, + oldRequest: range.base + ? { maxBytes, path: file.oldPath ?? file.path, revision: range.base } + : null, + }; + }); + const flatRequests = descriptors.flatMap(({ newRequest, oldRequest }) => [ + ...(newRequest ? [newRequest] : []), + ...(oldRequest ? [oldRequest] : []), + ]); + const flatResults = await readRevisionBytesBatch(flatRequests); + let cursor = 0; + return descriptors.map(({ newRequest, oldRequest }) => ({ + newBytes: newRequest ? (flatResults[cursor++] ?? null) : null, + oldBytes: oldRequest ? (flatResults[cursor++] ?? null) : null, + })); + }; + + const readDiffBytes: ReviewContentRun['readDiffBytes'] = async (file, section, maxBytes) => { + const [result] = await readDiffBytesBatch([{ file, section }], maxBytes); + if (!result) { + throw new Error('Diff content result is unavailable.'); + } + return result; + }; + + const resolveLoadedFiles = ( + file: ChangedFile, + section: DiffSection, + { + newBytes, + oldBytes, + }: { + newBytes: ResolvedRevisionBytes | null; + oldBytes: ResolvedRevisionBytes | null; + }, + ): FileDiffLoadedFiles => { + if (!newBytes && !oldBytes && file.status !== 'added' && file.status !== 'deleted') { + throw new Error(`Full review context is unavailable for '${file.path}'.`); + } + return { + newFile: decodeTextRevision( + newBytes, + file.path, + `${getSourceRevisionKey(source)}:${generation}:${file.path}:empty`, + ), + oldFile: + section.range?.base == null || file.status === 'added' || file.status === 'untracked' + ? null + : decodeTextRevision( + oldBytes, + file.oldPath ?? file.path, + `${getSourceRevisionKey(source)}:${generation}:${file.oldPath ?? file.path}:empty`, + ), + }; + }; + + return { + abort: (reason) => controller.abort(reason), + diagnostics: () => ({ + cacheHits, + sourceCalls, + sourceReads: Object.fromEntries(sourceReads), + }), + readDiffBytes, + readDiffBytesBatch, + resolveImage: async (file, section) => { + try { + const { newBytes, oldBytes } = await readDiffBytes(file, section, imageFileLimit); + const oldImage = decodeImageRevision(oldBytes, file.oldPath ?? file.path); + const newImage = decodeImageRevision(newBytes, file.path); + return oldImage || newImage + ? { + ...(newImage ? { newImage } : {}), + ...(oldImage ? { oldImage } : {}), + status: 'ready', + } + : { reason: 'Codiff could not load either side of this image.', status: 'unavailable' }; + } catch (error) { + return { + reason: error instanceof Error ? error.message : 'Codiff could not load this image.', + status: 'unavailable', + }; + } + }, + resolveSectionContents: async (file, section) => { + const result = await readDiffBytes(file, section); + return resolveLoadedFiles(file, section, result); + }, + resolveSectionContentsBatch: async (requests) => { + const results = await readDiffBytesBatch(requests); + return requests.map(({ file, section }, index) => + resolveLoadedFiles(file, section, results[index]!), + ); + }, + }; +}; diff --git a/core/lib/review-context-expansion.ts b/core/lib/review-context-expansion.ts new file mode 100644 index 00000000..4b7697bf --- /dev/null +++ b/core/lib/review-context-expansion.ts @@ -0,0 +1,45 @@ +import type { ExpansionDirections } from '@pierre/diffs'; + +export type ReviewContextExpansionRegion = { + fromEnd: number; + fromStart: number; +}; + +export type ReviewContextExpansionState = ReadonlyMap; + +const emptyExpansionState = new Map(); + +export const getReviewContextExpansionState = ( + state: ReviewContextExpansionState | undefined, + hunkIndex: number, + direction: ExpansionDirections, + expansionLineCount: number, + expandAll: boolean, +): ReviewContextExpansionState => { + const current = state?.get(hunkIndex) ?? { fromEnd: 0, fromStart: 0 }; + const amount = expandAll ? Number.POSITIVE_INFINITY : expansionLineCount; + const next = new Map(state ?? emptyExpansionState); + next.set(hunkIndex, { + fromEnd: + direction === 'down' || direction === 'both' + ? Math.max(current.fromEnd, amount) + : current.fromEnd, + fromStart: + direction === 'up' || direction === 'both' + ? Math.max(current.fromStart, amount) + : current.fromStart, + }); + return next; +}; + +export const reviewContextExpansionDigest = (state: ReviewContextExpansionState | undefined) => + [...(state ?? emptyExpansionState).entries()] + .toSorted(([firstIndex], [secondIndex]) => firstIndex - secondIndex) + .map(([index, region]) => `${index}:${region.fromStart}:${region.fromEnd}`) + .join('|'); + +export const reviewContextExpansionProjectionKey = ( + projectionKey: string, + fileFingerprint: string, + sectionId: string, +) => `${projectionKey}:${fileFingerprint}:${sectionId}`; diff --git a/core/types/review-identity.ts b/core/types/review-identity.ts index 6968637f..5d573f47 100644 --- a/core/types/review-identity.ts +++ b/core/types/review-identity.ts @@ -106,30 +106,43 @@ export type Revision = /** A null endpoint represents an absent file side, such as an unborn-repository addition. */ export type DiffRange = { base: Revision | null; head: Revision | null }; -export type ReviewContextRequest = { - baseSha: GitSha; - filePath: string; - headSha: GitSha; - oldPath?: string; - range: DiffRange; +export type RevisionContentRequest = { + key: string; + maxBytes: number; + path: string; + revision: Revision; +}; + +export type ResolvedRevisionBytes = { + bytes: Uint8Array; + cacheKey: string; + objectId?: string; + path: string; + provenance: 'filesystem' | 'git-index' | 'github-api' | 'gitlab-api' | 'native-git'; + size: number; +}; + +export type RevisionContentItemResult = + | { key: string; status: 'missing' } + | { key: string; reason: string; status: 'unavailable' } + | { key: string; status: 'ready'; value: ResolvedRevisionBytes }; + +export type RevisionContentBatchRequest = { + generation: string; + requestId?: string; + requests: ReadonlyArray; source: ResolvedReviewSource; - status: GitFileStatus; }; -/** Display-only result used to expand unchanged review context. */ -export type ReviewContextResult = - | { - newFile: NonNullable; - oldFile: NonNullable | null; - status: 'ready'; - } - | { reason: string; status: 'unavailable' }; +export type RevisionContentBatchResult = { + results: ReadonlyArray; +}; + +export type DiffImageRevision = { dataUrl: string; mimeType: string; name: string; size: number }; -/** - * Host capability for resolving unchanged context without mutating captured - * walkthrough provenance or generated-component reuse inputs. - */ -export type ReviewContextResolver = (request: ReviewContextRequest) => Promise; +export type DiffImageContentResult = + | { newImage?: DiffImageRevision; oldImage?: DiffImageRevision; status: 'ready' } + | { reason: string; status: 'unavailable' }; export type GitIdentity = { email: string; @@ -191,9 +204,3 @@ export type DiffImageContentRequest = { requestId?: string; source?: ResolvedReviewSource; }; - -export type DiffImageRevision = { dataUrl: string; mimeType: string; name: string; size: number }; - -export type DiffImageContentResult = - | { newImage?: DiffImageRevision; oldImage?: DiffImageRevision; status: 'ready' } - | { reason: string; status: 'unavailable' }; diff --git a/electron/__tests__/provider-review-state.test.ts b/electron/__tests__/provider-review-state.test.ts index b8a82153..c5cc246a 100644 --- a/electron/__tests__/provider-review-state.test.ts +++ b/electron/__tests__/provider-review-state.test.ts @@ -4,18 +4,24 @@ import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { delimiter, join } from 'node:path'; import { expect, test } from 'vite-plus/test'; +import type { + RepositoryState, + RevisionContentBatchRequest, + RevisionContentBatchResult, +} from '../../core/types.ts'; -const { readPullRequestSectionsContent, readPullRequestState } = - require('../git-state/pull-request.cjs') as { - readPullRequestState: ( - repoRoot: string, - source: { type: 'pull-request'; url: string }, - ) => Promise; - readPullRequestSectionsContent: ( - repoRoot: string, - source: Extract, - ) => Promise; - }; +const { readPullRequestState } = require('../git-state/pull-request.cjs') as { + readPullRequestState: ( + repoRoot: string, + source: { type: 'pull-request'; url: string }, + ) => Promise; +}; +const { readRevisionContent } = require('../review-content.cjs') as { + readRevisionContent: ( + repoRoot: string, + request: RevisionContentBatchRequest, + ) => Promise; +}; const { rangeArtifactToPullRequestFiles } = require('../git-state/review-range-sections.cjs') as { rangeArtifactToPullRequestFiles: ( artifact: import('../../core/index.ts').RangeArtifact, @@ -192,17 +198,13 @@ test('renders a visible unavailable item for a wholly truncated Range Artifact', expect(files[0]?.sections[0]).not.toHaveProperty('range'); }); -const { createGitLabPosition, readMergeRequestSectionsContent, readMergeRequestState } = +const { createGitLabPosition, readMergeRequestState } = require('../git-state/merge-request.cjs') as { createGitLabPosition: (comment: unknown, metadata: unknown, diff?: unknown) => unknown; readMergeRequestState: ( repoRoot: string, source: { provider: 'gitlab'; type: 'pull-request'; url: string }, - ) => Promise; - readMergeRequestSectionsContent: ( - repoRoot: string, - source: Extract, - ) => Promise; + ) => Promise; }; const git = (directory: string, args: ReadonlyArray) => @@ -236,11 +238,42 @@ const createReviewRange = async ( return { baseSha, headSha }; }; +const readStateRevisionContents = (directory: string, state: RepositoryState) => { + const requests = state.files.flatMap((file) => + file.sections.flatMap((section) => [ + ...(section.range?.base + ? [ + { + key: `${file.path}:old`, + maxBytes: 2 * 1024 * 1024, + path: file.oldPath ?? file.path, + revision: section.range.base, + }, + ] + : []), + ...(section.range?.head + ? [ + { + key: `${file.path}:new`, + maxBytes: 2 * 1024 * 1024, + path: file.path, + revision: section.range.head, + }, + ] + : []), + ]), + ); + return readRevisionContent(directory, { + generation: 'provider-state-test', + requests, + source: state.source, + }); +}; + test('returns a GitHub Range Artifact before exact file hydration', async () => { const directory = await createRepository('https://github.com/nkzw-tech/codiff.git'); const fakeGh = join(directory, 'gh'); const callLog = join(directory, 'gh-calls.jsonl'); - const gitTrace = join(directory, 'git-trace.log'); const { baseSha, headSha } = await createReviewRange( directory, { @@ -284,9 +317,7 @@ if (resource.includes('/compare/')) { ); await chmod(fakeGh, 0o755); const previousPath = process.env.PATH; - const previousGitTrace = process.env.GIT_TRACE; process.env.PATH = `${directory}${delimiter}${previousPath ?? ''}`; - process.env.GIT_TRACE = gitTrace; try { const source = { type: 'pull-request', @@ -309,19 +340,18 @@ if (resource.includes('/compare/')) { expect(state.files[0]?.sections[0]).not.toHaveProperty('newFile'); expect(state.files[0]?.sections[0]).not.toHaveProperty('oldFile'); expect(state.reviewComments).toBeUndefined(); - const [hydrated, joinedHydration] = await Promise.all([ - readPullRequestSectionsContent(directory, state.source), - readPullRequestSectionsContent(directory, state.source), - ]); - expect(joinedHydration).toBe(hydrated); - expect(hydrated.headSha).toBe(headSha); - expect(hydrated.sections).toHaveLength(2); - expect(hydrated.sections.map(({ path }) => path)).toEqual(['src/app.ts', 'src/other.ts']); - expect(hydrated.sections.every(({ section }) => section.loadState === 'ready')).toBe(true); - expect(hydrated.sections[0]?.section).toMatchObject({ - newFile: { contents: 'new src/app.ts\n', name: 'src/app.ts' }, - oldFile: { contents: 'old src/app.ts\n', name: 'src/app.ts' }, - }); + const contents = await readStateRevisionContents(directory, state); + expect(contents.results).toHaveLength(4); + expect(contents.results.every((result) => result.status === 'ready')).toBe(true); + const readyContents = new Map( + contents.results.flatMap((result) => + result.status === 'ready' + ? [[result.key, new TextDecoder().decode(result.value.bytes)] as const] + : [], + ), + ); + expect(readyContents.get('src/app.ts:old')).toBe('old src/app.ts\n'); + expect(readyContents.get('src/app.ts:new')).toBe('new src/app.ts\n'); const calls = (await readFile(callLog, 'utf8')) .trim() .split('\n') @@ -332,17 +362,9 @@ if (resource.includes('/compare/')) { expect(calls.flat()).not.toContainEqual( expect.stringMatching(/comments|contents|graphql|application\/vnd\.github\.v3\.diff/), ); - const trace = (await readFile(gitTrace, 'utf8')).split('\n'); - expect(trace.filter((line) => line.includes('built-in: git ls-tree '))).toHaveLength(2); - expect( - trace.filter((line) => line.includes('git cat-file') && line.includes('--batch-check')), - ).toHaveLength(2); - expect(trace.filter((line) => line.endsWith('git cat-file --batch'))).toHaveLength(2); } finally { if (previousPath == null) delete process.env.PATH; else process.env.PATH = previousPath; - if (previousGitTrace == null) delete process.env.GIT_TRACE; - else process.env.GIT_TRACE = previousGitTrace; } }, 30_000); @@ -429,14 +451,9 @@ if (resource.includes('/repository/compare?')) { { new_path: 'src/app.ts', old_path: 'src/app.ts' }, ), ).toMatchObject({ base_sha: baseSha, head_sha: headSha, start_sha: startSha }); - const hydrated = await readMergeRequestSectionsContent(directory, state.source); - expect(hydrated.headSha).toBe(headSha); - expect(hydrated.sections).toHaveLength(2); - expect(hydrated.sections.every(({ section }) => section.loadState === 'ready')).toBe(true); - expect(hydrated.sections[0]?.section).toMatchObject({ - newFile: { contents: 'new src/app.ts\n', name: 'src/app.ts' }, - oldFile: { contents: 'old src/app.ts\n', name: 'src/app.ts' }, - }); + const contents = await readStateRevisionContents(directory, state); + expect(contents.results).toHaveLength(4); + expect(contents.results.every((result) => result.status === 'ready')).toBe(true); const calls = (await readFile(callLog, 'utf8')) .trim() .split('\n') diff --git a/electron/__tests__/review-content.test.ts b/electron/__tests__/review-content.test.ts new file mode 100644 index 00000000..8407b8ca --- /dev/null +++ b/electron/__tests__/review-content.test.ts @@ -0,0 +1,218 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { expect, test, vi } from 'vite-plus/test'; +import { createTemporaryDirectory } from '../../core/__tests__/helpers/resources.ts'; +import type { + GitSha, + Revision, + RevisionContentBatchRequest, + RevisionContentBatchResult, +} from '../../core/types.ts'; + +const require = createRequire(import.meta.url); +const { readRevisionContent } = require('../review-content.cjs') as { + readRevisionContent( + launchPath: string, + batch: RevisionContentBatchRequest, + ): Promise; +}; +const { readGitHubFileBlobArtifacts, readGitLabFileBlobArtifacts } = + require('../git-state/provider-artifact-sources.cjs') as { + readGitHubFileBlobArtifacts( + repoRoot: string, + pull: { headSha?: string; host?: string; number?: number; owner: string; repo: string }, + requests: ReadonlyArray<{ maxBytes: number; path: string; ref: string }>, + transport?: unknown, + ): Promise>; + readGitLabFileBlobArtifacts( + repoRoot: string, + mergeRequest: { headSha?: string; host: string; number?: number; projectPath: string }, + requests: ReadonlyArray<{ maxBytes: number; path: string; ref: string }>, + transport?: unknown, + ): Promise>; + }; + +const git = (repo: string, ...args: Array) => + execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim(); +const gitSha = (value: string) => value as GitSha; +const commitRevision = (sha: string): Revision => ({ + label: { kind: 'commit', text: sha.slice(0, 7) }, + sha: gitSha(sha), +}); + +const createRepository = async () => { + const directory = await createTemporaryDirectory('codiff-review-content-'); + git(directory.path, 'init', '--quiet'); + git(directory.path, 'config', 'user.email', 'codiff@example.com'); + git(directory.path, 'config', 'user.name', 'Codiff Test'); + await mkdir(join(directory.path, 'src'), { recursive: true }); + return directory; +}; + +test('reads commit, index, and working-copy bytes in one revision batch', async () => { + await using directory = await createRepository(); + const path = 'src/file.txt'; + await writeFile(join(directory.path, path), 'committed\n'); + git(directory.path, 'add', path); + git(directory.path, 'commit', '--quiet', '-m', 'Add file'); + const sha = git(directory.path, 'rev-parse', 'HEAD'); + await writeFile(join(directory.path, path), 'indexed\n'); + git(directory.path, 'add', path); + await writeFile(join(directory.path, path), 'working\n'); + const requests = [ + { key: 'commit', maxBytes: 1024, path, revision: commitRevision(sha) }, + { + key: 'index', + maxBytes: 1024, + path, + revision: { + kind: 'index' as const, + label: { kind: 'review-marker' as const, text: 'Index' }, + }, + }, + { + key: 'working', + maxBytes: 1024, + path, + revision: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }, + ]; + + const result = await readRevisionContent(directory.path, { + generation: 'generation-1', + requests, + source: { type: 'working-tree' }, + }); + const contents = new Map( + result.results.flatMap((item) => + item.status === 'ready' + ? [[item.key, new TextDecoder().decode(item.value.bytes)] as const] + : [], + ), + ); + + expect(contents).toEqual( + new Map([ + ['commit', 'committed\n'], + ['index', 'indexed\n'], + ['working', 'working\n'], + ]), + ); + expect(result.results.map((item) => item.status)).toEqual(['ready', 'ready', 'ready']); +}); + +test('returns missing coordinates and bounded-read failures per item', async () => { + await using directory = await createRepository(); + await writeFile(join(directory.path, 'src/large.txt'), 'larger than four bytes'); + + const result = await readRevisionContent(directory.path, { + generation: 'generation-1', + requests: [ + { + key: 'missing', + maxBytes: 1024, + path: 'src/missing.txt', + revision: { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }, + }, + { + key: 'large', + maxBytes: 4, + path: 'src/large.txt', + revision: { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }, + }, + ], + source: { type: 'working-tree' }, + }); + + expect(result.results).toEqual([ + { key: 'missing', status: 'missing' }, + expect.objectContaining({ key: 'large', status: 'unavailable' }), + ]); +}); + +test('uses native Git before GitHub transport and falls back with normalized bytes', async () => { + await using directory = await createRepository(); + await writeFile(join(directory.path, 'src/native.txt'), 'native\n'); + git(directory.path, 'add', '.'); + git(directory.path, 'commit', '--quiet', '-m', 'Add native file'); + const sha = git(directory.path, 'rev-parse', 'HEAD'); + const transport = { + request: vi.fn(async () => ({ + content: Buffer.from('provider\n').toString('base64'), + encoding: 'base64', + sha: 'b'.repeat(40), + })), + }; + const pull = { number: 1, owner: 'example', repo: 'repo' }; + + const native = await readGitHubFileBlobArtifacts( + directory.path, + pull, + [{ maxBytes: 1024, path: 'src/native.txt', ref: sha }], + transport, + ); + const fallback = await readGitHubFileBlobArtifacts( + directory.path, + pull, + [{ maxBytes: 1024, path: 'src/provider.txt', ref: 'a'.repeat(40) }], + transport, + ); + + expect(transport.request).toHaveBeenCalledOnce(); + expect(native.get(`${sha}:src/native.txt`)?.provenance.kind).toBe('native-git'); + expect(new TextDecoder().decode(fallback.get(`${'a'.repeat(40)}:src/provider.txt`)?.bytes)).toBe( + 'provider\n', + ); + expect(fallback.get(`${'a'.repeat(40)}:src/provider.txt`)?.provenance.kind).toBe('github-api'); +}); + +test('normalizes GitLab fallback through the same commit-content adapter shape', async () => { + await using directory = await createRepository(); + const transport = { + request: vi.fn(async () => ({ + blob_id: 'c'.repeat(40), + content: Buffer.from('gitlab\n').toString('base64'), + encoding: 'base64', + })), + }; + const ref = 'd'.repeat(40); + const result = await readGitLabFileBlobArtifacts( + directory.path, + { host: 'gitlab.example.com', projectPath: 'group/project' }, + [{ maxBytes: 1024, path: 'src/provider.txt', ref }], + transport, + ); + const blob = result.get(`${ref}:src/provider.txt`); + + expect(new TextDecoder().decode(blob?.bytes)).toBe('gitlab\n'); + expect(blob?.provenance.kind).toBe('gitlab-api'); +}); + +test('rejects provider fallback after the logical review head changes', async () => { + await using directory = await createRepository(); + const expectedHead = 'a'.repeat(40); + const transport = { + request: vi.fn(async () => ({ head: { sha: 'b'.repeat(40) } })), + }; + + await expect( + readGitHubFileBlobArtifacts( + directory.path, + { headSha: expectedHead, number: 1, owner: 'example', repo: 'repo' }, + [{ maxBytes: 1024, path: 'src/provider.txt', ref: expectedHead }], + transport, + ), + ).rejects.toThrow('head changed'); + expect(transport.request).toHaveBeenCalledOnce(); +}); diff --git a/electron/git-state.cjs b/electron/git-state.cjs index c6c57e5c..cf92f08f 100644 --- a/electron/git-state.cjs +++ b/electron/git-state.cjs @@ -9,18 +9,10 @@ const { } = require('./git-state/common.cjs'); const { listRepositoryHistory, - readBranchImageContent, - readBranchSectionContent, readBranchState, - readBranchWorkingTreeImageContent, - readBranchWorkingTreeSectionContent, readBranchWorkingTreeState, - readCommitImageContent, - readCommitSectionContent, readCommitState, readResolvedCommitState, - readRangeImageContent, - readRangeSectionContent, readRangeState, } = require('./git-state/commit.cjs'); const { @@ -34,18 +26,13 @@ const { collectResolvedReviewCommentIds, createPullRequestHistoryFetchRefspecs, createPullRequestSource, - getPullRequestHeadImageSource, listPullRequestHistory, normalizeGitHubPullRequestCommit, normalizeGitHubReviewComment, normalizePullRequestComment, parseGitHubPullRequestUrl, - readPullRequestImageContent, readPullRequestReviewComments, - readPullRequestSectionContent, - readPullRequestSectionsContent, readPullRequestState, - resolvePullRequestContentRefs, selectUnresolvedReviewComments, submitPullRequestComment, submitPullRequestReview, @@ -57,28 +44,17 @@ const { listMergeRequestHistory, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, - readMergeRequestImageContent, readMergeRequestReviewComments, - readMergeRequestSectionContent, - readMergeRequestSectionsContent, readMergeRequestState, submitMergeRequestComment, submitMergeRequestReview, } = require('./git-state/merge-request.cjs'); const { parseReviewUrl } = require('./review-source.cjs'); -const { - readDiffSectionContent: readWorkingTreeDiffSectionContent, - readDiffImageContent: readWorkingTreeDiffImageContent, - readGitIdentity, - readWorkingTreeState, -} = require('./git-state/working-tree.cjs'); +const { readGitIdentity, readWorkingTreeState } = require('./git-state/working-tree.cjs'); const { annotateGeneratedFiles } = require('./generated-files.cjs'); +const { readRevisionContent } = require('./review-content.cjs'); /** - * @typedef {import('../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest - * @typedef {import('../core/types.ts').DiffSectionsContentRequest} DiffSectionsContentRequest - * @typedef {import('../core/types.ts').DiffImageContentRequest} DiffImageContentRequest - * @typedef {import('../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../core/types.ts').RepositoryHistory} RepositoryHistory * @typedef {import('../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../core/types.ts').ReviewSource} ReviewSource @@ -220,78 +196,6 @@ const readReviewComments = (launchPath, source) => source, ); -/** @param {string} launchPath @param {DiffSectionContentRequest} request */ -const readDiffSectionContent = async (launchPath, request) => - request.source?.type === 'pull-request' - ? (isGitLabReviewSource(request.source) - ? readMergeRequestSectionContent - : readPullRequestSectionContent)(launchPath, request.source, request.path, { - force: request.force, - }) - : request.source?.type === 'range' - ? readRangeSectionContent( - launchPath, - request.source.base, - request.source.head, - request.source.symmetric, - request.path, - { force: request.force }, - ) - : request.source?.type === 'branch' || request.source?.type === 'branch-diff' - ? readBranchSectionContent(launchPath, request.source, request.path, { - force: request.force, - }) - : request.source?.type === 'branch-working-tree' - ? readBranchWorkingTreeSectionContent(launchPath, request) - : request.kind === 'commit' || request.source?.type === 'commit' - ? readCommitSectionContent( - launchPath, - request.source?.type === 'commit' ? request.source.sha : 'HEAD', - request.path, - { - force: request.force, - }, - ) - : readWorkingTreeDiffSectionContent(launchPath, request); - -/** @param {string} launchPath @param {DiffSectionsContentRequest} request */ -const readDiffSectionsContent = (launchPath, request) => { - if (request.source?.type !== 'pull-request') { - throw new Error('Bulk diff hydration requires a pull-request source.'); - } - return ( - isGitLabReviewSource(request.source) - ? readMergeRequestSectionsContent - : readPullRequestSectionsContent - )(launchPath, request.source); -}; - -/** @param {string} launchPath @param {DiffImageContentRequest} request @returns {Promise} */ -const readDiffImageContent = (launchPath, request) => - request.source?.type === 'pull-request' - ? (isGitLabReviewSource(request.source) - ? readMergeRequestImageContent - : readPullRequestImageContent)(launchPath, request.source, request.path) - : request.source?.type === 'range' - ? readRangeImageContent( - launchPath, - request.source.base, - request.source.head, - request.source.symmetric, - request.path, - ) - : request.source?.type === 'branch' || request.source?.type === 'branch-diff' - ? readBranchImageContent(launchPath, request.source, request.path) - : request.source?.type === 'branch-working-tree' - ? readBranchWorkingTreeImageContent(launchPath, request) - : request.kind === 'commit' || request.source?.type === 'commit' - ? readCommitImageContent( - launchPath, - request.source?.type === 'commit' ? request.source.sha : 'HEAD', - request.path, - ) - : readWorkingTreeDiffImageContent(launchPath, request); - module.exports = { PENDING_REVIEW_COMMENT_ERROR, collectResolvedReviewCommentIds, @@ -300,7 +204,6 @@ module.exports = { createMergeRequestFetchRefspecs, createPullRequestSection, createPullRequestSource, - getPullRequestHeadImageSource, listRepositoryHistory: readRepositoryHistory, normalizeGitHubPullRequestCommit, normalizeGitHubReviewComment, @@ -311,17 +214,14 @@ module.exports = { parseGitLabMergeRequestUrl, selectUnresolvedReviewComments, readBranchState, - readDiffSectionContent, - readDiffSectionsContent, - readDiffImageContent, readGitIdentity, readReviewComments, readCommitState, readPullRequestState, + readRevisionContent, readRepositoryState, readWalkthroughRepositoryState, readWorkingTreeState, - resolvePullRequestContentRefs, runWithCommandSignal, submitPullRequestComment: (launchPath, request) => (isGitLabReviewSource(request.source) ? submitMergeRequestComment : submitPullRequestComment)( diff --git a/electron/git-state/commit.cjs b/electron/git-state/commit.cjs index 0a49a289..4b4a44fa 100644 --- a/electron/git-state/commit.cjs +++ b/electron/git-state/commit.cjs @@ -1,28 +1,17 @@ // @ts-check const { fileSort, getFingerprint, getGravatarHash, git, normalizeStatus } = require('./common.cjs'); -const { - readComparisonImageContent, - readComparisonSectionContent, - readComparisonState, -} = require('./comparison.cjs'); +const { readComparisonState } = require('./comparison.cjs'); const { readCommitMetadataForCommit } = require('./commit-metadata.cjs'); const { applyGeneratedAttributeStates, readRevisionGeneratedAttributeStates, } = require('../generated-files.cjs'); -const { - readDiffImageContent: readWorkingTreeDiffImageContent, - readDiffSectionContent: readWorkingTreeDiffSectionContent, - readWorkingTreeState, -} = require('./working-tree.cjs'); +const { readWorkingTreeState } = require('./working-tree.cjs'); const { transferRepositoryWatcherInitialSnapshot } = require('../repository-watcher.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile - * @typedef {import('../../core/types.ts').DiffImageContentRequest} DiffImageContentRequest - * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult - * @typedef {import('../../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../../core/types.ts').ResolvedReviewSource} ResolvedReviewSource @@ -369,55 +358,6 @@ const readComparisonSourceState = async (launchPath, source) => { return applyGeneratedAttributeStates(state, generatedAttributeStates); }; -/** - * @param {string} launchPath - * @param {ComparisonSource} source - * @param {string} requestedPath - * @param {{force?: boolean}} [options] - */ -const readComparisonSourceSectionContent = async ( - launchPath, - source, - requestedPath, - options = {}, -) => { - const comparison = await readResolvedComparison(launchPath, source); - return readComparisonSectionContent( - comparison.repoRoot, - comparison.newSha, - comparison.oldSha, - comparison.status, - requestedPath, - comparison.sourceLabel, - options, - ); -}; - -/** - * @param {string} launchPath - * @param {ComparisonSource} source - * @param {string} requestedPath - * @returns {Promise} - */ -const readComparisonSourceImageContent = async (launchPath, source, requestedPath) => { - try { - const comparison = await readResolvedComparison(launchPath, source); - return await readComparisonImageContent( - comparison.repoRoot, - comparison.newSha, - comparison.oldSha, - comparison.status, - requestedPath, - comparison.sourceLabel, - ); - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; - /** @param {string} launchPath @param {ResolvedComparison} comparison */ const readCommitStateFromComparison = async (launchPath, comparison) => { const [commitMetadata, state, generatedAttributeStates] = await Promise.all([ @@ -453,24 +393,6 @@ const readCommitState = async (launchPath, ref) => const readResolvedCommitState = async (launchPath, repoRoot, commit) => readCommitStateFromComparison(launchPath, await readResolvedCommitComparison(repoRoot, commit)); -/** - * @param {string} launchPath - * @param {string} ref - * @param {string} requestedPath - * @param {{force?: boolean}} [options] - */ -const readCommitSectionContent = (launchPath, ref, requestedPath, options = {}) => - readComparisonSourceSectionContent(launchPath, { ref, type: 'commit' }, requestedPath, options); - -/** - * @param {string} launchPath - * @param {string} ref - * @param {string} requestedPath - * @returns {Promise} - */ -const readCommitImageContent = (launchPath, ref, requestedPath) => - readComparisonSourceImageContent(launchPath, { ref, type: 'commit' }, requestedPath); - /** * @param {string} launchPath @param {string} base @param {string} head @param {boolean} symmetric * @returns {Promise} @@ -483,65 +405,10 @@ const readRangeState = (launchPath, base, head, symmetric) => type: 'range', }); -/** - * @param {string} launchPath @param {string} base @param {string} head @param {boolean} symmetric @param {string} requestedPath @param {{encoding?: BufferEncoding, force?: boolean}} [options] - */ -const readRangeSectionContent = (launchPath, base, head, symmetric, requestedPath, options = {}) => - readComparisonSourceSectionContent( - launchPath, - { - base, - head, - symmetric, - type: 'range', - }, - requestedPath, - options, - ); - -/** - * @param {string} launchPath @param {string} base @param {string} head @param {boolean} symmetric @param {string} requestedPath - * @returns {Promise} - */ -const readRangeImageContent = (launchPath, base, head, symmetric, requestedPath) => - readComparisonSourceImageContent( - launchPath, - { - base, - head, - symmetric, - type: 'range', - }, - requestedPath, - ); - /** @param {string} launchPath @param {string | BranchSource | BranchDiffSource} input @returns {Promise} */ const readBranchState = (launchPath, input) => readComparisonSourceState(launchPath, normalizeBranchSourceInput(input)); -/** - * @param {string} launchPath - * @param {string | BranchSource | BranchDiffSource} input - * @param {string} requestedPath - * @param {{force?: boolean}} [options] - */ -const readBranchSectionContent = (launchPath, input, requestedPath, options = {}) => - readComparisonSourceSectionContent( - launchPath, - normalizeBranchSourceInput(input), - requestedPath, - options, - ); - -/** - * @param {string} launchPath - * @param {string | BranchSource | BranchDiffSource} input - * @param {string} requestedPath - * @returns {Promise} - */ -const readBranchImageContent = (launchPath, input, requestedPath) => - readComparisonSourceImageContent(launchPath, normalizeBranchSourceInput(input), requestedPath); - /** * Reduce a `branch-working-tree` input (which may or may not already carry a * resolved baseSha/headSha) down to the plain branch/branch-diff shape that @@ -652,48 +519,6 @@ const readBranchWorkingTreeState = async (launchPath, input, options = {}) => { return mergeBranchAndWorkingTreeState(branchState, workingTreeState); }; -/** - * By the time a section/image content request comes in for a - * `branch-working-tree` source, that source is always the fully resolved - * copy round-tripped from `RepositoryState.source` (baseSha/headSha are only - * absent momentarily, at CLI-argument construction time, before the initial - * state has been read). - * @param {BranchWorkingTreeSource} source - * @returns {BranchDiffSource} - */ -const toResolvedBranchDiffSource = (source) => { - if (!source.baseSha || !source.headSha) { - throw new Error('Cannot load branch-working-tree content before the branch diff is resolved.'); - } - - return { baseSha: source.baseSha, headSha: source.headSha, ref: source.ref, type: 'branch-diff' }; -}; - -/** - * @param {string} launchPath - * @param {DiffSectionContentRequest} request - */ -const readBranchWorkingTreeSectionContent = (launchPath, request) => { - const source = /** @type {BranchWorkingTreeSource} */ (request.source); - return request.kind === 'staged' || request.kind === 'unstaged' - ? readWorkingTreeDiffSectionContent(launchPath, request) - : readBranchSectionContent(launchPath, toResolvedBranchDiffSource(source), request.path, { - force: request.force, - }); -}; - -/** - * @param {string} launchPath - * @param {DiffImageContentRequest} request - * @returns {Promise} - */ -const readBranchWorkingTreeImageContent = (launchPath, request) => { - const source = /** @type {BranchWorkingTreeSource} */ (request.source); - return request.kind === 'staged' || request.kind === 'unstaged' - ? readWorkingTreeDiffImageContent(launchPath, request) - : readBranchImageContent(launchPath, toResolvedBranchDiffSource(source), request.path); -}; - /** @param {string} launchPath @param {number} [limit] @param {string} [ref] */ const listRepositoryHistory = async (launchPath, limit = 200, ref = 'HEAD') => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); @@ -746,17 +571,9 @@ const listRepositoryHistory = async (launchPath, limit = 200, ref = 'HEAD') => { module.exports = { listRepositoryHistory, - readBranchImageContent, - readBranchSectionContent, readBranchState, - readBranchWorkingTreeImageContent, - readBranchWorkingTreeSectionContent, readBranchWorkingTreeState, - readCommitImageContent, - readCommitSectionContent, readCommitState, readResolvedCommitState, - readRangeImageContent, - readRangeSectionContent, readRangeState, }; diff --git a/electron/git-state/common.cjs b/electron/git-state/common.cjs index 5ebcdd55..7081028d 100644 --- a/electron/git-state/common.cjs +++ b/electron/git-state/common.cjs @@ -17,9 +17,7 @@ const runWithCommandSignal = (signal, callback) => commandSignalStorage.run(sign /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile - * @typedef {import('../../core/types.ts').DiffImageRevision} DiffImageRevision * @typedef {import('../../core/types.ts').DiffSection} DiffSection - * @typedef {import('../../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest * @typedef {import('../../core/types.ts').GitFileStatus} GitFileStatus * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState @@ -124,19 +122,7 @@ const gitBufferWithInput = (repoPath, args, input, options = {}) => const EAGER_TEXT_FILE_LIMIT = 1024 * 1024; const MANUAL_TEXT_FILE_LIMIT = 2 * 1024 * 1024; -const IMAGE_FILE_LIMIT = 32 * 1024 * 1024; const MAX_UNTRACKED_INITIAL_ITEMS = 1000; -const imageMimeTypes = new Map([ - ['.apng', 'image/apng'], - ['.avif', 'image/avif'], - ['.bmp', 'image/bmp'], - ['.gif', 'image/gif'], - ['.ico', 'image/x-icon'], - ['.jpeg', 'image/jpeg'], - ['.jpg', 'image/jpeg'], - ['.png', 'image/png'], - ['.webp', 'image/webp'], -]); const GENERATED_DIRECTORY_NAMES = new Set([ '.cache', '.next', @@ -270,35 +256,6 @@ const formatBytes = (size) => { return `${size} B`; }; -/** @param {string} path */ -const getImageMimeType = (path) => { - const dotIndex = path.lastIndexOf('.'); - return dotIndex === -1 ? undefined : imageMimeTypes.get(path.slice(dotIndex).toLowerCase()); -}; - -/** - * @param {string} path - * @param {Buffer} buffer - * @returns {DiffImageRevision} - */ -const bufferToImageRevision = (path, buffer) => { - const mimeType = getImageMimeType(path); - if (!mimeType) { - throw new Error('Unsupported image file type.'); - } - - if (buffer.length > IMAGE_FILE_LIMIT) { - throw new Error(`Image is ${formatBytes(buffer.length)}, so Codiff skipped rendering it.`); - } - - return { - dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, - mimeType, - name: path, - size: buffer.length, - }; -}; - /** @param {string} reason @param {Partial} [details] @returns {DiffSummary} */ const createSummary = (reason, details = {}) => ({ reason, @@ -368,60 +325,6 @@ const bufferToTextFile = (name, buffer, cacheKey) => { }; }; -/** - * @param {string} repoRoot - * @param {string} spec - * @param {string} path - * @returns {Promise} - */ -const readImageSpec = async (repoRoot, spec, path) => { - const mimeType = getImageMimeType(path); - if (!mimeType) { - throw new Error('Unsupported image file type.'); - } - - const size = await getBlobSize(repoRoot, spec); - if (size == null) { - return undefined; - } - - if (size > IMAGE_FILE_LIMIT) { - throw new Error(`Image is ${formatBytes(size)}, so Codiff skipped rendering it.`); - } - - try { - return bufferToImageRevision(path, await gitBuffer(repoRoot, ['show', spec])); - } catch { - return undefined; - } -}; - -/** @param {string} repoRoot @param {string} ref @param {string} path */ -const readGitImageFile = (repoRoot, ref, path) => readImageSpec(repoRoot, `${ref}:${path}`, path); - -/** @param {string} repoRoot @param {string} path @param {1 | 2 | 3} [stage] */ -const readIndexImageFile = (repoRoot, path, stage) => - readImageSpec(repoRoot, stage ? `:${stage}:${path}` : `:${path}`, path); - -/** @param {string} repoRoot @param {string} path */ -const readWorkingTreeImageFile = async (repoRoot, path) => { - const mimeType = getImageMimeType(path); - if (!mimeType) { - throw new Error('Unsupported image file type.'); - } - - const stat = await readFileStat(repoRoot, path); - if (!stat || !stat.isFile()) { - return undefined; - } - - if (stat.size > IMAGE_FILE_LIMIT) { - throw new Error(`Image is ${formatBytes(stat.size)}, so Codiff skipped rendering it.`); - } - - return bufferToImageRevision(path, await fs.readFile(join(repoRoot, path))); -}; - /** * @param {string} repoRoot * @param {string} ref @@ -910,11 +813,9 @@ const gitOrEmpty = async (repoRoot, args) => { module.exports = { EAGER_TEXT_FILE_LIMIT, - IMAGE_FILE_LIMIT, MANUAL_TEXT_FILE_LIMIT, MAX_UNTRACKED_INITIAL_ITEMS, bufferToTextFile, - bufferToImageRevision, createSection, createSummary, fileSort, @@ -924,7 +825,6 @@ module.exports = { getCurrentCommandSignal, getFingerprint, getGravatarHash, - getImageMimeType, getWhitespaceDiffArgs, git, gitBufferWithInput, @@ -933,9 +833,6 @@ module.exports = { parseStatus, readFileStat, readGitFile, - readGitImageFile, - readIndexImageFile, - readWorkingTreeImageFile, runWithCommandSignal, summarizeContent, validateRepositoryPath, diff --git a/electron/git-state/comparison.cjs b/electron/git-state/comparison.cjs index 7baab24d..bb8e045b 100644 --- a/electron/git-state/comparison.cjs +++ b/electron/git-state/comparison.cjs @@ -1,13 +1,6 @@ // @ts-check -const { - fileSort, - getFingerprint, - git, - readGitImageFile, - summarizeContent, - validateRepositoryPath, -} = require('./common.cjs'); +const { fileSort, getFingerprint, git, summarizeContent } = require('./common.cjs'); const { createEmptyFileContent, readGitFiles } = require('./git-files.cjs'); /** @@ -222,99 +215,6 @@ const readComparisonState = async ({ launchPath, newSha, oldSha, repoRoot, sourc }; }; -/** - * @param {string} repoRoot - * @param {GitSha} newSha - * @param {GitSha | undefined} oldSha - * @param {ReadonlyArray>} status - * @param {string} requestedPath - * @param {string} sourceLabel - * @param {{force?: boolean}} [options] - */ -const readComparisonSectionContent = async ( - repoRoot, - newSha, - oldSha, - status, - requestedPath, - sourceLabel, - options = {}, -) => { - const path = validateRepositoryPath(requestedPath); - const item = status.find((candidate) => candidate.path === path); - if (!item) { - throw new Error(`File is not part of this ${sourceLabel}.`); - } - - const { oldFiles, newFiles } = await readComparisonFiles( - repoRoot, - newSha, - oldSha, - [item], - options, - ); - const oldFile = getOldComparisonFile(oldFiles, oldSha, item); - const newFile = newFiles.get(item.path) || createEmptyFileContent(item.path); - const summary = summarizeContent(oldFile, newFile); - const patch = - summary.loadState === 'ready' - ? await readComparisonPatch(repoRoot, newSha, oldSha, item.path) - : ''; - - return createComparisonSection(newSha, oldSha, item, oldFile, newFile, patch); -}; - -/** - * @param {string} repoRoot - * @param {GitSha} newSha - * @param {GitSha | undefined} oldSha - * @param {ReadonlyArray>} status - * @param {string} requestedPath - * @param {string} sourceLabel - * @returns {Promise} - */ -const readComparisonImageContent = async ( - repoRoot, - newSha, - oldSha, - status, - requestedPath, - sourceLabel, -) => { - try { - const path = validateRepositoryPath(requestedPath); - const item = status.find((candidate) => candidate.path === path); - if (!item) { - throw new Error(`File is not part of this ${sourceLabel}.`); - } - - const [oldImage, newImage] = await Promise.all([ - oldSha ? readGitImageFile(repoRoot, oldSha, item.oldPath || item.path) : undefined, - readGitImageFile(repoRoot, newSha, item.path), - ]); - - if (!oldImage && !newImage) { - return { - reason: 'Codiff could not load either side of this image.', - status: 'unavailable', - }; - } - - return { - ...(newImage ? { newImage } : {}), - ...(oldImage ? { oldImage } : {}), - status: 'ready', - }; - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; - module.exports = { - readComparisonImageContent, - readComparisonSectionContent, readComparisonState, }; diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index 34d18670..487188c2 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -1,13 +1,6 @@ // @ts-check -const { - getCurrentCommandSignal, - git, - gitOrEmpty, - readGitImageFile, - validateRepositoryPath, -} = require('./common.cjs'); -const { readGitFiles } = require('./git-files.cjs'); +const { getCurrentCommandSignal, git } = require('./common.cjs'); const { createGitLabPosition, createGitLabReviewMutations, @@ -16,18 +9,12 @@ const { const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); const { normalizeGitHubCommit } = require('./pull-request.cjs'); -const { - canHydrateArtifactFile, - createPullRequestSection, - isBinaryDiffPatch, - rangeArtifactToPullRequestFiles, -} = require('./review-range-sections.cjs'); +const { rangeArtifactToPullRequestFiles } = require('./review-range-sections.cjs'); const { parseReviewUrl, readReviewRemotes } = require('../review-source.cjs'); /** * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource - * @typedef {import('../../core/lib/review-artifacts.ts').ArtifactFile} ArtifactFile */ /** @@ -37,10 +24,6 @@ const { parseReviewUrl, readReviewRemotes } = require('../review-source.cjs'); const mergeRequestHydrationSnapshots = new Map(); const MAX_MERGE_REQUEST_HYDRATION_SNAPSHOTS = 8; -/** @type {Map>} */ -const mergeRequestBulkHydrations = new Map(); -const MAX_MERGE_REQUEST_BULK_HYDRATIONS = 8; - /** @param {string} value */ const parseGitLabMergeRequestUrl = (value) => { const parsed = parseReviewUrl(value); @@ -298,181 +281,6 @@ const createMergeRequestFetchRefspecs = (mergeRequest, metadata) => [ : []), ]; -/** @param {string} repoRoot @param {any} remote @param {any} mergeRequest @param {any} metadata */ -const fetchMergeRequestRefs = (repoRoot, remote, mergeRequest, metadata) => - git(repoRoot, [ - 'fetch', - '--no-tags', - remote.name, - ...createMergeRequestFetchRefspecs(mergeRequest, metadata), - ]); - -/** @param {string} repoRoot @param {any} mergeRequest @param {any} metadata */ -const resolveMergeRequestContentRefs = async (repoRoot, mergeRequest, metadata) => { - const head = `refs/codiff/merge-requests/${mergeRequest.number}/head`; - const base = `refs/codiff/merge-requests/${mergeRequest.number}/base`; - const localHead = (await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', head])).trim(); - const localBase = (await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', base])).trim(); - if (!localHead || !localBase || (metadata.sha && localHead !== metadata.sha)) { - await fetchMergeRequestRefs( - repoRoot, - selectMergeRequestRemote(repoRoot, mergeRequest), - mergeRequest, - metadata, - ); - } - const expectedHead = metadata.diff_refs?.head_sha || metadata.sha; - const resolvedHead = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', head]) - ).trim(); - const resolvedBase = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', base]) - ).trim(); - if (!resolvedHead || !resolvedBase || (expectedHead && resolvedHead !== expectedHead)) { - return null; - } - const metadataBase = metadata.diff_refs?.base_sha; - if ( - metadataBase && - (await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', `${metadataBase}^{commit}`])) - ) { - return { base: metadataBase, head: resolvedHead }; - } - const mergeBase = (await gitOrEmpty(repoRoot, ['merge-base', base, head])).trim(); - return mergeBase ? { base: mergeBase, head: resolvedHead } : null; -}; - -/** - * Hydrate eligible files from one immutable MR range with one pair of batched - * Git object reads. - * - * @param {string} repoRoot - * @param {ReturnType} mergeRequest - * @param {any} metadata - * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range - * @param {ReadonlyArray} files - * @param {{force?: boolean}} [options] - */ -const hydrateMergeRequestSections = async ( - repoRoot, - mergeRequest, - metadata, - range, - files, - options = {}, -) => { - const candidates = files.filter( - (file) => canHydrateArtifactFile(file) && !isBinaryDiffPatch(file.patch || ''), - ); - const refs = await resolveMergeRequestContentRefs(repoRoot, mergeRequest, metadata).catch( - () => null, - ); - if (!refs) { - return candidates.map((file) => ({ - path: file.path, - section: createPullRequestSection(mergeRequest, file, undefined, undefined, { - base: range.baseSha, - contentAttempted: true, - contentError: - 'Codiff could not resolve the immutable merge request range. Retry exact content loading.', - head: range.headSha, - }), - })); - } - - const oldPaths = candidates.map((file) => file.oldPath || file.path); - const newPaths = candidates.map((file) => file.path); - const [oldFiles, newFiles] = await Promise.all([ - readGitFiles(repoRoot, refs.base, oldPaths, { - force: options.force, - refScopedEmptyCacheKey: true, - }), - readGitFiles(repoRoot, refs.head, newPaths, { - force: options.force, - refScopedEmptyCacheKey: true, - }), - ]); - return candidates.map((file) => { - const oldPath = file.oldPath || file.path; - return { - path: file.path, - section: createPullRequestSection( - mergeRequest, - file, - oldFiles.get(oldPath), - newFiles.get(file.path), - { base: range.baseSha, head: range.headSha }, - ), - }; - }); -}; - -/** - * @param {string} repoRoot - * @param {ReturnType} mergeRequest - * @param {any} metadata - * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range - * @param {ArtifactFile} file - * @param {{force?: boolean}} [options] - */ -const hydrateMergeRequestSection = async ( - repoRoot, - mergeRequest, - metadata, - range, - file, - options = {}, -) => { - const [result] = await hydrateMergeRequestSections( - repoRoot, - mergeRequest, - metadata, - range, - [file], - options, - ); - return ( - result?.section ?? - createPullRequestSection(mergeRequest, file, undefined, undefined, { - base: range.baseSha, - head: range.headSha, - }) - ); -}; - -/** @param {string} launchPath @param {Extract} source */ -const readMergeRequestSectionsContent = async (launchPath, source) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const mergeRequest = parseGitLabMergeRequestUrl(source.url); - selectMergeRequestRemote(repoRoot, mergeRequest); - const { metadata, range } = await readMergeRequestHydrationSnapshot(repoRoot, mergeRequest, { - expectedHeadSha: source.headSha, - }); - const key = `${repoRoot}:${mergeRequest.url}:${range.headSha}`; - const existing = mergeRequestBulkHydrations.get(key); - if (existing) { - return existing; - } - - const hydration = hydrateMergeRequestSections( - repoRoot, - mergeRequest, - metadata, - range, - range.files, - ) - .then((sections) => ({ headSha: range.headSha, sections })) - .catch((error) => { - mergeRequestBulkHydrations.delete(key); - throw error; - }); - mergeRequestBulkHydrations.set(key, hydration); - while (mergeRequestBulkHydrations.size > MAX_MERGE_REQUEST_BULK_HYDRATIONS) { - mergeRequestBulkHydrations.delete(mergeRequestBulkHydrations.keys().next().value); - } - return hydration; -}; - /** @param {string} launchPath @param {Extract} source */ const readMergeRequestState = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); @@ -509,27 +317,6 @@ const readMergeRequestReviewComments = async (launchPath, source) => { return comments; }; -/** - * Load exact local contents for one merge-request file when explicitly retried. - * @param {string} launchPath - * @param {Extract} source - * @param {string} requestedPath - */ -const readMergeRequestSectionContent = async (launchPath, source, requestedPath, options = {}) => { - const path = validateRepositoryPath(requestedPath); - const mergeRequest = parseGitLabMergeRequestUrl(source.url); - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - selectMergeRequestRemote(repoRoot, mergeRequest); - const { metadata, range } = await readMergeRequestHydrationSnapshot(repoRoot, mergeRequest, { - expectedHeadSha: source.headSha, - }); - const file = range.files.find((candidate) => candidate.path === path); - if (!file) { - throw new Error('File is not part of this merge request.'); - } - return hydrateMergeRequestSection(repoRoot, mergeRequest, metadata, range, file, options); -}; - /** @param {any} commit @param {'base' | 'pull-request'} scope */ const normalizeGitLabCommit = (commit, scope) => normalizeGitHubCommit( @@ -610,35 +397,6 @@ const { submitMergeRequestComment, submitMergeRequestReview } = createGitLabRevi selectMergeRequestRemote, }); -/** @param {string} launchPath @param {Extract} source @param {string} requestedPath */ -const readMergeRequestImageContent = async (launchPath, source, requestedPath) => { - try { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const path = validateRepositoryPath(requestedPath); - const mergeRequest = parseGitLabMergeRequestUrl(source.url); - const { metadata, range } = await readMergeRequestHydrationSnapshot(repoRoot, mergeRequest, { - expectedHeadSha: source.headSha, - }); - const file = range.files.find((candidate) => candidate.path === path); - if (!file) { - throw new Error('File is not part of this merge request.'); - } - const refs = await resolveMergeRequestContentRefs(repoRoot, mergeRequest, metadata); - const [oldImage, newImage] = await Promise.all([ - refs ? readGitImageFile(repoRoot, refs.base, file.oldPath || file.path) : undefined, - refs ? readGitImageFile(repoRoot, refs.head, file.path) : undefined, - ]); - return oldImage || newImage - ? { ...(newImage ? { newImage } : {}), ...(oldImage ? { oldImage } : {}), status: 'ready' } - : { reason: 'Codiff could not load either side of this image.', status: 'unavailable' }; - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; - module.exports = { createGitLabPosition, createMergeRequestFetchRefspecs, @@ -647,10 +405,7 @@ module.exports = { normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, resolveGitLabCommentTarget, - readMergeRequestImageContent, readMergeRequestReviewComments, - readMergeRequestSectionContent, - readMergeRequestSectionsContent, readMergeRequestState, submitMergeRequestComment, submitMergeRequestReview, diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index e3af7a0a..79dc738a 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -1,27 +1,8 @@ // @ts-check -const { - IMAGE_FILE_LIMIT, - bufferToImageRevision, - formatBytes, - getImageMimeType, - git, - gitOrEmpty, - getCurrentCommandSignal, - validateRepositoryPath, -} = require('./common.cjs'); -const { readGitFiles } = require('./git-files.cjs'); -const { - canHydrateArtifactFile, - createPullRequestSection, - isBinaryDiffPatch, - rangeArtifactToPullRequestFiles, -} = require('./review-range-sections.cjs'); -const { - createGhGitHubTransport, - runGhApi, - runGhApiBuffer, -} = require('./github-history/gh-github-transport.cjs'); +const { git, gitOrEmpty, getCurrentCommandSignal } = require('./common.cjs'); +const { rangeArtifactToPullRequestFiles } = require('./review-range-sections.cjs'); +const { createGhGitHubTransport, runGhApi } = require('./github-history/gh-github-transport.cjs'); const { PENDING_REVIEW_COMMENT_ERROR, createGitHubReviewMutations, @@ -31,12 +12,10 @@ const { loadGitHubHistory } = require('../github-history-bridge.cjs'); const { parseReviewUrl } = require('../review-source.cjs'); /** - * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('../../core/types.ts').HistoryEntry} HistoryEntry * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource - * @typedef {import('../../core/lib/review-artifacts.ts').ArtifactFile} ArtifactFile * @typedef {{owner: string; repo: string}} GitHubRepositoryReference * @typedef {{name: string; url: string}} LocalGitRemote * @typedef {{full_name?: string; name?: string; owner?: {login?: string}}} GitHubRepositoryMetadata @@ -59,10 +38,6 @@ const { parseReviewUrl } = require('../review-source.cjs'); const pullRequestHydrationSnapshots = new Map(); const MAX_PULL_REQUEST_HYDRATION_SNAPSHOTS = 8; -/** @type {Map>} */ -const pullRequestBulkHydrations = new Map(); -const MAX_PULL_REQUEST_BULK_HYDRATIONS = 8; - /** @param {string} value @returns {PullRequestReference} */ const parseGitHubPullRequestUrl = (value) => { const parsed = parseReviewUrl(value); @@ -258,22 +233,6 @@ const fetchPullRequestHistoryRefs = (repoRoot, remote, pullRequest, metadata) => */ const ghApi = (repoRoot, args, input) => runGhApi(repoRoot, args, input); -/** - * @param {string} repoRoot - * @param {ReadonlyArray} args - * @returns {Promise} - */ -const ghApiBuffer = async (repoRoot, args) => { - try { - return await runGhApiBuffer(repoRoot, args, undefined, { maxBytes: IMAGE_FILE_LIMIT }); - } catch (error) { - if (error instanceof Error && /not found|404/i.test(error.message)) { - return undefined; - } - throw error; - } -}; - /** Provider transport backed by the authenticated `gh` process owned by Electron. */ const createPullRequestTransport = (repoRoot) => createGhGitHubTransport({ repoRoot }); @@ -398,68 +357,6 @@ const readPullRequestHydrationSnapshot = async (repoRoot, pullRequest, options = return snapshot; }; -/** @param {string} path */ -const encodeGitHubContentPath = (path) => path.split('/').map(encodeURIComponent).join('/'); - -/** @param {GitHubRepositoryMetadata | null | undefined} repository */ -const normalizeGitHubRepositoryReference = (repository) => { - const owner = repository?.owner?.login; - const repo = repository?.name; - if (owner && repo) { - return { owner, repo }; - } - - const [fullNameOwner, fullNameRepo] = repository?.full_name?.split('/') ?? []; - return fullNameOwner && fullNameRepo - ? { - owner: fullNameOwner, - repo: fullNameRepo, - } - : null; -}; - -/** @param {PullRequestReference} pullRequest @param {GitHubPullRequestMetadata} metadata */ -const getPullRequestHeadImageSource = (pullRequest, metadata) => { - const repository = normalizeGitHubRepositoryReference(metadata.head?.repo); - return { - owner: repository?.owner ?? pullRequest.owner, - ref: repository - ? (metadata.head?.sha ?? metadata.head?.ref ?? 'HEAD') - : `refs/pull/${pullRequest.number}/head`, - repo: repository?.repo ?? pullRequest.repo, - }; -}; - -/** - * @param {string} repoRoot - * @param {GitHubRepositoryReference} repository - * @param {string} ref - * @param {string} path - */ -const readGitHubImageFile = async (repoRoot, repository, ref, path) => { - if (!getImageMimeType(path)) { - throw new Error('Unsupported image file type.'); - } - - const buffer = await ghApiBuffer(repoRoot, [ - '-H', - 'Accept: application/vnd.github.raw', - `repos/${repository.owner}/${repository.repo}/contents/${encodeGitHubContentPath( - path, - )}?ref=${encodeURIComponent(ref)}`, - ]); - - if (!buffer) { - return undefined; - } - - if (buffer.length > IMAGE_FILE_LIMIT) { - throw new Error(`Image is ${formatBytes(buffer.length)}, so Codiff skipped rendering it.`); - } - - return bufferToImageRevision(path, buffer); -}; - /** @param {unknown} side */ const fromGitHubReviewSide = (side) => (side === 'LEFT' ? 'deletions' : 'additions'); /** @param {unknown} side */ @@ -729,219 +626,6 @@ const createPullRequestSource = (pullRequest, metadata) => ({ url: pullRequest.url, }); -/** - * Make sure the pull request head and base branch are available as local refs - * and resolve the two commits to diff against. GitHub computes the pull request - * diff against the merge base of the base branch and the head, so mirror that to - * keep line numbers and changes aligned with the GitHub review. - * - * Returns `null` when the full file contents cannot be resolved, in which case - * callers fall back to the GitHub-provided patch (which cannot expand - * unmodified context). - * - * @param {string} repoRoot - * @param {PullRequestReference} pullRequest - * @param {GitHubPullRequestMetadata} metadata - * @param {string} expectedBaseSha - * @param {GitHubRemote} [selectedRemote] - * @returns {Promise<{base: string; head: string} | null>} - */ -const resolvePullRequestContentRefs = async ( - repoRoot, - pullRequest, - metadata, - expectedBaseSha, - selectedRemote, -) => { - if (!metadata.base?.ref) { - return null; - } - - const headRef = `refs/codiff/pull-requests/${pullRequest.number}/head`; - const baseRef = `refs/codiff/pull-requests/${pullRequest.number}/base`; - const headSha = metadata.head?.sha; - const baseSha = metadata.base?.sha; - const localHead = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', headRef]) - ).trim(); - const localBase = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', baseRef]) - ).trim(); - - // Refetch when a ref is missing or has moved -- including when the base branch - // advanced or the pull request was retargeted (localBase !== base sha) -- so - // the merge base is always resolved against the current base and head rather - // than stale contents. - if ( - localBase === '' || - localHead === '' || - (headSha != null && localHead !== headSha) || - (baseSha != null && localBase !== baseSha) - ) { - try { - const remote = - selectedRemote ?? - (await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha)); - await fetchPullRequestHistoryRefs(repoRoot, remote, pullRequest, metadata); - } catch { - return null; - } - } - - const resolvedHead = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', headRef]) - ).trim(); - const resolvedBase = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', baseRef]) - ).trim(); - if ( - !resolvedHead || - !resolvedBase || - (headSha != null && resolvedHead !== headSha) || - (baseSha != null && resolvedBase !== baseSha) - ) { - return null; - } - - const resolvedEffectiveBase = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', `${expectedBaseSha}^{commit}`]) - ).trim(); - return resolvedEffectiveBase === expectedBaseSha - ? { base: expectedBaseSha, head: headRef } - : null; -}; - -/** - * Hydrate eligible files from one immutable PR range. Ref resolution and Git - * object reads are shared across the complete file set. - * - * @param {string} repoRoot - * @param {PullRequestReference} pullRequest - * @param {GitHubPullRequestMetadata} metadata - * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range - * @param {ReadonlyArray} files - * @param {{force?: boolean}} [options] - */ -const hydratePullRequestSections = async ( - repoRoot, - pullRequest, - metadata, - range, - files, - options = {}, -) => { - const candidates = files.filter( - (file) => canHydrateArtifactFile(file) && !isBinaryDiffPatch(file.patch || ''), - ); - const refs = await resolvePullRequestContentRefs( - repoRoot, - pullRequest, - metadata, - range.baseSha, - ).catch(() => null); - if (!refs) { - return candidates.map((file) => ({ - path: file.path, - section: createPullRequestSection(pullRequest, file, undefined, undefined, { - base: range.baseSha, - contentAttempted: true, - contentError: - 'Codiff could not resolve the immutable pull request range. Retry exact content loading.', - head: range.headSha, - }), - })); - } - - const oldPaths = candidates.map((file) => file.oldPath || file.path); - const newPaths = candidates.map((file) => file.path); - const [oldFiles, newFiles] = await Promise.all([ - readGitFiles(repoRoot, refs.base, oldPaths, { - force: options.force, - refScopedEmptyCacheKey: true, - }), - readGitFiles(repoRoot, refs.head, newPaths, { - force: options.force, - refScopedEmptyCacheKey: true, - }), - ]); - return candidates.map((file) => { - const oldPath = file.oldPath || file.path; - return { - path: file.path, - section: createPullRequestSection( - pullRequest, - file, - oldFiles.get(oldPath), - newFiles.get(file.path), - { base: range.baseSha, head: range.headSha }, - ), - }; - }); -}; - -/** - * Hydrate one explicitly requested file. This force path remains available for - * large-file retries after normal bulk hydration has completed. - * - * @param {string} repoRoot - * @param {PullRequestReference} pullRequest - * @param {GitHubPullRequestMetadata} metadata - * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} range - * @param {ArtifactFile} file - * @param {{force?: boolean}} [options] - */ -const hydratePullRequestSection = async ( - repoRoot, - pullRequest, - metadata, - range, - file, - options = {}, -) => { - const [result] = await hydratePullRequestSections( - repoRoot, - pullRequest, - metadata, - range, - [file], - options, - ); - return ( - result?.section ?? - createPullRequestSection(pullRequest, file, undefined, undefined, { - base: range.baseSha, - head: range.headSha, - }) - ); -}; - -/** @param {string} launchPath @param {Extract} source */ -const readPullRequestSectionsContent = async (launchPath, source) => { - const pullRequest = parseGitHubPullRequestUrl(source.url); - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - await assertPullRequestMatchesRepository(repoRoot, pullRequest); - const { metadata, range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { - expectedHeadSha: source.headSha, - }); - const key = `${repoRoot}:${pullRequest.url}:${range.headSha}`; - const existing = pullRequestBulkHydrations.get(key); - if (existing) { - return existing; - } - - const hydration = hydratePullRequestSections(repoRoot, pullRequest, metadata, range, range.files) - .then((sections) => ({ headSha: range.headSha, sections })) - .catch((error) => { - pullRequestBulkHydrations.delete(key); - throw error; - }); - pullRequestBulkHydrations.set(key, hydration); - while (pullRequestBulkHydrations.size > MAX_PULL_REQUEST_BULK_HYDRATIONS) { - pullRequestBulkHydrations.delete(pullRequestBulkHydrations.keys().next().value); - } - return hydration; -}; - /** @param {string} launchPath @param {Extract} source @returns {Promise} */ const readPullRequestState = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); @@ -976,74 +660,6 @@ const readPullRequestReviewComments = async (launchPath, source) => { return comments; }; -/** - * Load exact local contents for one pull-request file when explicitly retried. - * @param {string} launchPath - * @param {Extract} source - * @param {string} requestedPath - * @param {{force?: boolean}} [options] - */ -const readPullRequestSectionContent = async (launchPath, source, requestedPath, options = {}) => { - const path = validateRepositoryPath(requestedPath); - const pullRequest = parseGitHubPullRequestUrl(source.url); - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - await assertPullRequestMatchesRepository(repoRoot, pullRequest); - const { metadata, range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { - expectedHeadSha: source.headSha, - }); - const file = range.files.find((candidate) => candidate.path === path); - if (!file) { - throw new Error('File is not part of this pull request.'); - } - return hydratePullRequestSection(repoRoot, pullRequest, metadata, range, file, options); -}; - -/** - * @param {string} launchPath - * @param {Extract} source - * @param {string} requestedPath - * @returns {Promise} - */ -const readPullRequestImageContent = async (launchPath, source, requestedPath) => { - try { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const path = validateRepositoryPath(requestedPath); - const pullRequest = parseGitHubPullRequestUrl(source.url); - await assertPullRequestMatchesRepository(repoRoot, pullRequest); - const { metadata, range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { - expectedHeadSha: source.headSha, - }); - const file = range.files.find((candidate) => candidate.path === path); - if (!file) { - throw new Error('File is not part of this pull request.'); - } - - const headImageSource = getPullRequestHeadImageSource(pullRequest, metadata); - const [oldImage, newImage] = await Promise.all([ - readGitHubImageFile(repoRoot, pullRequest, range.baseSha, file.oldPath || file.path), - readGitHubImageFile(repoRoot, headImageSource, headImageSource.ref, file.path), - ]); - - if (!oldImage && !newImage) { - return { - reason: 'Codiff could not load either side of this image.', - status: 'unavailable', - }; - } - - return { - ...(newImage ? { newImage } : {}), - ...(oldImage ? { oldImage } : {}), - status: 'ready', - }; - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; - const { submitPullRequestComment, submitPullRequestReview } = createGitHubReviewMutations({ assertPullRequestMatchesRepository, createTransport: createPullRequestTransport, @@ -1057,19 +673,14 @@ module.exports = { collectResolvedReviewCommentIds, createPullRequestHistoryFetchRefspecs, createPullRequestSource, - getPullRequestHeadImageSource, listPullRequestHistory, normalizeGitHubCommit, normalizeGitHubPullRequestCommit, normalizeGitHubReviewComment, normalizePullRequestComment, parseGitHubPullRequestUrl, - readPullRequestImageContent, readPullRequestReviewComments, - readPullRequestSectionContent, - readPullRequestSectionsContent, readPullRequestState, - resolvePullRequestContentRefs, selectPullRequestRemote, selectUnresolvedReviewComments, submitPullRequestComment, diff --git a/electron/git-state/working-tree.cjs b/electron/git-state/working-tree.cjs index b96aa9f0..36733d49 100644 --- a/electron/git-state/working-tree.cjs +++ b/electron/git-state/working-tree.cjs @@ -17,20 +17,11 @@ const { git, MAX_UNTRACKED_INITIAL_ITEMS, normalizeStatus, - parseStatus, - readFileStat, - readGitImageFile, - readIndexImageFile, - readWorkingTreeImageFile, - validateRepositoryPath, } = require('./common.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile - * @typedef {import('../../core/types.ts').DiffImageContentRequest} DiffImageContentRequest - * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../../core/types.ts').DiffSection} DiffSection - * @typedef {import('../../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('./common.cjs').StatusItem} StatusItem * @typedef {'staged' | 'unstaged'} WorkingTreeSectionKind @@ -407,90 +398,6 @@ const readWorkingTreeState = async (launchPath, options = {}) => { ); }; -/** @param {string} repoRoot @param {string} path @returns {Promise} */ -const getStatusItemForPath = async (repoRoot, path) => { - const trackedStatus = parseStatus( - await git(repoRoot, ['status', '--porcelain=v1', '-z', '-uno']), - ); - const trackedItem = trackedStatus.find((item) => item.path === path); - if (trackedItem) { - return trackedItem; - } - - const stat = await readFileStat(repoRoot, path); - return { - directory: Boolean(stat?.isDirectory()), - path, - staged: false, - status: 'untracked', - unstaged: true, - untracked: true, - }; -}; - -/** @param {string} launchPath @param {DiffSectionContentRequest} request */ -const readDiffSectionContent = async (launchPath, request) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const path = validateRepositoryPath(request.path); - if (request.kind === 'commit' || request.source?.type === 'commit') { - throw new Error('Lazy loading commit diffs is not supported.'); - } - - const item = await getStatusItemForPath(repoRoot, path); - return createSection(repoRoot, item, /** @type {WorkingTreeSectionKind} */ (request.kind), { - force: request.force, - showWhitespace: request.showWhitespace, - }); -}; - -/** - * @param {string} launchPath - * @param {DiffImageContentRequest} request - * @returns {Promise} - */ -const readDiffImageContent = async (launchPath, request) => { - try { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const path = validateRepositoryPath(request.path); - if (request.kind === 'commit' || request.source?.type === 'commit') { - throw new Error('Commit image diffs are loaded through the commit reader.'); - } - - const item = await getStatusItemForPath(repoRoot, path); - const oldPath = item.oldPath || item.path; - const [oldImage, newImage] = - request.kind === 'staged' - ? await Promise.all([ - readGitImageFile(repoRoot, 'HEAD', oldPath), - readIndexImageFile(repoRoot, item.path), - ]) - : await Promise.all([ - item.untracked - ? undefined - : readIndexImageFile(repoRoot, item.path, item.conflictStage), - readWorkingTreeImageFile(repoRoot, item.path), - ]); - - if (!oldImage && !newImage) { - return { - reason: 'Codiff could not load either side of this image.', - status: 'unavailable', - }; - } - - return { - ...(newImage ? { newImage } : {}), - ...(oldImage ? { oldImage } : {}), - status: 'ready', - }; - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; - /** @param {string} repoRoot @param {ReadonlyArray} args */ const gitOrEmpty = async (repoRoot, args) => { try { @@ -532,8 +439,6 @@ const readGitIdentity = (launchPath) => { module.exports = { parsePorcelainV2Status, - readDiffSectionContent, - readDiffImageContent, readGitIdentity, readWorkingTreeState, }; diff --git a/electron/main.cjs b/electron/main.cjs index c16173b5..8073b29c 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -18,11 +18,9 @@ const { const squirrelStartup = require('electron-squirrel-startup'); const { listRepositoryHistory, - readDiffImageContent, - readDiffSectionContent, - readDiffSectionsContent, readGitIdentity, readRepositoryState, + readRevisionContent, readReviewComments, readWalkthroughRepositoryState, runWithCommandSignal, @@ -1995,26 +1993,9 @@ ipcMain.handle('codiff:submitPullRequestReview', async (event, request) => { } }); -ipcMain.handle('codiff:getDiffSectionContent', async (event, request) => { +ipcMain.handle('codiff:readRevisionContent', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return runDiffContentRequest(event, request, () => - readDiffSectionContent(repositoryPath, { - ...request, - showWhitespace: request?.showWhitespace ?? config.settings.showWhitespace, - }), - ); -}); - -ipcMain.handle('codiff:getDiffSectionsContent', async (event, request) => { - const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return runDiffContentRequest(event, request, () => - readDiffSectionsContent(repositoryPath, request), - ); -}); - -ipcMain.handle('codiff:getDiffImageContent', async (event, request) => { - const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return runDiffContentRequest(event, request, () => readDiffImageContent(repositoryPath, request)); + return runDiffContentRequest(event, request, () => readRevisionContent(repositoryPath, request)); }); ipcMain.on('codiff:cancelDiffContentRequest', (event, requestId) => { diff --git a/electron/preload.cjs b/electron/preload.cjs index f9ea067e..2773a3fe 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -28,10 +28,7 @@ const codiff = { getAgentSkillStatus: () => ipcRenderer.invoke('codiff:getAgentSkillStatus'), getConfig: () => ipcRenderer.invoke('codiff:getConfig'), decreaseCodeFontSize: () => ipcRenderer.invoke('codiff:decreaseCodeFontSize'), - getDiffSectionContent: (request) => ipcRenderer.invoke('codiff:getDiffSectionContent', request), - getDiffSectionsContent: (request) => ipcRenderer.invoke('codiff:getDiffSectionsContent', request), getFeatureFlags: () => ipcRenderer.invoke('codiff:getFeatureFlags'), - getDiffImageContent: (request) => ipcRenderer.invoke('codiff:getDiffImageContent', request), getGitIdentity: () => ipcRenderer.invoke('codiff:getGitIdentity'), getKeyboardLayout: () => ipcRenderer.invoke('codiff:getKeyboardLayout'), getLaunchOptions: () => ipcRenderer.invoke('codiff:getLaunchOptions'), @@ -146,6 +143,7 @@ const codiff = { openRepositoryFolder: () => ipcRenderer.invoke('codiff:openRepositoryFolder'), resolvePullRequestUrl: (value) => ipcRenderer.invoke('codiff:resolvePullRequestUrl', value), reportInitialLoadMilestone: (name) => ipcRenderer.send('codiff:initialLoadMilestone', name), + readRevisionContent: (request) => ipcRenderer.invoke('codiff:readRevisionContent', request), setDiffStyle: (value) => ipcRenderer.invoke('codiff:setDiffStyle', value), setShowOutdated: (value) => ipcRenderer.invoke('codiff:setShowOutdated', value), setWordWrap: (value) => ipcRenderer.invoke('codiff:setWordWrap', value), diff --git a/electron/review-content.cjs b/electron/review-content.cjs new file mode 100644 index 00000000..dbd0a9e4 --- /dev/null +++ b/electron/review-content.cjs @@ -0,0 +1,169 @@ +// @ts-check + +const { promises: fs } = require('node:fs'); +const { join } = require('node:path'); +const { + formatBytes, + getCurrentCommandSignal, + git, + gitBufferWithInput, + validateRepositoryPath, +} = require('./git-state/common.cjs'); +const { createCommitContentAdapter } = require('./git-state/provider-artifact-sources.cjs'); + +/** @typedef {import('../core/types.ts').RevisionContentBatchRequest} RevisionContentBatchRequest */ +/** @typedef {import('../core/types.ts').RevisionContentItemResult} RevisionContentItemResult */ +/** @typedef {import('../core/types.ts').ResolvedRevisionBytes} ResolvedRevisionBytes */ + +/** @param {unknown} error */ +const getErrorMessage = (error) => (error instanceof Error ? error.message : String(error)); + +/** @param {import('../core/types.ts').Revision} revision */ +const getRevisionKind = (revision) => revision.kind || 'commit'; + +/** @param {string} repoRoot @param {string} spec @param {number} maxBytes */ +const readGitSpec = async (repoRoot, spec, maxBytes) => { + let objectId; + let size; + try { + objectId = (await git(repoRoot, ['rev-parse', '--verify', spec])).trim(); + size = Number.parseInt((await git(repoRoot, ['cat-file', '-s', objectId])).trim(), 10); + } catch { + getCurrentCommandSignal()?.throwIfAborted(); + return null; + } + if (!Number.isFinite(size)) { + return null; + } + if (size > maxBytes) { + throw new Error( + `File is ${formatBytes(size)}, exceeding the ${formatBytes(maxBytes)} content limit.`, + ); + } + const bytes = await gitBufferWithInput(repoRoot, ['cat-file', 'blob', objectId], ''); + return { bytes, objectId, size }; +}; + +/** + * @param {string} repoRoot + * @param {RevisionContentBatchRequest['requests'][number]} request + * @returns {Promise} + */ +const readMutableRevision = async (repoRoot, request) => { + const path = validateRepositoryPath(request.path); + if (request.revision.kind === 'index') { + const stage = request.revision.stage; + const spec = stage ? `:${stage}:${path}` : `:${path}`; + const result = await readGitSpec(repoRoot, spec, request.maxBytes); + return result + ? { + bytes: result.bytes, + cacheKey: `index:${stage || 0}:${result.objectId}:${path}`, + objectId: result.objectId, + path, + provenance: /** @type {const} */ ('git-index'), + size: result.size, + } + : null; + } + + const absolutePath = join(repoRoot, path); + let stat; + try { + stat = await fs.lstat(absolutePath); + } catch { + getCurrentCommandSignal()?.throwIfAborted(); + return null; + } + if (stat.isDirectory()) { + throw new Error('Path is a directory, so Codiff cannot load it as file content.'); + } + const bytes = stat.isSymbolicLink() + ? Buffer.from(await fs.readlink(absolutePath), 'utf8') + : stat.isFile() + ? await fs.readFile(absolutePath) + : null; + if (!bytes) { + throw new Error('Path is not a regular file.'); + } + if (bytes.byteLength > request.maxBytes) { + throw new Error( + `File is ${formatBytes(bytes.byteLength)}, exceeding the ${formatBytes(request.maxBytes)} content limit.`, + ); + } + return { + bytes, + cacheKey: `working-copy:${request.key}:${bytes.byteLength}`, + path, + provenance: /** @type {const} */ ('filesystem'), + size: bytes.byteLength, + }; +}; + +/** + * Dispatch exact file reads only by Revision kind. Commit transport selection + * is captured once by the source-scoped adapter; callers never rediscover a + * source from section presentation metadata. + * @param {string} launchPath + * @param {RevisionContentBatchRequest} batch + * @returns {Promise} + */ +const readRevisionContent = async (launchPath, batch) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const signal = getCurrentCommandSignal(); + signal?.throwIfAborted(); + const requests = [...new Map(batch.requests.map((request) => [request.key, request])).values()]; + const commitRequests = requests.filter( + (request) => getRevisionKind(request.revision) === 'commit', + ); + const adapter = createCommitContentAdapter(repoRoot, batch.source); + const commitBlobs = + commitRequests.length === 0 + ? new Map() + : await adapter.readFileBlobs( + commitRequests.map((request) => ({ + maxBytes: request.maxBytes, + path: validateRepositoryPath(request.path), + ref: 'sha' in request.revision ? request.revision.sha : '', + ...(signal ? { signal } : {}), + })), + ); + signal?.throwIfAborted(); + + /** @type {Array>} */ + const reads = requests.map(async (request) => { + try { + const kind = getRevisionKind(request.revision); + const value = + kind === 'commit' + ? (() => { + const ref = 'sha' in request.revision ? request.revision.sha : ''; + const blob = commitBlobs.get(`${ref}:${request.path}`); + return blob + ? { + bytes: blob.bytes, + cacheKey: `${blob.provenance.kind}:${blob.objectId}:${request.path}`, + objectId: blob.objectId, + path: request.path, + provenance: blob.provenance.kind, + size: blob.bytes.byteLength, + } + : null; + })() + : await readMutableRevision(repoRoot, request); + return value + ? { key: request.key, status: /** @type {const} */ ('ready'), value } + : { key: request.key, status: /** @type {const} */ ('missing') }; + } catch (error) { + if (signal?.aborted) throw error; + return { + key: request.key, + reason: getErrorMessage(error), + status: /** @type {const} */ ('unavailable'), + }; + } + }); + return { results: await Promise.all(reads) }; +}; + +module.exports = { readRevisionContent }; diff --git a/electron/reviewed-diff-signature.cjs b/electron/reviewed-diff-signature.cjs index 28f1edf8..ba1e26f0 100644 --- a/electron/reviewed-diff-signature.cjs +++ b/electron/reviewed-diff-signature.cjs @@ -2,9 +2,7 @@ const revisionIdentity = (range) => range - ? `${range.base.kind || 'commit'}:${range.base.sha || ''}:${ - range.head.kind || 'commit' - }:${range.head.sha || ''}` + ? `${range.base?.kind || (range.base ? 'commit' : 'absent')}:${range.base?.sha || ''}:${range.head?.kind || (range.head ? 'commit' : 'absent')}:${range.head?.sha || ''}` : ''; /** From 111655f594f416a9b98cbd3c5e3c7fce991c9abd Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 14:00:30 -0500 Subject: [PATCH 15/17] Present review comments in their anchored code regions Load overview and inline provider threads together, normalize every reply from its root, and preserve immutable old/new, ranged, file, rename, and outdated coordinates. Render resolvable threads against their exact Pierre code regions, including historical revision content, while retaining full comment bodies, authors, permalinks, and actions in the missing-region fallback when content or coordinates are unavailable. --- core/App.css | 186 +++++++++ core/ReviewSurface.tsx | 356 +++++++++++++++--- core/__tests__/App-plan.test.tsx | 2 +- core/__tests__/App-startup.test.tsx | 2 +- ...RepositoryReviewHost-capabilities.test.tsx | 68 ++-- core/__tests__/RepositoryReviewHost.test.tsx | 229 ++++++++++- core/__tests__/ReviewCodeView-scroll.test.tsx | 18 +- .../ReviewSurface-capabilities.test.tsx | 317 +++++++++++++++- core/__tests__/ReviewSurface.test.tsx | 157 +++++++- core/__tests__/git-state.test.ts | 167 +++++++- core/__tests__/gitlab.test.ts | 166 +++++++- core/__tests__/review-comments.test.ts | 5 + core/app/RepositoryReviewHost.tsx | 123 +++++- core/app/components/ReviewCodeView.tsx | 142 ++++++- .../merge-request/GeneralComments.tsx | 163 +++++++- core/global.d.ts | 6 +- core/lib/review-comments.ts | 155 +++++--- electron/git-state/merge-request.cjs | 92 ++++- electron/git-state/pull-request.cjs | 143 +++++-- 19 files changed, 2252 insertions(+), 245 deletions(-) diff --git a/core/App.css b/core/App.css index 85d5e9f8..6585cb0d 100644 --- a/core/App.css +++ b/core/App.css @@ -1702,10 +1702,124 @@ html[data-codiff-platform='darwin'] .sidebar { padding-top: 6px; } +.sidebar-comment-section { + border-bottom: 1px solid var(--sidebar-border); + min-width: 0; +} + +.sidebar-comment-section-toggle { + align-items: center; + background: transparent; + border: 0; + color: var(--sidebar-text); + cursor: pointer; + display: flex; + justify-content: space-between; + min-width: 0; + padding: 8px 10px; + text-align: left; + width: 100%; +} + +.sidebar-comment-section-toggle > span { + align-items: baseline; + display: flex; + gap: 6px; + min-width: 0; +} + +.sidebar-comment-section-toggle strong { + font: 700 11px/1.25 var(--font-sans); + text-transform: uppercase; +} + +.sidebar-comment-section-toggle small { + color: var(--muted); + font: 11px/1.25 var(--font-mono); +} + +.sidebar-comment-section-toggle svg { + color: var(--muted); + flex: none; + transition: transform 120ms ease; +} + +.sidebar-comment-section-toggle svg.collapsed { + transform: rotate(-90deg); +} + +.sidebar-comment-section-body { + min-width: 0; +} + .sidebar-comment-entry.history-entry { grid-template-columns: 34px minmax(0, 1fr); } +.sidebar-inline-comment-entry.history-entry { + display: flex; + flex-direction: column; + gap: 4px; + padding: 7px 8px; +} + +.sidebar-inline-comment-location { + align-items: baseline; + display: flex; + gap: 8px; + justify-content: space-between; + min-width: 0; +} + +.sidebar-inline-comment-location > span:first-child { + color: var(--sidebar-ref); + font: 11px/1.35 var(--font-mono); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-inline-comment-location > span:last-child { + color: var(--muted); + flex: none; + font: 10px/1.35 var(--font-mono); +} + +.sidebar-inline-comment-entry .history-entry-subject { + display: -webkit-box; + font-size: 12px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-height: 1.4; + white-space: normal; +} + +.sidebar-inline-comment-entry .history-entry-meta { + justify-content: flex-start; +} + +.sidebar-inline-comment-entry .history-entry-author > span:last-child { + flex: initial; + overflow: hidden; + text-overflow: ellipsis; + visibility: visible; + width: auto; +} + +.sidebar-inline-comment-status { + align-items: center; + color: var(--muted); + display: flex; + flex-wrap: wrap; + font: 10px/1.35 var(--font-sans); + gap: 4px 8px; +} + +.sidebar-inline-comment-status a { + color: var(--link); +} + .sidebar-comment-entry .history-entry-ref { font-size: 11px; } @@ -2284,6 +2398,58 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 11px 12px 48px; } +.review-comments-overview .merge-request-comments-view { + flex: none; + min-height: auto; + overflow: visible; + padding-bottom: 12px; +} + +.review-comments-section-heading { + color: var(--text); + font: 700 13px/1.35 var(--font-sans); + padding: 12px 16px 6px; +} + +.missing-review-comments { + padding-bottom: 16px; +} + +.missing-review-comment-list { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 16px; +} + +.missing-review-comment-thread { + background: var(--file-bg); + border: 1px solid var(--file-border); + border-radius: 20px; + corner-shape: squircle; + overflow: hidden; +} + +.missing-review-comment-location { + align-items: baseline; + border-bottom: 1px solid var(--file-border); + color: var(--muted); + display: flex; + flex-wrap: wrap; + font: 11px/1.35 var(--font-sans); + gap: 4px 10px; + padding: 8px 12px; +} + +.missing-review-comment-location strong { + color: var(--text); + font-family: var(--font-mono); +} + +.missing-review-comment-thread > .review-comment-thread-group { + padding: 10px 12px; +} + .merge-request-comments-source-description { --codiff-diff-radius: 28px; @@ -3621,6 +3787,26 @@ diffs-container .review-comment-thread { white-space: nowrap; } +.review-comment-header a { + align-items: center; + color: inherit; + display: inline-flex; + gap: 4px; + min-width: 0; + text-decoration: none; +} + +.review-comment-header a:hover { + color: var(--text); +} + +.review-comment-header .review-comment-permalink { + flex: none; + margin-left: auto; + overflow: visible; + white-space: nowrap; +} + .review-comment-header .general-comment-edit-actions { margin-left: auto; overflow: visible; diff --git a/core/ReviewSurface.tsx b/core/ReviewSurface.tsx index 63d48017..4ec81554 100644 --- a/core/ReviewSurface.tsx +++ b/core/ReviewSurface.tsx @@ -22,7 +22,9 @@ import { ReviewFileTree } from './app/components/FileTree.tsx'; import { KeyboardShortcutsHelp } from './app/components/KeyboardShortcutsHelp.tsx'; import { MergeRequestCommentsView, + SidebarCommentSection, SidebarGeneralCommentList, + SidebarInlineReviewCommentList, } from './app/components/merge-request/GeneralComments.tsx'; import { AgentUnavailablePanel, @@ -36,6 +38,7 @@ import { } from './app/components/Panels.tsx'; import { PullRequestSourceDescription, + ReviewCommentThreadList, ReviewCodeView, type ReviewDiffBlock, } from './app/components/ReviewCodeView.tsx'; @@ -95,9 +98,12 @@ import { import { buildReviewCommentsMarkdown, getPendingPullRequestReviewComments, + getReviewCommentRendererSectionId, getReviewCommentsFromState, + isFileReviewComment, isLocalReviewNote, isProviderCommentDraft, + isReviewCommentRegionSection, isReviewDraft, isShareCommentDraft, isSubmittedReviewComment, @@ -312,6 +318,7 @@ export type ReviewContentCapabilities = { initialScrollTarget?: ReviewScrollTarget | null; itemVersionByKey?: Readonly>; loadingSectionIds?: ReadonlySet; + onLoadCommentRegion?: (comment: PullRequestExistingReviewComment) => Promise | void; onLoadSection?: (file: ChangedFile, section: DiffSection) => Promise | void; onRefreshMarkdown?: (file: ChangedFile, section: DiffSection) => Promise; resolveImage?: (file: ChangedFile, section: DiffSection) => Promise; @@ -544,6 +551,14 @@ export function ReviewSurface({ const controlledPreferences = capabilities?.preferences; const sourceNavigation = capabilities?.sourceNavigation; const walkthrough = capabilities?.walkthrough; + const reviewedFiles = useMemo( + () => + snapshot.files.flatMap((file) => { + const sections = file.sections.filter((section) => !isReviewCommentRegionSection(section)); + return sections.length > 0 ? [{ ...file, sections }] : []; + }), + [snapshot.files], + ); const reviewSession = providerComments?.reviewSession; const canComment = localReviewNotes?.canCreateInline ?? @@ -607,7 +622,7 @@ export function ReviewSurface({ ); const navigation = useNarrativeNavigation( sharedWalkthrough, - snapshot.files, + reviewedFiles, `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`, ); const defaultKeymap = useMemo(() => createDefaultConfig().keymap, []); @@ -660,7 +675,7 @@ export function ReviewSurface({ } = useReviewFileState({ collapsed: desktop?.collapsed, initialSelectedPath: - controlledPreferences?.selectedPath?.value ?? snapshot.files[0]?.path ?? null, + controlledPreferences?.selectedPath?.value ?? reviewedFiles[0]?.path ?? null, onCollapsedChange: desktop?.onCollapsedChange, onViewedChange: desktop?.onViewedChange, viewed: desktop?.viewed, @@ -804,6 +819,13 @@ export function ReviewSurface({ () => mergeReviewComments(visibleSnapshotReviewComments, localReviewComments), [localReviewComments, visibleSnapshotReviewComments], ); + const renderableReviewComments = useMemo( + () => + reviewComments.filter( + (comment) => isReviewDraft(comment) || comment.resolvedSectionId != null, + ), + [reviewComments], + ); const { activeReviewCommentDraftRef, activeReviewCommentDraftState, @@ -832,7 +854,7 @@ export function ReviewSurface({ if (!comments) { return; } - const file = snapshot.files.find((candidate) => candidate.path === comment.filePath); + const file = reviewedFiles.find((candidate) => candidate.path === comment.filePath); const section = file?.sections.find((candidate) => candidate.id === comment.sectionId); if (!file || !section) { return; @@ -849,8 +871,8 @@ export function ReviewSurface({ }; const target = comments.destination === 'share' - ? resolveShareCommentTarget({ ...targetInput, displayedFiles: snapshot.files }) - : resolveProviderCommentTarget({ ...targetInput, canonicalFiles: snapshot.files }); + ? resolveShareCommentTarget({ ...targetInput, displayedFiles: reviewedFiles }) + : resolveProviderCommentTarget({ ...targetInput, canonicalFiles: reviewedFiles }); if (target.status !== 'enabled') { return; } @@ -863,10 +885,30 @@ export function ReviewSurface({ comments, createDraftComment, localReviewNotes, - snapshot.files, + reviewedFiles, snapshot.preferences.showWhitespace, ], ); + const createMissingReviewReply = useCallback( + (threadId: string, comment: ReviewComment) => { + if (!canComment) { + return; + } + createDraftComment({ + ...(isFileReviewComment(comment) ? { anchor: 'file' as const } : {}), + filePath: comment.filePath, + ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), + ...(comment.position ? { position: comment.position } : {}), + sectionId: + getReviewCommentRendererSectionId(comment) ?? `missing-review-thread:${threadId}`, + ...(comment.side ? { side: comment.side } : {}), + ...(comment.startLineNumber != null ? { startLineNumber: comment.startLineNumber } : {}), + ...(comment.startSide ? { startSide: comment.startSide } : {}), + threadId, + }); + }, + [canComment, createDraftComment], + ); const generalCommentThreads = snapshot.repository.generalComments ?? emptyGeneralCommentThreads; const generalComments = useMemo( () => @@ -876,8 +918,9 @@ export function ReviewSurface({ [snapshot.repository.generalComments], ); const generalCommentCount = generalComments.length; - const showCommentsTab = - comments != null || generalCommentCount > 0 || visibleSnapshotReviewComments.length > 0; + const inlineReviewCommentCount = visibleSnapshotReviewComments.length; + const reviewCommentCount = generalCommentCount + inlineReviewCommentCount; + const showCommentsTab = comments != null || reviewCommentCount > 0; const [generalCommentDraft, setGeneralCommentDraft] = useState(''); const [generalCommentEditDraft, setGeneralCommentEditDraft] = useState(''); const [editingGeneralCommentId, setEditingGeneralCommentId] = useState(null); @@ -885,6 +928,13 @@ export function ReviewSurface({ const [generalCommentEditSubmitting, setGeneralCommentEditSubmitting] = useState(false); const [generalCommentError, setGeneralCommentError] = useState(null); const [focusedGeneralCommentId, setFocusedGeneralCommentId] = useState(null); + const [focusedInlineSidebarCommentId, setFocusedInlineSidebarCommentId] = useState( + null, + ); + const [focusedReviewCommentPath, setFocusedReviewCommentPath] = useState(null); + const [pendingReviewCommentNavigationId, setPendingReviewCommentNavigationId] = useState< + string | null + >(null); const handledHashTargetRef = useRef(null); const [generalCommentScrollRequest, setGeneralCommentScrollRequest] = useState(0); const [generalCommentSubmitting, setGeneralCommentSubmitting] = useState(false); @@ -898,7 +948,7 @@ export function ReviewSurface({ const [walkthroughRequestId, setWalkthroughRequestId] = useState(0); const walkthroughRef = useRef(walkthrough); - const orderedFiles = useMemo(() => sortFiles(snapshot.files), [snapshot.files]); + const orderedFiles = useMemo(() => sortFiles(reviewedFiles), [reviewedFiles]); const { activeMatch: activeDiffSearchMatch, activeMatchIndex: activeDiffSearchMatchIndex, @@ -920,8 +970,13 @@ export function ReviewSurface({ showWhitespace: snapshot.preferences.showWhitespace, }); const forceExpandedPaths = useMemo( - () => new Set([...diffSearchMatchPathSet, ...(content?.forceExpandedPaths ?? emptyPaths)]), - [content?.forceExpandedPaths, diffSearchMatchPathSet], + () => + new Set([ + ...diffSearchMatchPathSet, + ...(content?.forceExpandedPaths ?? emptyPaths), + ...(focusedReviewCommentPath ? [focusedReviewCommentPath] : []), + ]), + [content?.forceExpandedPaths, diffSearchMatchPathSet, focusedReviewCommentPath], ); const totalLineCount = useMemo( () => @@ -937,7 +992,7 @@ export function ReviewSurface({ ? selectedPath : (visibleFiles[0]?.path ?? null); const initialMarkdownPreviewSectionIds = useMemo(() => { - const nonGeneratedFiles = snapshot.files.filter((file) => !isGeneratedWalkthroughFile(file)); + const nonGeneratedFiles = reviewedFiles.filter((file) => !isGeneratedWalkthroughFile(file)); if ( nonGeneratedFiles.length === 0 || !nonGeneratedFiles.every((file) => isMarkdownFilePath(file.path)) @@ -946,11 +1001,11 @@ export function ReviewSurface({ } return new Set( - snapshot.files + reviewedFiles .filter((file) => isMarkdownFilePath(file.path)) .flatMap((file) => file.sections.map((section) => section.id)), ); - }, [snapshot.files]); + }, [reviewedFiles]); useDocumentAppearance({ codeFontFamily: snapshot.preferences.codeFontFamily, @@ -984,23 +1039,25 @@ export function ReviewSurface({ const activateGeneralComment = useCallback( (commentId: string) => { changeSidebarMode('comments'); + setFocusedReviewCommentPath(null); setFocusedGeneralCommentId(commentId); setGeneralCommentScrollRequest((current) => current + 1); }, [changeSidebarMode], ); - const activateReviewComment = useCallback( - (comment: ReviewComment) => { - changeSidebarMode('tree'); + const showResolvedReviewComment = useCallback( + (comment: PullRequestExistingReviewComment) => { + setFocusedInlineSidebarCommentId(null); + setFocusedReviewCommentPath(comment.filePath); setUncontrolledSelectedPath(comment.filePath); controlledPreferences?.selectedPath?.onChange(comment.filePath); - focusComment(comment.id); setTreeScrollTarget((current) => ({ behavior: 'smooth', - commentId: comment.id, path: comment.filePath, request: (current?.request ?? 0) + 1, })); + changeSidebarMode('tree'); + focusComment(comment.id); }, [ changeSidebarMode, @@ -1009,18 +1066,56 @@ export function ReviewSurface({ setUncontrolledSelectedPath, ], ); + const activateReviewComment = useCallback( + (commentId: string) => { + const comment = visibleSnapshotReviewComments.find((candidate) => candidate.id === commentId); + if (!comment?.resolvedSectionId) { + changeSidebarMode('comments'); + setFocusedReviewCommentPath(null); + setFocusedInlineSidebarCommentId(commentId); + if (comment && content?.onLoadCommentRegion) { + setPendingReviewCommentNavigationId(commentId); + void Promise.resolve(content.onLoadCommentRegion(comment)).catch(() => { + setPendingReviewCommentNavigationId((current) => + current === commentId ? null : current, + ); + }); + } + return; + } + showResolvedReviewComment(comment); + }, + [changeSidebarMode, content, showResolvedReviewComment, visibleSnapshotReviewComments], + ); + useEffect(() => { + if (!pendingReviewCommentNavigationId) { + return; + } + const comment = visibleSnapshotReviewComments.find( + (candidate) => + candidate.id === pendingReviewCommentNavigationId && candidate.resolvedSectionId, + ); + if (!comment) { + return; + } + const timeout = window.setTimeout(() => { + setPendingReviewCommentNavigationId(null); + showResolvedReviewComment(comment); + }, 0); + return () => window.clearTimeout(timeout); + }, [pendingReviewCommentNavigationId, showResolvedReviewComment, visibleSnapshotReviewComments]); const activateHashTarget = useCallback(() => { const target = getLocationHashTarget(); if (!target || handledHashTargetRef.current === target) { return; } - const reviewComment = reviewComments.find((comment) => + const reviewComment = visibleSnapshotReviewComments.find((comment) => commentMatchesHashTarget(comment, target), ); if (reviewComment) { handledHashTargetRef.current = target; - activateReviewComment(reviewComment); + activateReviewComment(reviewComment.id); return; } @@ -1034,7 +1129,12 @@ export function ReviewSurface({ return; } } - }, [activateGeneralComment, activateReviewComment, generalCommentThreads, reviewComments]); + }, [ + activateGeneralComment, + activateReviewComment, + generalCommentThreads, + visibleSnapshotReviewComments, + ]); useEffect(() => { const timeout = window.setTimeout(activateHashTarget, 0); return () => window.clearTimeout(timeout); @@ -1488,6 +1588,7 @@ export function ReviewSurface({ ); const activateTreePath = useCallback( (path: string) => { + setFocusedReviewCommentPath(null); selectPath(path); setTreeScrollTarget((current) => ({ behavior: 'smooth', @@ -1616,7 +1717,7 @@ export function ReviewSurface({ () => ({ copyPendingComments: () => buildReviewCommentsMarkdown( - snapshot.files, + reviewedFiles, localReviewComments, snapshot.preferences.showWhitespace, pendingCommentPrefix, @@ -1629,7 +1730,7 @@ export function ReviewSurface({ openDiffSearch, pendingCommentPrefix, sidebarMode, - snapshot.files, + reviewedFiles, snapshot.preferences.showWhitespace, visibleSelectedPath, ], @@ -1676,7 +1777,7 @@ export function ReviewSurface({ agentLabel: getAgentLabel(snapshot.walkthrough.agent), codeQualityFindings: snapshot.codeQualityFindings, collapsed, - comments: reviewComments, + comments: renderableReviewComments, commitMetadata: snapshot.commitMetadata ?? null, diffLineHeight, diffStyle: controlledPreferences?.diffLayout?.value ?? snapshot.preferences.diffStyle, @@ -1728,7 +1829,7 @@ export function ReviewSurface({ const showDesktopCommitButton = sidebarMode === 'tree' && source.type === 'working-tree' && - snapshot.files.length > 0 && + reviewedFiles.length > 0 && desktop?.commit != null; const emptySourceDetail = getEmptySourceDetail(source, snapshot.repository.root); const hasDiffSearchQuery = diffSearchQuery.trim().length > 0; @@ -1799,6 +1900,122 @@ export function ReviewSurface({ /> ) : null; + const { commentReviewBlocks, missingRegionComments } = useMemo(() => { + const groups: Array<{ comments: Array; key: string }> = []; + const byThread = new Map; key: string }>(); + for (const comment of reviewComments) { + const key = comment.threadId ? `thread:${comment.threadId}` : `comment:${comment.id}`; + let group = byThread.get(key); + if (!group) { + group = { comments: [], key }; + byThread.set(key, group); + groups.push(group); + } + group.comments.push(comment); + } + + const blocks: Array = []; + const missing: Array = []; + for (const group of groups) { + const root = group.comments[0]!; + const sectionId = getReviewCommentRendererSectionId(root); + const file = snapshot.files.find((candidate) => candidate.path === root.filePath); + const section = file?.sections.find((candidate) => candidate.id === sectionId); + if (!file || !section) { + missing.push(...group.comments); + continue; + } + blocks.push({ + comments: group.comments, + file: { ...file, sections: [section] }, + id: `review-comments:${group.key}`, + itemIdPrefix: `review-comments:${group.key}`, + }); + } + return { commentReviewBlocks: blocks, missingRegionComments: missing }; + }, [reviewComments, snapshot.files]); + + const commentsOverview = ( +
+ +
+ ); + const commentsBlocks: ReadonlyArray = [ + { header: commentsOverview, id: 'review-comments:overview' }, + ...(commentReviewBlocks.length > 0 + ? [ + { + header: ( +
+ Code comments +
+ ), + id: 'review-comments:code-heading', + } satisfies ReviewDiffBlock, + ...commentReviewBlocks, + ] + : []), + ...(missingRegionComments.length > 0 + ? [ + { + header: ( +
+
+ Comments without a code region +
+ +
+ ), + id: 'review-comments:missing', + } satisfies ReviewDiffBlock, + ] + : []), + ]; + const renderWalkthroughDiffBlocks = ( blocks: ReadonlyArray, blockScrollTarget: WalkthroughBlockScrollTarget | null, @@ -1894,18 +2111,18 @@ export function ReviewSurface({ ...(showCommentsTab ? [ { - ariaLabel: generalCommentCount > 0 ? `Comments (${generalCommentCount})` : 'Comments', + ariaLabel: reviewCommentCount > 0 ? `Comments (${reviewCommentCount})` : 'Comments', icon: , indicator: - generalCommentCount > 0 ? ( + reviewCommentCount > 0 ? ( - {generalCommentCount} + {reviewCommentCount} ) : undefined, label: 'Comments', title: - generalCommentCount > 0 - ? `${generalCommentCount} ${generalCommentCount === 1 ? 'comment' : 'comments'}` + reviewCommentCount > 0 + ? `${reviewCommentCount} ${reviewCommentCount === 1 ? 'comment' : 'comments'}` : 'Comments', value: 'comments' as const, }, @@ -2020,7 +2237,7 @@ export function ReviewSurface({ @@ -2076,11 +2293,30 @@ export function ReviewSurface({ viewed={viewed} /> ) : sidebarMode === 'comments' ? ( - + <> + + + + + + + ) : walkthroughReady ? ( ) : sidebarMode === 'comments' ? ( - (block.file ? [block.file.path] : [])), + ) + } + hunkNavigation={null} + isReadOnly + onSelectPathFromScroll={noop} + scrollTarget={null} + searchQuery="" + selectedPath={null} + showSourceDescription={false} + walkthroughNotes={emptyWalkthroughNotes} /> ) : sidebarMode === 'tree' || sidebarMode === 'history' ? ( - snapshot.files.length === 0 ? ( + reviewedFiles.length === 0 ? (
{getEmptySourceTitle(source)} @@ -2220,7 +2452,7 @@ export function ReviewSurface({ ) : walkthroughReady ? ( = {}): Window['co getPreferences: vi.fn(async () => createDefaultConfig().settings), getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), getRepositoryState: vi.fn(async () => repositoryState), - getReviewComments: vi.fn(async () => []), + getReviewComments: vi.fn(async () => ({ generalComments: [], reviewComments: [] })), getTerminalHelperStatus: vi.fn(async () => ({ command: 'codiff', installed: true, diff --git a/core/__tests__/App-startup.test.tsx b/core/__tests__/App-startup.test.tsx index dc1f6b5e..7c5c5b18 100644 --- a/core/__tests__/App-startup.test.tsx +++ b/core/__tests__/App-startup.test.tsx @@ -89,7 +89,7 @@ const createAppApi = (overrides: Record = {}) => ({ getPlanReview: vi.fn(async () => null), getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), getRepositoryState: vi.fn(async () => repositoryState), - getReviewComments: vi.fn(async () => []), + getReviewComments: vi.fn(async () => ({ generalComments: [], reviewComments: [] })), getTerminalHelperStatus: vi.fn(async () => ({ command: 'codiff', installed: true, diff --git a/core/__tests__/RepositoryReviewHost-capabilities.test.tsx b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx index 30d5cc3a..e729cf06 100644 --- a/core/__tests__/RepositoryReviewHost-capabilities.test.tsx +++ b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx @@ -127,7 +127,7 @@ const installWindowApi = (overrides: Record = {}) => { root: '/repo', })), getRepositoryState: vi.fn(async () => stateFor({ type: 'working-tree' })), - getReviewComments: vi.fn(async () => []), + getReviewComments: vi.fn(async () => ({ generalComments: [], reviewComments: [] })), getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), isWindowFullScreen: vi.fn(async () => false), onCopyPendingCommentsRequest: vi.fn((callback: () => string | Promise) => { @@ -1669,26 +1669,28 @@ test('keeps failed whitespace reloads recoverable without applying stale state', test('hydrates provider comments after first usable and ignores a superseded source result', async () => { surfaceProps.mockClear(); - const firstComments = deferred< - ReadonlyArray<{ + const firstComments = deferred<{ + generalComments: []; + reviewComments: ReadonlyArray<{ author: { login: string }; body: string; filePath: string; id: string; lineNumber: number; side: 'additions'; - }> - >(); - const secondComments = deferred< - ReadonlyArray<{ + }>; + }>(); + const secondComments = deferred<{ + generalComments: []; + reviewComments: ReadonlyArray<{ author: { login: string }; body: string; filePath: string; id: string; lineNumber: number; side: 'additions'; - }> - >(); + }>; + }>(); const firstSource = { headSha: gitSha('a'), number: 12, @@ -1744,16 +1746,19 @@ test('hydrates provider comments after first usable and ignores a superseded sou ); await act(async () => { - firstComments.resolve([ - { - author: { login: 'stale-reviewer' }, - body: 'Stale comment', - filePath: 'src/first.ts', - id: 'stale', - lineNumber: 1, - side: 'additions', - }, - ]); + firstComments.resolve({ + generalComments: [], + reviewComments: [ + { + author: { login: 'stale-reviewer' }, + body: 'Stale comment', + filePath: 'src/first.ts', + id: 'stale', + lineNumber: 1, + side: 'additions', + }, + ], + }); await firstComments.promise; }); expect(getSurfaceProps().snapshot.reviewComments ?? []).not.toEqual( @@ -1761,16 +1766,19 @@ test('hydrates provider comments after first usable and ignores a superseded sou ); await act(async () => { - secondComments.resolve([ - { - author: { login: 'current-reviewer' }, - body: 'Current comment', - filePath: 'src/second.ts', - id: 'current', - lineNumber: 1, - side: 'additions', - }, - ]); + secondComments.resolve({ + generalComments: [], + reviewComments: [ + { + author: { login: 'current-reviewer' }, + body: 'Current comment', + filePath: 'src/second.ts', + id: 'current', + lineNumber: 1, + side: 'additions', + }, + ], + }); await secondComments.promise; }); await waitFor(() => @@ -1788,7 +1796,7 @@ test('keeps provider comment hydration failures visible and retryable', async () const getReviewComments = vi .fn() .mockRejectedValueOnce(new Error('Provider comments are unavailable.')) - .mockResolvedValueOnce([]); + .mockResolvedValueOnce({ generalComments: [], reviewComments: [] }); installWindowApi({ getReviewComments }); const source = { headSha: gitSha('c'), diff --git a/core/__tests__/RepositoryReviewHost.test.tsx b/core/__tests__/RepositoryReviewHost.test.tsx index 5b953dc6..a0772e96 100644 --- a/core/__tests__/RepositoryReviewHost.test.tsx +++ b/core/__tests__/RepositoryReviewHost.test.tsx @@ -469,7 +469,7 @@ test('reports first usable before initial history and deferred completion after }); test('RepositoryReviewHost hydrates deferred provider comments', async () => { - const file = createChangedFile('src/review.ts'); + const file = createChangedFile('src/review.ts', { kind: 'pull-request' }); const source = { headSha: 'c'.repeat(40), number: 42, @@ -485,17 +485,32 @@ test('RepositoryReviewHost hydrates deferred provider comments', async () => { reviewCommentsLoadState: 'not-loaded' as const, source, } satisfies RepositoryState; - const getReviewComments = vi.fn(async (_source: typeof source, _requestId?: string) => [ - { - author: { login: 'reviewer' }, - body: 'Loaded through the R04 review-comments capability.', - filePath: file.path, - id: 'github:1', - lineNumber: 1, - side: 'additions' as const, - threadId: '1', - }, - ]); + const getReviewComments = vi.fn(async (_source: typeof source, _requestId?: string) => ({ + generalComments: [ + { + comments: [ + { + author: { login: 'overview-reviewer' }, + body: 'Loaded overview feedback with the inline thread.', + id: 'github:overview:1', + }, + ], + id: 'overview-thread', + }, + ], + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'Loaded through the R04 review-comments capability.', + filePath: file.path, + id: 'github:1', + lineNumber: 1, + position: { range: file.sections[0]!.range! }, + side: 'additions' as const, + threadId: '1', + }, + ], + })); window.codiff = { applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), cancelDiffContentRequest: vi.fn(), @@ -516,7 +531,6 @@ test('RepositoryReviewHost hydrates deferred provider comments', async () => { reportInitialLoadMilestone: vi.fn(), resolvePullRequestUrl: vi.fn(async (value: string) => value), } as unknown as Window['codiff']; - await using view = await renderReact( { 'Loaded through the R04 review-comments capability.', ); }); + const commentsButton = Array.from(view.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Comments'), + ); + await act(async () => commentsButton?.click()); + await waitFor(() => { + expect(view.container.querySelector('main.review')?.textContent).toContain( + 'Loaded overview feedback with the inline thread.', + ); + expect(view.container.querySelector('main.review')?.textContent).toContain( + 'Loaded through the R04 review-comments capability.', + ); + }); }); -test('RepositoryReviewHost cancels active bulk provider hydration on unmount', async () => { +test('RepositoryReviewHost renders historical comment regions and falls back after content failure', async () => { + const currentFile = createChangedFile('src/history.ts', { kind: 'pull-request' }); + const source = { + headSha: 'b'.repeat(40), + number: 43, + provider: 'github', + targetBranch: 'main', + title: 'Review historical anchors', + type: 'pull-request', + url: 'https://github.com/example/review/pull/43', + } as const; + const pullRequestState = { + ...state, + files: [currentFile], + reviewCommentsLoadState: 'not-loaded' as const, + source, + } satisfies RepositoryState; + const historicalRange = { + base: { + label: { kind: 'commit' as const, text: 'ccccccc' }, + sha: 'c'.repeat(40) as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'ddddddd' }, + sha: 'd'.repeat(40) as GitSha, + }, + }; + const failedRange = { + base: { + label: { kind: 'commit' as const, text: 'eeeeeee' }, + sha: 'e'.repeat(40) as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'fffffff' }, + sha: 'f'.repeat(40) as GitSha, + }, + }; + const getReviewComments = vi.fn(async () => ({ + generalComments: [], + reviewComments: [ + { + author: { login: 'historical-reviewer' }, + body: 'Render this against the historical code.', + filePath: currentFile.path, + id: 'github:historical', + isOutdated: true, + lineNumber: 2, + position: { range: historicalRange }, + side: 'additions' as const, + threadId: 'historical-thread', + }, + { + author: { login: 'failed-reviewer' }, + body: 'Keep this thread when historical content cannot load.', + filePath: 'src/unavailable-history.ts', + id: 'github:failed-history', + isOutdated: true, + lineNumber: 2, + position: { range: failedRange }, + side: 'additions' as const, + threadId: 'failed-thread', + }, + ], + })); + const readRevisionContent = vi.fn(async (request: RevisionContentBatchRequest) => ({ + results: request.requests.map((item) => { + if ('sha' in item.revision && ['e'.repeat(40), 'f'.repeat(40)].includes(item.revision.sha)) { + return { + key: item.key, + reason: 'Historical object is unavailable.', + status: 'unavailable' as const, + }; + } + const contents = + 'sha' in item.revision && item.revision.sha === historicalRange.base.sha + ? 'one\nold value\nthree\n' + : 'one\nnew value\nthree\n'; + const bytes = new TextEncoder().encode(contents); + return { + key: item.key, + status: 'ready' as const, + value: { + bytes, + cacheKey: `${item.key}:cache`, + path: item.path, + provenance: 'native-git' as const, + size: bytes.byteLength, + }, + }; + }), + })); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getReviewComments, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + readRevisionContent, + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + const config = createDefaultConfig(); + config.settings.showOutdated = true; + + await using view = await renderReact( + , + ); + + await waitFor(() => expect(getReviewComments).toHaveBeenCalledOnce()); + expect(readRevisionContent).not.toHaveBeenCalled(); + await waitFor(() => { + const commentsTab = Array.from( + view.container.querySelectorAll('[role="tab"]'), + ).find((button) => button.textContent?.includes('Comments')); + expect(commentsTab?.getAttribute('aria-label')).toBe('Comments (2)'); + }); + const commentsButton = Array.from(view.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Comments'), + ); + await act(async () => commentsButton?.click()); + const historicalButton = Array.from(view.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Render this against the historical code.'), + ); + await act(async () => historicalButton?.click()); + await waitFor(() => expect(readRevisionContent).toHaveBeenCalledOnce()); + await waitFor(() => { + const main = view.container.querySelector('main.review'); + expect( + main?.querySelectorAll('.codiff-file-header:not(.codiff-source-description-header)'), + ).toHaveLength(1); + }); + await act(async () => commentsButton?.click()); + const failedButton = Array.from(view.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Keep this thread when historical content cannot load.'), + ); + await act(async () => failedButton?.click()); + await waitFor(() => expect(readRevisionContent).toHaveBeenCalledTimes(2)); + const requestedShas = readRevisionContent.mock.calls.flatMap(([request]) => + request.requests.flatMap((item) => ('sha' in item.revision ? [item.revision.sha] : [])), + ); + expect(requestedShas).toEqual( + expect.arrayContaining([ + historicalRange.base.sha, + historicalRange.head.sha, + failedRange.base.sha, + failedRange.head.sha, + ]), + ); +}); + +test('RepositoryReviewHost hydrates provider content before mounting and cancels on unmount', async () => { const baseFile = createChangedFile('src/lazy.ts', { kind: 'pull-request' }); const file = { ...baseFile, @@ -604,10 +798,11 @@ test('RepositoryReviewHost cancels active bulk provider hydration on unmount', a />, ); await waitFor(() => expect(readRevisionContent).toHaveBeenCalledOnce()); - const requestId = readRevisionContent.mock.calls[0]![0].requestId; - expect(requestId).toMatch(/^revision-content:/); + expect(view.container.querySelector('main.review')).toBeNull(); await view.cleanup(); - expect(cancelDiffContentRequest).toHaveBeenCalledWith(requestId); + expect(cancelDiffContentRequest).toHaveBeenCalledWith( + readRevisionContent.mock.calls[0]![0].requestId, + ); }); test('RepositoryReviewHost cancels an active image request on unmount', async () => { diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index d1731bfd..42cb3d4e 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -844,12 +844,14 @@ test('resolved review threads collapse inline, expand when focused, and can be r author: { login: 'reviewer' }, body: 'Please keep this explicit.', canResolveThread: true, + destination: 'provider', filePath: file.path, id: 'gitlab:99', isReadOnly: true, isThreadResolved: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', threadId: 'discussion-1', }, @@ -857,12 +859,14 @@ test('resolved review threads collapse inline, expand when focused, and can be r author: { login: 'author' }, body: 'Resolved in the latest update.', canResolveThread: true, + destination: 'provider', filePath: file.path, id: 'gitlab:103', isReadOnly: true, isThreadResolved: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', threadId: 'discussion-1', }, @@ -933,11 +937,13 @@ test('resolving an open review thread collapses it in place', async () => { author: { login: 'reviewer' }, body: 'Please keep this explicit.', canResolveThread: true, + destination: 'provider', filePath: file.path, id: 'gitlab:99', isReadOnly: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', threadId: 'discussion-1', } satisfies ReviewComment; @@ -978,11 +984,13 @@ test('comment scroll targets navigate directly to their diff annotation', async const comment = { author: { login: 'reviewer' }, body: 'Linked review comment.', + destination: 'provider', filePath: file.path, id: 'gitlab:99', isReadOnly: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', threadId: 'discussion-1', } satisfies ReviewComment; @@ -1570,6 +1578,7 @@ function StatefulReviewCommentHarness({ const [comments, setComments] = useState>([initialComment]); const commentState = useReviewCommentDrafts({ comments, + draftKind: 'provider-draft', onCommentFileChange: ignoreCommentFileChange, setComments, }); @@ -1623,6 +1632,7 @@ test('pointer-driven comment blurs preserve each newly focused editor across rep body: '', filePath: file.path, id: 'comment-1', + kind: 'provider-draft', lineNumber: 1, sectionId: file.sections[0].id, side: 'additions', diff --git a/core/__tests__/ReviewSurface-capabilities.test.tsx b/core/__tests__/ReviewSurface-capabilities.test.tsx index defd1d72..b6c6b33c 100644 --- a/core/__tests__/ReviewSurface-capabilities.test.tsx +++ b/core/__tests__/ReviewSurface-capabilities.test.tsx @@ -514,6 +514,319 @@ test('keeps title-only source footer actions reachable', async () => { }); }); +test('keeps empty provider Comments visible and guides inline feedback through Tree', async () => { + await using empty = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: providerSnapshot, + }); + expect(findButton(empty.container, 'Comments')).not.toBeUndefined(); + expect(empty.container.textContent).toContain('No review comments yet'); + expect(empty.container.textContent).toContain('Add inline feedback from Tree'); + expect(empty.container.textContent).toContain('PR #7'); + + const file = providerSnapshot.files[0]!; + await using inlineOnly = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: { + ...providerSnapshot, + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'Inline feedback exists.', + filePath: file.path, + id: 'inline-only', + lineNumber: 1, + side: 'additions', + }, + ], + }, + }); + expect(inlineOnly.container.textContent).toContain('No overview comments yet'); + expect(inlineOnly.container.textContent).toContain( + 'Inline review comments are available in Tree.', + ); + expect( + Array.from(inlineOnly.container.querySelectorAll('[role="tab"]')) + .find((button) => button.textContent?.includes('Comments')) + ?.getAttribute('aria-label'), + ).toBe('Comments (1)'); +}); + +test('renders overview threads before Pierre-anchored old, ranged, and file comments', async () => { + const file = createChangedFile('src/anchored.ts', { + kind: 'pull-request', + patch: + 'diff --git a/src/anchored.ts b/src/anchored.ts\n@@ -1,3 +1,3 @@\n context\n-old value\n+new value\n tail\n', + }); + const position = { range: file.sections[0]!.range! }; + await using view = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: { + ...providerSnapshot, + files: [file], + repository: { + ...providerSnapshot.repository, + generalComments: [ + { + comments: [ + { + author: { login: 'overview-author' }, + body: 'Overview feedback arrives with the inline threads.', + id: 'overview-1', + url: 'https://github.test/overview-1', + }, + ], + id: 'overview-thread', + }, + ], + }, + reviewComments: [ + { + author: { login: 'old-author' }, + body: 'Old-side feedback stays on the reported deletion.', + filePath: file.path, + id: 'old-side', + lineNumber: 2, + position, + side: 'deletions', + threadId: 'old-thread', + }, + { + author: { login: 'range-author' }, + body: 'The cross-side range stays intact.', + filePath: file.path, + id: 'cross-side-range', + lineNumber: 2, + position, + side: 'additions', + startLineNumber: 2, + startSide: 'deletions', + threadId: 'range-thread', + }, + { + anchor: 'file', + author: { login: 'file-author' }, + body: 'The file-level thread stays on this file.', + filePath: file.path, + id: 'file-comment', + position, + threadId: 'file-thread', + }, + ], + }, + }); + + const main = view.container.querySelector('main.review'); + expect(main?.textContent).toContain('Overview feedback arrives with the inline threads.'); + expect(main?.textContent).toContain('Old-side feedback stays on the reported deletion.'); + expect(main?.textContent).toContain('The cross-side range stays intact.'); + expect(main?.textContent).toContain('The file-level thread stays on this file.'); + expect(main?.textContent?.indexOf('Overview feedback')).toBeLessThan( + main?.textContent?.indexOf('Old-side feedback') ?? -1, + ); + expect(main?.querySelectorAll('.codiff-file-header')).toHaveLength(3); + expect(view.container.textContent).toContain('Old line 2'); + expect(view.container.textContent).toContain('Old line 2 to New line 2'); + expect(view.container.textContent).toContain('File'); + expect(main?.textContent).not.toContain('Comments without a code region'); +}); + +test('lists complete threads when revision, file, side, or line coordinates cannot resolve', async () => { + const file = createChangedFile('src/available.ts', { kind: 'pull-request' }); + const position = { range: file.sections[0]!.range! }; + await using view = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: { + ...providerSnapshot, + files: [file], + reviewComments: [ + { + author: { login: 'revision-author' }, + body: 'Missing immutable revision.', + filePath: file.path, + id: 'missing-revision', + lineNumber: 1, + side: 'additions', + threadId: 'missing-revision-thread', + }, + { + author: { login: 'file-author' }, + body: 'Missing file.', + filePath: 'src/missing.ts', + id: 'missing-file', + lineNumber: 1, + position, + side: 'additions', + threadId: 'missing-file-thread', + }, + { + author: { login: 'side-author' }, + body: 'Missing side.', + filePath: file.path, + id: 'missing-side', + lineNumber: 1, + position, + threadId: 'missing-side-thread', + }, + { + author: { login: 'line-author' }, + body: 'Out-of-range line.', + filePath: file.path, + id: 'bad-line', + lineNumber: 999, + position, + side: 'additions', + threadId: 'bad-line-thread', + }, + ], + }, + }); + + const missing = view.container.querySelector('main.review .missing-review-comments'); + expect(missing?.querySelectorAll('.missing-review-comment-thread')).toHaveLength(4); + for (const text of [ + 'Missing immutable revision.', + 'Missing file.', + 'Missing side.', + 'Out-of-range line.', + ]) { + expect(missing?.textContent).toContain(text); + } + expect(missing?.textContent).toContain('revision-author'); + expect(missing?.textContent).toContain('Code region unavailable'); +}); + +test('preserves missing-region permalink and edit, delete, reply, and resolve actions', async () => { + const onDelete = vi.fn(async () => {}); + const onResolve = vi.fn(async () => {}); + const onSubmit = vi.fn(async () => { + throw new Error('Not submitted by this test.'); + }); + const onUpdate = vi.fn(async () => {}); + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + inline: { onDelete, onResolve, onSubmit, onUpdate }, + }), + }, + initialMode: 'comments', + snapshot: { + ...providerSnapshot, + reviewComments: [ + { + author: { login: 'action-author' }, + body: 'Keep every action available in the fallback.', + canDelete: true, + canEdit: true, + canResolveThread: true, + filePath: 'src/missing-actions.ts', + id: 'action-comment', + lineNumber: 12, + position: { range: providerSnapshot.files[0]!.sections[0]!.range! }, + side: 'additions', + threadId: 'action-thread', + url: 'https://github.test/action-comment', + }, + ], + }, + }); + + const fallback = view.container.querySelector('main.review .missing-review-comments'); + expect(fallback?.textContent).toContain('Keep every action available in the fallback.'); + expect(fallback?.textContent).toContain('action-author'); + expect(fallback?.querySelector('a[href="https://github.test/action-comment"]')).not.toBeNull(); + expect(findButton(fallback as HTMLElement, 'Edit')).not.toBeUndefined(); + expect(fallback?.querySelector('button[aria-label="Delete comment"]')).not.toBeNull(); + expect(findButton(fallback as HTMLElement, 'Reply')).not.toBeUndefined(); + const resolve = findButton(fallback as HTMLElement, 'Resolve'); + expect(resolve).not.toBeUndefined(); + await act(async () => resolve?.click()); + await waitFor(() => expect(onResolve).toHaveBeenCalledWith('action-thread', true)); +}); + +test('keeps unresolved outdated provider comments in Comments without guessing a diff target', async () => { + const unresolvedSnapshot = { + ...providerSnapshot, + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'The original provider line is unavailable.', + filePath: 'src/missing.ts', + id: 'provider:unresolved', + isOutdated: true, + lineNumber: 999, + position: { range: providerSnapshot.files[0]!.sections[0]!.range! }, + side: 'additions' as const, + url: 'https://github.com/cloudflare/codiff/pull/7#discussion_r1', + }, + ], + } satisfies SharedWalkthroughSnapshot; + await using view = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: unresolvedSnapshot, + }); + + const commentsTab = () => + Array.from(view.container.querySelectorAll('[role="tab"]')).find((button) => + button.textContent?.includes('Comments'), + ); + expect(commentsTab()?.getAttribute('aria-label')).toBe('Comments (1)'); + expect(view.container.textContent).toContain('Location unavailable'); + expect(view.container.textContent).toContain('The original provider line is unavailable.'); + expect(view.container.textContent).toContain('reviewer'); + expect(view.container.textContent).toContain('Outdated'); + expect(view.container.textContent).toContain('View on provider'); + const entry = view.container.querySelector('.sidebar-comment-entry'); + await act(async () => entry?.click()); + expect(commentsTab()?.getAttribute('aria-selected')).toBe('true'); + expect(entry?.getAttribute('aria-current')).toBe('true'); + expect(view.container.querySelector('.file-tree-shell')).toBeNull(); + + await view.render({ + capabilities: { + comments: createProviderComments(), + preferences: { outdatedVisibility: { onChange: vi.fn(), value: false } }, + }, + initialMode: 'comments', + snapshot: unresolvedSnapshot, + }); + expect(commentsTab()?.getAttribute('aria-label')).toBe('Comments'); + expect(view.container.textContent).not.toContain('The original provider line is unavailable.'); +}); + +test('labels shared review provenance as Codiff rather than a provider', async () => { + const file = snapshot.files[0]!; + await using view = await renderSurface({ + capabilities: { comments: createShareComments() }, + initialMode: 'comments', + snapshot: { + ...snapshot, + reviewComments: [ + { + author: { login: 'ada' }, + body: 'Shared Codiff feedback.', + filePath: file.path, + id: 'share:comment', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + url: 'https://codiff.example/share/comment/1', + }, + ], + }, + }); + + expect(view.container.textContent).toContain('Shared Codiff feedback.'); + expect(view.container.textContent).toContain('ada'); + expect(view.container.textContent).toContain('View on Codiff'); + expect(view.container.textContent).not.toContain('View on provider'); +}); + test('keeps provider source-description collapse state across Tree and Comments', async () => { const richSnapshot = { ...providerSnapshot, @@ -720,7 +1033,7 @@ test('copies provider drafts with the provider label and Markdown heading', asyn }); test('excludes existing provider comments from Ask and pending-copy flows', async () => { - const file = providerSnapshot.files[0]!; + const file = createChangedFile('src/app.ts', { kind: 'pull-request' }); const onAsk = vi.fn(); const bridge = { current: null as ReviewSurfaceCommandBridge | null }; await using view = await renderSurface({ @@ -738,6 +1051,7 @@ test('excludes existing provider comments from Ask and pending-copy flows', asyn }, snapshot: { ...providerSnapshot, + files: [file], reviewComments: [ { author: { login: 'reviewer' }, @@ -745,6 +1059,7 @@ test('excludes existing provider comments from Ask and pending-copy flows', asyn filePath: file.path, id: 'provider:existing', lineNumber: 1, + position: { range: file.sections[0]!.range! }, side: 'additions', }, ], diff --git a/core/__tests__/ReviewSurface.test.tsx b/core/__tests__/ReviewSurface.test.tsx index 7f7e43fa..eddce81d 100644 --- a/core/__tests__/ReviewSurface.test.tsx +++ b/core/__tests__/ReviewSurface.test.tsx @@ -7,7 +7,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { expect, test, vi } from 'vite-plus/test'; import { ReviewTopBar } from '../app/components/ReviewTopBar.tsx'; import { createDefaultConfig } from '../config/defaults.ts'; -import { ReviewSurface, type ReviewCommenting } from '../ReviewSurface.tsx'; +import { ReviewSurface } from '../ReviewSurface.tsx'; import type { NarrativeWalkthrough, SharedWalkthroughSnapshot } from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { waitFor } from './helpers/react.tsx'; @@ -21,6 +21,7 @@ reactActEnvironment.ResizeObserver ??= class ResizeObserver { unobserve() {} }; HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; HTMLElement.prototype.scrollTo ??= function scrollTo() {}; const createMarkdownFile = (path = 'README.md') => @@ -61,7 +62,7 @@ const commenting = { onSubmitGeneralComment: async () => {}, onUpdateComment: async () => {}, onUpdateGeneralComment: async () => {}, -} satisfies ReviewCommenting; +}; test('review top bar renders its leading control at the far left', async () => { const container = document.createElement('div'); @@ -225,6 +226,158 @@ test('shared review surface owns configured search and portable commands', async } }); +test('review threads expose provider provenance and code status navigates to the current diff', async () => { + const firstFile = createChangedFile('src/first.ts'); + const file = createChangedFile('src/thread.ts', { kind: 'pull-request' }); + const scrollIntoView = vi.spyOn(HTMLElement.prototype, 'scrollIntoView'); + const source = { + number: 31, + projectPath: 'example-org/example-repo', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/example-org/example-repo/-/merge_requests/31', + } as const; + const snapshot = { + branch: 'feature', + codiffVersion: 'test', + exportedAt: '2026-07-29T00:00:00.000Z', + files: [firstFile, file], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, + }, + repository: { + generalComments: [ + { + comments: [ + { + author: { + login: 'overview-reviewer', + name: 'Overview Reviewer', + url: 'https://gitlab.example.com/overview-reviewer', + }, + body: 'Overview feedback.', + id: 'overview-comment', + submittedAt: '2026-07-29T00:00:00.000Z', + url: `${source.url}#note_1`, + }, + ], + id: 'overview-thread', + }, + ], + root: '/repo', + source, + }, + reviewComments: [ + { + author: { + login: 'code-reviewer', + name: 'Code Reviewer', + url: 'https://gitlab.example.com/code-reviewer', + }, + body: 'This line changed later.', + filePath: file.path, + id: 'outdated-comment', + isOutdated: true, + lineNumber: 1, + position: { range: file.sections[0]!.range! }, + sectionId: file.sections[0]!.id, + side: 'additions' as const, + submittedAt: '2026-07-29T00:01:00.000Z', + url: `${source.url}#note_2`, + }, + ], + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Review the change.', + generatedAt: '2026-07-29T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'feature', root: '/repo' }, + source, + support: [], + title: 'Review', + version: 4, + }, + } satisfies SharedWalkthroughSnapshot; + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('Overview comments'); + expect(container.textContent).toContain('Inline review comments'); + expect( + [...container.querySelectorAll('[role="tab"]')] + .find((tab) => tab.textContent?.includes('Comments')) + ?.getAttribute('aria-label'), + ).toBe('Comments (2)'); + expect( + container.querySelector(`a[href="https://gitlab.example.com/overview-reviewer"]`), + ).not.toBeNull(); + const overviewPermalink = container.querySelector( + `a[href="${source.url}#note_1"]`, + ); + expect(overviewPermalink?.textContent).toContain('View on GitLab'); + + const outdatedStatus = [...container.querySelectorAll('span')].find( + ({ textContent }) => textContent === 'Outdated', + ); + expect(outdatedStatus?.getAttribute('role')).toBeNull(); + const inlineEntry = [ + ...container.querySelectorAll('.sidebar-comment-entry'), + ].find((entry) => entry.title === 'This line changed later.'); + expect(inlineEntry).not.toBeUndefined(); + await act(async () => inlineEntry?.click()); + await waitFor(() => expect(container.querySelector('.file-tree-shell')).not.toBeNull()); + await waitFor(() => + expect( + container.querySelector('.codiff-file-header.selected .codiff-file-path')?.textContent, + ).toBe(file.path), + ); + const targetHeader = [...container.querySelectorAll('.codiff-file-header')].find( + (header) => header.textContent?.includes(file.path), + ); + expect( + targetHeader + ?.querySelector('.codiff-header-toggle') + ?.getAttribute('aria-expanded'), + ).toBe('true'); + await waitFor(() => + expect(document.activeElement?.classList.contains('review-comment-thread')).toBe(true), + ); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' }); + } finally { + scrollIntoView.mockRestore(); + await act(async () => root.unmount()); + container.remove(); + } +}); + test('shared walkthroughs switch between walkthrough and tree review modes', async () => { const onDeleteShare = vi.fn(); const confirmDelete = vi.spyOn(window, 'confirm').mockReturnValue(false); diff --git a/core/__tests__/git-state.test.ts b/core/__tests__/git-state.test.ts index 12ca80d9..8c721a04 100644 --- a/core/__tests__/git-state.test.ts +++ b/core/__tests__/git-state.test.ts @@ -84,7 +84,11 @@ type GitStateModule = { source?: ReviewSource, ) => Promise<{ entries: ReadonlyArray; root: string }>; normalizeGitHubPullRequestCommit: (commit: Record) => unknown; - normalizeGitHubReviewComment: (comment: Record) => unknown; + normalizeGitHubReviewComment: ( + comment: Record, + rootComment?: Record, + baseSha?: string, + ) => unknown; normalizePullRequestComment: (comment: Record) => Record; parseGitHubPullRequestUrl: (value: string) => { number: number; @@ -115,6 +119,7 @@ type GitStateModule = { selectUnresolvedReviewComments: ( comments: ReadonlyArray>, resolvedCommentIds: ReadonlySet, + baseSha?: string, ) => Array>; submitPullRequestComment: ( launchPath: string, @@ -178,6 +183,13 @@ const { submitPullRequestComment, validateRepositoryPath, } = require('../../electron/git-state.cjs') as GitStateModule; +const { selectGitHubGeneralCommentThreads } = + require('../../electron/git-state/pull-request.cjs') as { + selectGitHubGeneralCommentThreads: ( + issueComments: ReadonlyArray>, + reviews: ReadonlyArray>, + ) => Array>; + }; const { getRepositoryWatcherInitialSnapshot, readRepositoryWatcherSnapshot: readRepositoryChangeSignature, @@ -925,6 +937,159 @@ test('normalizeGitHubReviewComment keeps replies in their root thread', () => { ).toMatchObject({ id: 'github:2', threadId: '1' }); }); +test('selectUnresolvedReviewComments makes replies inherit the root anchor and immutable range', () => { + const baseSha = 'a'.repeat(40); + const rootCommitSha = 'b'.repeat(40); + const comments = [ + { + body: 'Root.', + commit_id: rootCommitSha, + id: 1, + line: 8, + path: 'src/new-name.ts', + side: 'LEFT', + start_line: 5, + start_side: 'LEFT', + user: { login: 'root-reviewer' }, + }, + { + body: 'Reply with misleading coordinates.', + commit_id: 'c'.repeat(40), + id: 2, + in_reply_to_id: 1, + line: 99, + path: 'src/reply-name.ts', + side: 'RIGHT', + user: { login: 'reply-reviewer' }, + }, + ]; + + expect(selectUnresolvedReviewComments(comments, new Set(), baseSha)).toEqual([ + expect.objectContaining({ + filePath: 'src/new-name.ts', + lineNumber: 8, + position: { + range: { + base: expect.objectContaining({ sha: baseSha }), + head: expect.objectContaining({ sha: rootCommitSha }), + }, + }, + side: 'deletions', + startLineNumber: 5, + threadId: '1', + }), + expect.objectContaining({ + body: 'Reply with misleading coordinates.', + filePath: 'src/new-name.ts', + lineNumber: 8, + side: 'deletions', + startLineNumber: 5, + threadId: '1', + }), + ]); +}); + +test('normalizeGitHubReviewComment uses original commits and coordinates for outdated roots', () => { + const baseSha = 'a'.repeat(40); + const originalCommitSha = 'b'.repeat(40); + const normalized = normalizeGitHubReviewComment( + { + body: 'This belongs to the old side.', + commit_id: 'c'.repeat(40), + id: 7, + line: null, + original_commit_id: originalCommitSha, + original_line: 12, + original_start_line: 10, + path: 'src/renamed.ts', + side: 'LEFT', + start_side: 'LEFT', + user: { login: 'reviewer' }, + }, + undefined, + baseSha, + ); + + expect(normalized).toMatchObject({ + filePath: 'src/renamed.ts', + isOutdated: true, + lineNumber: 12, + position: { + range: { + base: { sha: baseSha }, + head: { sha: originalCommitSha }, + }, + }, + side: 'deletions', + startLineNumber: 10, + }); +}); + +test('normalizeGitHubReviewComment anchors file comments to the review commit', () => { + const baseSha = 'a'.repeat(40); + const headSha = 'b'.repeat(40); + expect( + normalizeGitHubReviewComment( + { + body: 'Review the whole file.', + commit_id: headSha, + id: 41, + path: 'src/file.ts', + subject_type: 'file', + user: { login: 'reviewer' }, + }, + undefined, + baseSha, + ), + ).toMatchObject({ + anchor: 'file', + position: { + range: { + base: { sha: baseSha }, + head: { sha: headSha }, + }, + }, + }); +}); + +test('selectGitHubGeneralCommentThreads includes issue comments and submitted review bodies', () => { + expect( + selectGitHubGeneralCommentThreads( + [ + { + body: 'Issue-level context.', + created_at: '2026-05-18T00:00:00Z', + html_url: 'https://github.test/issue-comment', + id: 1, + user: { login: 'issue-author' }, + }, + ], + [ + { + body: 'Submitted review summary.', + html_url: 'https://github.test/review', + id: 2, + state: 'COMMENTED', + submitted_at: '2026-05-19T00:00:00Z', + user: { login: 'review-author' }, + }, + { body: 'Draft review.', id: 3, state: 'PENDING', user: { login: 'draft-author' } }, + ], + ), + ).toEqual([ + { + comments: [expect.objectContaining({ body: 'Issue-level context.', id: 'github:issue:1' })], + id: 'github:issue:1', + }, + { + comments: [ + expect.objectContaining({ body: 'Submitted review summary.', id: 'github:review:2' }), + ], + id: 'github:review:2', + }, + ]); +}); + test('normalizeGitHubReviewComment flags comments anchored to outdated lines', () => { expect( normalizeGitHubReviewComment({ diff --git a/core/__tests__/gitlab.test.ts b/core/__tests__/gitlab.test.ts index a9aa3094..4b256fdb 100644 --- a/core/__tests__/gitlab.test.ts +++ b/core/__tests__/gitlab.test.ts @@ -18,6 +18,7 @@ const { createGitLabPosition, createMergeRequestFetchRefspecs, createMergeRequestSource, + normalizeGitLabGeneralDiscussion, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, readMergeRequestReviewComments, @@ -37,16 +38,24 @@ const { mergeRequest: Record, metadata: Record, ) => Record; + normalizeGitLabGeneralDiscussion: ( + discussion: Record, + url: string, + ) => Record | null; normalizeGitLabReviewComment: ( note: Record, url: string, threadId?: string, + rootNote?: Record, ) => Record | null; parseGitLabMergeRequestUrl: (url: string) => Record; readMergeRequestReviewComments: ( launchPath: string, source: Record, - ) => Promise>>; + ) => Promise<{ + generalComments: ReadonlyArray>; + reviewComments: ReadonlyArray>; + }>; submitMergeRequestComment: ( launchPath: string, request: { @@ -376,6 +385,144 @@ describe('GitLab merge requests', () => { }); }); + test('makes GitLab replies inherit the root position, range, and thread identity', () => { + const root = { + author: { username: 'root-reviewer' }, + body: 'Root.', + created_at: '2026-06-17T00:00:00Z', + id: 44, + position: { + base_sha: 'a'.repeat(40), + head_sha: 'b'.repeat(40), + line_range: { + end: { new_line: 14, old_line: null, type: 'new' }, + start: { new_line: null, old_line: 10, type: 'old' }, + }, + new_line: 14, + new_path: 'src/new-name.ts', + old_line: 10, + old_path: 'src/old-name.ts', + }, + resolvable: true, + }; + const reply = { + author: { username: 'reply-reviewer' }, + body: 'Reply with misleading coordinates.', + created_at: '2026-06-18T00:00:00Z', + id: 45, + position: { + new_line: 99, + new_path: 'src/reply.ts', + old_path: 'src/reply.ts', + }, + }; + + expect( + normalizeGitLabReviewComment( + reply, + 'https://gitlab.example.com/group/project/-/merge_requests/23', + 'discussion-44', + root, + ), + ).toMatchObject({ + body: 'Reply with misleading coordinates.', + canResolveThread: true, + filePath: 'src/new-name.ts', + lineNumber: 14, + position: { + range: { + base: { sha: 'a'.repeat(40) }, + head: { sha: 'b'.repeat(40) }, + }, + }, + side: 'additions', + startLineNumber: 10, + startSide: 'deletions', + threadId: 'discussion-44', + }); + }); + + test('uses original GitLab positions for outdated old-side and file comments', () => { + const url = 'https://gitlab.example.com/group/project/-/merge_requests/23'; + const oldSide = normalizeGitLabReviewComment( + { + author: { username: 'reviewer' }, + body: 'Historical old-side comment.', + created_at: '2026-06-17T00:00:00Z', + id: 46, + original_position: { + base_sha: 'a'.repeat(40), + head_sha: 'b'.repeat(40), + new_path: 'src/new-name.ts', + old_line: 7, + old_path: 'src/old-name.ts', + }, + }, + url, + ); + const file = normalizeGitLabReviewComment( + { + author: { username: 'reviewer' }, + body: 'Historical file comment.', + created_at: '2026-06-17T00:00:00Z', + id: 47, + original_position: { + base_sha: 'a'.repeat(40), + head_sha: 'b'.repeat(40), + new_path: 'src/new-name.ts', + old_path: 'src/old-name.ts', + position_type: 'file', + }, + }, + url, + ); + + expect(oldSide).toMatchObject({ + filePath: 'src/new-name.ts', + isOutdated: true, + lineNumber: 7, + side: 'deletions', + }); + expect(file).toMatchObject({ + anchor: 'file', + filePath: 'src/new-name.ts', + isOutdated: true, + }); + }); + + test('maps positionless GitLab discussions to overview threads', () => { + expect( + normalizeGitLabGeneralDiscussion( + { + id: 'overview-thread', + notes: [ + { + author: { username: 'author' }, + body: 'Overview root.', + created_at: '2026-06-17T00:00:00Z', + id: 50, + resolvable: true, + }, + { + author: { username: 'reply-author' }, + body: 'Overview reply.', + created_at: '2026-06-18T00:00:00Z', + id: 51, + }, + ], + }, + 'https://gitlab.example.com/group/project/-/merge_requests/23', + ), + ).toMatchObject({ + canResolve: true, + comments: [ + { body: 'Overview root.', id: 'gitlab:50' }, + { body: 'Overview reply.', id: 'gitlab:51' }, + ], + id: 'overview-thread', + }); + }); + test('submits GitLab reviews with paginated diffs and JSON request bodies', async () => { await withFakeGitLab(async (repo, readCalls) => { const source = { @@ -469,13 +616,16 @@ describe('GitLab merge requests', () => { type: 'pull-request', url: 'https://gitlab.example.com/group/project/-/merge_requests/23', }), - ).resolves.toEqual([ - expect.objectContaining({ - body: 'Loaded discussion comment.', - id: 'gitlab:47', - threadId: 'discussion-from-provider', - }), - ]); + ).resolves.toEqual({ + generalComments: [], + reviewComments: [ + expect.objectContaining({ + body: 'Loaded discussion comment.', + id: 'gitlab:47', + threadId: 'discussion-from-provider', + }), + ], + }); }); }); diff --git a/core/__tests__/review-comments.test.ts b/core/__tests__/review-comments.test.ts index 161bce30..331cf46c 100644 --- a/core/__tests__/review-comments.test.ts +++ b/core/__tests__/review-comments.test.ts @@ -81,6 +81,7 @@ const createPullRequestState = (): RepositoryState => ({ kind: 'pull-request', patch: 'diff --git a/src/a.ts b/src/a.ts\n@@ -1,6 +1,6 @@\n one\n two\n three\n four\n five\n six\n', + range: providerPosition.range, }, ], status: 'modified', @@ -96,6 +97,7 @@ const createPullRequestState = (): RepositoryState => ({ id: 'github:1', isOutdated: true, lineNumber: 5, + position: providerPosition, side: 'additions', }, { @@ -104,6 +106,7 @@ const createPullRequestState = (): RepositoryState => ({ filePath: 'src/a.ts', id: 'github:2', lineNumber: 6, + position: providerPosition, side: 'additions', }, ], @@ -419,6 +422,7 @@ test('getReviewCommentsFromState preserves file-level GitLab anchors', () => { body: 'Review the file as a whole.', filePath: 'src/a.ts', id: 'gitlab:file', + position: providerPosition, }, ]; @@ -429,6 +433,7 @@ test('getReviewCommentsFromState preserves file-level GitLab anchors', () => { filePath: 'src/a.ts', id: 'gitlab:file', isReadOnly: true, + position: providerPosition, resolvedSectionId: 'src/a.ts:pull-request:1', }), ]); diff --git a/core/app/RepositoryReviewHost.tsx b/core/app/RepositoryReviewHost.tsx index 58076a27..20219c70 100644 --- a/core/app/RepositoryReviewHost.tsx +++ b/core/app/RepositoryReviewHost.tsx @@ -21,10 +21,13 @@ import { import { reconcileRepositoryRefresh } from '../lib/repository-refresh.ts'; import type { RepositoryReviewBootstrap } from '../lib/repository-review-bootstrap.ts'; import { resolveReviewCommandTarget } from '../lib/review-command-target.ts'; +import { diffRangesMatch } from '../lib/review-comment-target.ts'; import { getReviewCommentsFromState, + isProviderReviewCommentPosition, isReviewDraft, mergeReviewComments, + reviewCommentRegionSectionPrefix, toProviderSubmittedReviewComment, toPullRequestExistingReviewComment, } from '../lib/review-comments.ts'; @@ -64,6 +67,7 @@ import type { NarrativeWalkthrough, NarrativeWalkthroughResult, OpenReviewSourceKind, + PullRequestExistingReviewComment, RepositoryState, ReviewSource, DiffSection, @@ -272,6 +276,71 @@ const mergeStateReviewComments = ( currentComments: ReadonlyArray, ) => mergeReviewComments(getReviewCommentsFromState(state), currentComments.filter(isReviewDraft)); +const getReviewCommentRegionKey = (comment: PullRequestExistingReviewComment) => { + const position = comment.position; + if (!position || !isProviderReviewCommentPosition(position)) { + return null; + } + return `${encodeURIComponent(comment.filePath)}:${position.range.base.sha}:${position.range.head.sha}`; +}; + +const createReviewCommentRegionFile = ( + state: RepositoryState, + comment: PullRequestExistingReviewComment, +): ChangedFile | null => { + const key = getReviewCommentRegionKey(comment); + const position = comment.position; + if ( + !key || + !position || + (comment.anchor !== 'file' && (comment.lineNumber == null || comment.side == null)) + ) { + return null; + } + const currentFile = state.files.find((file) => file.path === comment.filePath); + if (currentFile?.sections.some((section) => diffRangesMatch(section.range, position.range))) { + return null; + } + + const id = `${reviewCommentRegionSectionPrefix}${key}`; + return { + fingerprint: id, + ...(currentFile?.oldPath ? { oldPath: currentFile.oldPath } : {}), + path: comment.filePath, + sections: [ + { + binary: false, + id, + kind: 'pull-request', + loadState: 'deferred', + patch: '', + range: position.range, + summary: { + canLoad: true, + reason: 'Loading the exact code region for this review thread.', + }, + }, + ], + status: currentFile?.status ?? 'modified', + }; +}; + +const mergeReviewCommentRegionFiles = ( + files: ReadonlyArray, + regions: ReadonlyArray, +) => { + const remaining = new Map(regions.map((file) => [file.path, file])); + const merged = files.map((file) => { + const region = remaining.get(file.path); + if (!region) { + return file; + } + remaining.delete(file.path); + return { ...file, sections: [...file.sections, ...region.sections] }; + }); + return [...merged, ...remaining.values()]; +}; + export type RepositoryReviewHostProps = { bootstrap: RepositoryReviewBootstrap; config: CodiffConfig; @@ -344,6 +413,10 @@ export function RepositoryReviewHost({ ...initialState, files: sortFiles(initialState.files), })); + const [reviewCommentRegions, setReviewCommentRegions] = useState<{ + files: ReadonlyArray; + sourceKey: string; + } | null>(null); const [updateStatus, setUpdateStatus] = useState(null); const historyRequestRef = useRef(0); const historySourceRef = useRef(null); @@ -480,7 +553,7 @@ export function RepositoryReviewHost({ void window.codiff .getReviewComments(requestedState.source, requestId) - .then((loadedComments) => { + .then(({ generalComments, reviewComments: loadedComments }) => { if (!isCurrentState()) { return; } @@ -490,6 +563,7 @@ export function RepositoryReviewHost({ } const hydratedState = { ...current, + generalComments, reviewComments: loadedComments, reviewCommentsError: undefined, reviewCommentsLoadState: 'loaded' as const, @@ -866,6 +940,43 @@ export function RepositoryReviewHost({ [reviewContentRun], ); + const loadReviewCommentRegion = useCallback( + async (comment: PullRequestExistingReviewComment) => { + const current = stateRef.current; + if (!current || !reviewContentRun) { + return; + } + const file = createReviewCommentRegionFile(current, comment); + if (!file) { + return; + } + const section = file.sections[0]!; + const loadedFile = { + ...file, + sections: [ + hydrateSectionFromContents( + file, + section, + await reviewContentRun.resolveSectionContents(file, section), + ), + ], + }; + const sourceKey = `${current.root}:${getSourceRevisionKey(current.source)}`; + const latest = stateRef.current; + if (!latest || `${latest.root}:${getSourceRevisionKey(latest.source)}` !== sourceKey) { + return; + } + setReviewCommentRegions((regions) => ({ + files: + regions?.sourceKey === sourceKey + ? mergeReviewCommentRegionFiles(regions.files, [loadedFile]) + : [loadedFile], + sourceKey, + })); + }, + [reviewContentRun], + ); + const resolveImage = useCallback( (file: ChangedFile, section: DiffSection) => reviewContentRun?.resolveImage(file, section) ?? @@ -1651,10 +1762,17 @@ export function RepositoryReviewHost({ ? 'failed' : 'idle'; const walkthroughAgent = launchOptions.agentBackend ?? config.settings.agentBackend; + const snapshotState = + reviewCommentRegions?.sourceKey === `${state.root}:${getSourceRevisionKey(state.source)}` + ? { + ...state, + files: mergeReviewCommentRegionFiles(state.files, reviewCommentRegions.files), + } + : state; const snapshot = { ...buildSharedReviewSnapshot({ preferences, - state, + state: snapshotState, title, walkthrough: narrativeWalkthrough ?? createPlaceholderWalkthrough(state, title, walkthroughAgent), @@ -1740,6 +1858,7 @@ export function RepositoryReviewHost({ initialScrollTarget: surfaceInitialScrollTarget, itemVersionByKey, loadingSectionIds, + onLoadCommentRegion: loadReviewCommentRegion, onLoadSection: loadDiffSection, onRefreshMarkdown: refreshMarkdownFile, resolveImage, diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index 8fd0508a..f3f6591a 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -1,5 +1,6 @@ import type { MarkdownEditorHandle } from '@nkzw/mdx-editor'; import { frontmatterPlugin, imagePlugin } from '@nkzw/mdx-editor/core'; +import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; import { CaretDownIcon as CaretDown } from '@phosphor-icons/react/CaretDown'; import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; import { CheckIcon as Check } from '@phosphor-icons/react/Check'; @@ -1650,7 +1651,7 @@ function ReviewCommentEditor({ ); return ( -
+
{comment.author ? ( ) : ( @@ -1668,7 +1669,28 @@ function ReviewCommentEditor({ : '' }${comment.isReadOnly ? ' read-only' : ''}`} > - {displayName} + {comment.author?.url ? ( + + {displayName} + + ) : ( + {displayName} + )} + {comment.submittedAt ? ( + + ) : null} + {comment.url ? ( + + View comment + + + ) : null} {editingExistingComment ? ( + {expanded ?
{children}
: null} + + ); +} + +export function SidebarInlineReviewCommentList({ + comments, + focusedCommentId, + onActivateComment, + permalinkLabel, +}: { + comments: ReadonlyArray; + focusedCommentId: string | null; + onActivateComment: (commentId: string) => void; + permalinkLabel: string; +}) { + if (comments.length === 0) { + return
No inline review comments.
; + } + + return ( +
+ {comments.map((comment) => { + const displayName = getAuthorDisplayName(comment.author); + return ( + + ); + })} +
+ ); +} + function GeneralCommentComposer({ disabled, draft, @@ -578,6 +718,7 @@ function GeneralCommentComposer({ export function MergeRequestCommentsView({ canComment, commenting, + commentPermalinkLabel, draft, editDraft, editError, @@ -587,6 +728,7 @@ export function MergeRequestCommentsView({ focusedCommentId, focusedCommentRequest, gitIdentity, + inlineCommentCount, keymap, onCancelEdit, onChangeDraft, @@ -602,6 +744,7 @@ export function MergeRequestCommentsView({ }: { canComment: boolean; commenting?: ReviewCommenting; + commentPermalinkLabel: string; draft: string; editDraft: string; editError: string | null; @@ -611,6 +754,7 @@ export function MergeRequestCommentsView({ focusedCommentId: string | null; focusedCommentRequest: number; gitIdentity: GitIdentity | null; + inlineCommentCount: number; keymap: CodiffKeymap; onCancelEdit: () => void; onChangeDraft: (draft: string) => void; @@ -678,6 +822,7 @@ export function MergeRequestCommentsView({ } onSaveEdit={onSaveEdit} onStartEdit={onStartEdit} + permalinkLabel={commentPermalinkLabel} thread={thread} /> ))} @@ -685,8 +830,14 @@ export function MergeRequestCommentsView({ ) : (
- No comments yet - Add a comment to start the discussion. + + {inlineCommentCount > 0 ? 'No overview comments yet' : 'No review comments yet'} + + + {inlineCommentCount > 0 + ? 'Inline review comments are available in Tree.' + : 'Add inline feedback from Tree to start the review.'} +
)} diff --git a/core/global.d.ts b/core/global.d.ts index a3761c73..d8f8b0f2 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -33,6 +33,7 @@ import type { SharedWalkthroughSnapshot, ShareWalkthroughResult, SubmitPullRequestCommentRequest, + PullRequestGeneralCommentThread, PullRequestExistingReviewComment, RevisionContentBatchRequest, RevisionContentBatchResult, @@ -83,7 +84,10 @@ declare global { getReviewComments: ( source: Extract, requestId?: string, - ) => Promise>; + ) => Promise<{ + generalComments: ReadonlyArray; + reviewComments: ReadonlyArray; + }>; getTerminalHelperStatus: () => Promise; getUpdateStatus: () => Promise; increaseCodeFontSize: () => Promise; diff --git a/core/lib/review-comments.ts b/core/lib/review-comments.ts index 7519364a..cdfa68ad 100644 --- a/core/lib/review-comments.ts +++ b/core/lib/review-comments.ts @@ -118,9 +118,14 @@ export const isProviderReviewCommentPosition = ( export const getReviewCommentRendererSectionId = (comment: ReviewComment) => isReviewDraft(comment) ? comment.sectionId : comment.resolvedSectionId; +export const reviewCommentRegionSectionPrefix = 'review-comment-region:'; + +export const isReviewCommentRegionSection = (section: Pick) => + section.id.startsWith(reviewCommentRegionSectionPrefix); + export const isFileReviewComment = ( comment: Pick, -) => comment.anchor === 'file' || comment.lineNumber == null || comment.side == null; +) => comment.anchor === 'file'; export const isLineReviewComment = ( comment: ReviewComment, @@ -174,6 +179,9 @@ export const getReviewCommentLineLabel = ( if (isFileReviewComment(comment)) { return 'File'; } + if (comment.lineNumber == null || comment.side == null) { + return 'Location unavailable'; + } const startLineNumber = comment.startLineNumber; const startSide = getReviewCommentStartSide(comment); if ( @@ -532,6 +540,76 @@ const resolveReviewCommentSectionCandidates = ( strategy, }; +const sectionContainsReviewCommentAnchor = ( + file: ChangedFile, + section: DiffSection, + comment: Pick, + showWhitespace: boolean, +) => { + if (isFileReviewComment(comment)) { + return true; + } + if (comment.lineNumber == null || comment.side == null) { + return false; + } + + const line = comment.lineNumber; + const side = comment.side; + const startLine = comment.startLineNumber ?? line; + const startSide = comment.startSide ?? side; + const parsed = parseSectionDiffWithOptions(file, section, showWhitespace); + return parsed.hunks.some((hunk) => { + let oldLine = hunk.deletionStart; + let newLine = hunk.additionStart; + let hasStart = false; + let hasEnd = false; + for (const content of hunk.hunkContent) { + if (content.type === 'context') { + if ( + (startSide === 'additions' && + startLine >= newLine && + startLine < newLine + content.lines) || + (startSide === 'deletions' && startLine >= oldLine && startLine < oldLine + content.lines) + ) { + hasStart = true; + } + if ( + (side === 'additions' && line >= newLine && line < newLine + content.lines) || + (side === 'deletions' && line >= oldLine && line < oldLine + content.lines) + ) { + hasEnd = true; + } + oldLine += content.lines; + newLine += content.lines; + continue; + } + if (side === 'deletions' && line >= oldLine && line < oldLine + content.deletions) { + hasEnd = true; + } + if ( + startSide === 'deletions' && + startLine >= oldLine && + startLine < oldLine + content.deletions + ) { + hasStart = true; + } + if (side === 'additions' && line >= newLine && line < newLine + content.additions) { + hasEnd = true; + } + if ( + startSide === 'additions' && + startLine >= newLine && + startLine < newLine + content.additions + ) { + hasStart = true; + } + oldLine += content.deletions; + newLine += content.additions; + } + return hasStart && hasEnd; + }); +}; + export const resolveReviewCommentSection = ( file: ChangedFile, comment: Pick< @@ -543,7 +621,11 @@ export const resolveReviewCommentSection = ( const positionedRange = comment.position?.range; if (positionedRange) { return resolveReviewCommentSectionCandidates( - file.sections.filter((candidate) => diffRangesMatch(candidate.range, positionedRange)), + file.sections.filter( + (candidate) => + diffRangesMatch(candidate.range, positionedRange) && + sectionContainsReviewCommentAnchor(file, candidate, comment, showWhitespace), + ), 'position', ); } @@ -559,65 +641,9 @@ export const resolveReviewCommentSection = ( return resolveReviewCommentSectionCandidates(file.sections, 'file'); } - const side = comment.side ?? 'additions'; - const line = comment.lineNumber ?? 1; - const startLine = comment.startLineNumber ?? line; - const startSide = comment.startSide ?? side; - const matchingSections = file.sections.filter((section) => { - const parsed = parseSectionDiffWithOptions(file, section, showWhitespace); - return parsed.hunks.some((hunk) => { - let oldLine = hunk.deletionStart; - let newLine = hunk.additionStart; - let hasStart = false; - let hasEnd = false; - for (const content of hunk.hunkContent) { - if (content.type === 'context') { - if ( - (startSide === 'additions' && - startLine >= newLine && - startLine < newLine + content.lines) || - (startSide === 'deletions' && - startLine >= oldLine && - startLine < oldLine + content.lines) - ) { - hasStart = true; - } - if ( - (side === 'additions' && line >= newLine && line < newLine + content.lines) || - (side === 'deletions' && line >= oldLine && line < oldLine + content.lines) - ) { - hasEnd = true; - } - oldLine += content.lines; - newLine += content.lines; - continue; - } - if (side === 'deletions' && line >= oldLine && line < oldLine + content.deletions) { - hasEnd = true; - } - if ( - startSide === 'deletions' && - startLine >= oldLine && - startLine < oldLine + content.deletions - ) { - hasStart = true; - } - if (side === 'additions' && line >= newLine && line < newLine + content.additions) { - hasEnd = true; - } - if ( - startSide === 'additions' && - startLine >= newLine && - startLine < newLine + content.additions - ) { - hasStart = true; - } - oldLine += content.deletions; - newLine += content.additions; - } - return hasStart && hasEnd; - }); - }); + const matchingSections = file.sections.filter((section) => + sectionContainsReviewCommentAnchor(file, section, comment, showWhitespace), + ); return resolveReviewCommentSectionCandidates(matchingSections, 'coordinates'); }; @@ -684,7 +710,12 @@ export const getReviewCommentsFromState = ( ): ReadonlyArray => (state.reviewComments ?? []).map((comment) => { const file = state.files.find((candidate) => candidate.path === comment.filePath); - const section = file ? getReviewCommentSection(file, comment, false) : undefined; + const hasProviderPosition = + comment.position != null && isProviderReviewCommentPosition(comment.position); + const section = + file && (destination === 'share' || hasProviderPosition) + ? getReviewCommentSection(file, comment, false) + : undefined; const common = { author: comment.author, body: comment.body, diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index 487188c2..8c0fb062 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -161,9 +161,15 @@ const readMergeRequestHydrationSnapshot = async (repoRoot, mergeRequest, options return snapshot; }; -/** @param {any} note @param {string} url @param {string} [threadId] */ -const normalizeGitLabReviewComment = (note, url, threadId) => { - const position = note.position || note.original_position; +/** @param {string} sha */ +const createReviewCommitRevision = (sha) => ({ + label: { kind: /** @type {const} */ ('commit'), text: sha.slice(0, 7) }, + sha, +}); + +/** @param {any} note @param {string} url @param {string} [threadId] @param {any} [rootNote] */ +const normalizeGitLabReviewComment = (note, url, threadId, rootNote = note) => { + const position = rootNote.position || rootNote.original_position; const isFilePosition = position?.position_type === 'file'; const lineNumber = position?.new_line ?? position?.old_line; const filePath = position?.new_path || position?.old_path; @@ -174,6 +180,12 @@ const normalizeGitLabReviewComment = (note, url, threadId) => { const range = position.line_range; const start = range?.start; const end = range?.end; + const endSide = end?.type === 'old' ? 'deletions' : side; + const startSide = start?.type === 'old' ? 'deletions' : 'additions'; + const startLineNumber = start?.new_line ?? start?.old_line; + const endLineNumber = end?.new_line ?? end?.old_line ?? lineNumber; + const hasRange = + startLineNumber != null && (startLineNumber !== endLineNumber || startSide !== endSide); return { author: { avatarUrl: note.author?.avatar_url, @@ -183,20 +195,32 @@ const normalizeGitLabReviewComment = (note, url, threadId) => { body: note.body, filePath, id: `gitlab:${note.id}`, - ...(!note.position ? { isOutdated: true } : {}), + ...(!rootNote.position ? { isOutdated: true } : {}), ...(isFilePosition ? { anchor: 'file' } : { - lineNumber: end?.new_line ?? end?.old_line ?? lineNumber, - side: end?.type === 'old' ? 'deletions' : side, + lineNumber: endLineNumber, + side: endSide, }), - ...(start && (start.new_line ?? start.old_line) !== (end?.new_line ?? end?.old_line) + ...(hasRange ? { - startLineNumber: start.new_line ?? start.old_line, - startSide: start.type === 'old' ? 'deletions' : 'additions', + startLineNumber, + ...(startSide !== endSide ? { startSide } : {}), } : {}), ...(threadId ? { threadId } : {}), + ...(position?.base_sha && position?.head_sha + ? { + position: { + range: { + base: createReviewCommitRevision(position.base_sha), + head: createReviewCommitRevision(position.head_sha), + }, + }, + } + : {}), + ...(rootNote.resolvable === true ? { canResolveThread: true } : {}), + ...(rootNote.resolved === true ? { isThreadResolved: true } : {}), submittedAt: note.created_at, url: `${url}#note_${note.id}`, }; @@ -237,13 +261,48 @@ const readMergeRequestComments = async (repoRoot, mergeRequest, transport) => { path: mergeRequestEndpoint(mergeRequest, '/discussions'), query: { per_page: 100 }, }); - return discussions - .flatMap((discussion) => - (discussion.notes || []).map((note) => ({ note, threadId: discussion.id })), - ) - .filter(({ note }) => !note.system && !note.resolved) - .map(({ note, threadId }) => normalizeGitLabReviewComment(note, mergeRequest.url, threadId)) - .filter(Boolean); + return { + generalComments: discussions + .map((discussion) => normalizeGitLabGeneralDiscussion(discussion, mergeRequest.url)) + .filter(Boolean), + reviewComments: discussions.flatMap((discussion) => { + const notes = (discussion.notes || []).filter((note) => !note.system && note.body); + const root = notes[0]; + if (!root || (!root.position && !root.original_position)) { + return []; + } + return notes + .map((note) => normalizeGitLabReviewComment(note, mergeRequest.url, discussion.id, root)) + .filter(Boolean); + }), + }; +}; + +/** Treat a positionless GitLab discussion as one overview-comment thread. */ +const normalizeGitLabGeneralDiscussion = (discussion, url) => { + const notes = (discussion?.notes || []).filter((note) => !note.system && note.body); + const root = notes[0]; + if (!root || root.position || root.original_position) { + return null; + } + const comments = notes.map((note) => ({ + author: { + avatarUrl: note.author?.avatar_url, + login: note.author?.username || note.author?.name || 'GitLab user', + url: note.author?.web_url, + }, + body: note.body, + id: `gitlab:${note.id}`, + submittedAt: note.created_at, + url: `${url}#note_${note.id}`, + })); + const id = typeof discussion?.id === 'string' ? discussion.id : String(discussion?.id || ''); + return { + ...(root.resolvable === true ? { canResolve: true } : {}), + comments, + id: id || `gitlab:general:${comments[0].id}`, + ...(root.resolved === true ? { isResolved: true } : {}), + }; }; /** @param {ReturnType} mergeRequest @param {any} metadata @returns {Extract} */ @@ -402,6 +461,7 @@ module.exports = { createMergeRequestFetchRefspecs, createMergeRequestSource, listMergeRequestHistory, + normalizeGitLabGeneralDiscussion, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, resolveGitLabCommentTarget, diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index 79dc738a..48fa70b5 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -365,13 +365,33 @@ const isGitHubReviewSide = (side) => side === 'LEFT' || side === 'RIGHT'; /** @param {...unknown} values */ const firstNumber = (...values) => values.find((value) => typeof value === 'number'); -/** @param {GitHubReviewComment} comment */ -const normalizeGitHubReviewComment = (comment) => { - const lineNumber = firstNumber(comment.line, comment.original_line); - if (!comment.path || !comment.body) { +/** @param {string} sha */ +const createReviewCommitRevision = (sha) => ({ + label: { kind: /** @type {const} */ ('commit'), text: sha.slice(0, 7) }, + sha: /** @type {GitSha} */ (sha), +}); + +/** + * @param {GitHubReviewComment} comment + * @param {GitHubReviewComment} [rootComment] + * @param {string} [baseSha] + */ +const normalizeGitHubReviewComment = (comment, rootComment = comment, baseSha) => { + const lineNumber = firstNumber(rootComment.line, rootComment.original_line); + if (!rootComment.path || !comment.body) { return null; } - if (lineNumber == null && comment.subject_type === 'file') { + const reviewCommitSha = rootComment.original_commit_id || rootComment.commit_id; + const position = + baseSha && reviewCommitSha + ? { + range: { + base: createReviewCommitRevision(baseSha), + head: createReviewCommitRevision(reviewCommitSha), + }, + } + : undefined; + if (lineNumber == null && rootComment.subject_type === 'file') { return { anchor: 'file', author: { @@ -380,9 +400,12 @@ const normalizeGitHubReviewComment = (comment) => { url: comment.user?.html_url, }, body: comment.body, - filePath: comment.path, + filePath: rootComment.path, id: `github:${comment.id}`, - threadId: String(comment.in_reply_to_id || comment.id), + ...(position ? { position } : {}), + threadId: String( + rootComment === comment ? comment.in_reply_to_id || comment.id : rootComment.id, + ), submittedAt: comment.created_at, url: comment.html_url, }; @@ -391,10 +414,10 @@ const normalizeGitHubReviewComment = (comment) => { return null; } - const side = fromGitHubReviewSide(comment.side); - const startLineNumber = firstNumber(comment.start_line, comment.original_start_line); - const startSide = isGitHubReviewSide(comment.start_side) - ? fromGitHubReviewSide(comment.start_side) + const side = fromGitHubReviewSide(rootComment.side); + const startLineNumber = firstNumber(rootComment.start_line, rootComment.original_start_line); + const startSide = isGitHubReviewSide(rootComment.start_side) + ? fromGitHubReviewSide(rootComment.start_side) : undefined; const hasRange = startLineNumber != null && (startLineNumber !== lineNumber || (startSide ?? side) !== side); @@ -406,14 +429,17 @@ const normalizeGitHubReviewComment = (comment) => { url: comment.user?.html_url, }, body: comment.body, - filePath: comment.path, + filePath: rootComment.path, id: `github:${comment.id}`, - ...(typeof comment.line !== 'number' ? { isOutdated: true } : {}), + ...(typeof rootComment.line !== 'number' ? { isOutdated: true } : {}), lineNumber, + ...(position ? { position } : {}), side, ...(hasRange ? { startLineNumber } : {}), ...(hasRange && startSide != null && startSide !== side ? { startSide } : {}), - threadId: String(comment.in_reply_to_id || comment.id), + threadId: String( + rootComment === comment ? comment.in_reply_to_id || comment.id : rootComment.id, + ), submittedAt: comment.created_at, url: comment.html_url, }; @@ -436,12 +462,56 @@ const collectResolvedReviewCommentIds = (threads) => { return ids; }; -/** @param {ReadonlyArray} comments @param {ReadonlySet} resolvedCommentIds */ -const selectUnresolvedReviewComments = (comments, resolvedCommentIds) => - comments +/** + * @param {ReadonlyArray} comments + * @param {ReadonlySet} resolvedCommentIds + * @param {string} [baseSha] + */ +const selectUnresolvedReviewComments = (comments, resolvedCommentIds, baseSha) => { + const byId = new Map(comments.map((comment) => [comment.id, comment])); + return comments .filter((comment) => !resolvedCommentIds.has(comment.id)) - .map(normalizeGitHubReviewComment) + .map((comment) => { + const root = byId.get(comment.in_reply_to_id) || comment; + return normalizeGitHubReviewComment(comment, root, baseSha); + }) .filter(Boolean); +}; + +/** @param {GitHubReviewComment} comment @param {'issue' | 'review'} kind */ +const normalizeGitHubGeneralComment = (comment, kind) => { + if (!comment?.body || typeof comment.id !== 'number') { + return null; + } + return { + author: { + avatarUrl: comment.user?.avatar_url, + login: comment.user?.login || 'GitHub user', + url: comment.user?.html_url, + }, + body: comment.body, + id: `github:${kind}:${comment.id}`, + submittedAt: comment.submitted_at || comment.created_at, + url: comment.html_url, + }; +}; + +/** + * @param {ReadonlyArray} issueComments + * @param {ReadonlyArray} reviews + */ +const selectGitHubGeneralCommentThreads = (issueComments, reviews) => + [ + ...issueComments.map((comment) => normalizeGitHubGeneralComment(comment, 'issue')), + ...reviews + .filter((review) => review.state !== 'PENDING') + .map((review) => normalizeGitHubGeneralComment(review, 'review')), + ] + .filter(Boolean) + .sort((left, right) => + String(left.submittedAt || '').localeCompare(String(right.submittedAt || '')), + ) + .map((comment) => ({ comments: [comment], id: comment.id })); const RESOLVED_REVIEW_THREADS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -504,7 +574,7 @@ const readResolvedReviewCommentIds = async (repoRoot, pullRequest) => { }; /** @param {string} repoRoot @param {PullRequestReference} pullRequest */ -const readPullRequestComments = async (repoRoot, pullRequest) => { +const readPullRequestComments = async (repoRoot, pullRequest, baseSha) => { const [pages, resolvedCommentIds] = await Promise.all([ ghApi(repoRoot, [ '--paginate', @@ -513,7 +583,24 @@ const readPullRequestComments = async (repoRoot, pullRequest) => { ]).then((output) => JSON.parse(output)), readResolvedReviewCommentIds(repoRoot, pullRequest), ]); - return selectUnresolvedReviewComments(pages.flat(), resolvedCommentIds); + return selectUnresolvedReviewComments(pages.flat(), resolvedCommentIds, baseSha); +}; + +/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ +const readPullRequestGeneralComments = async (repoRoot, pullRequest) => { + const [issuePages, reviewPages] = await Promise.all([ + ghApi(repoRoot, [ + '--paginate', + '--slurp', + `repos/${pullRequest.owner}/${pullRequest.repo}/issues/${pullRequest.number}/comments?per_page=100`, + ]).then((output) => JSON.parse(output)), + ghApi(repoRoot, [ + '--paginate', + '--slurp', + `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews?per_page=100`, + ]).then((output) => JSON.parse(output)), + ]); + return selectGitHubGeneralCommentThreads(issuePages.flat(), reviewPages.flat()); }; /** @param {string} repoRoot @param {PullRequestReference} pullRequest @returns {Promise>} */ @@ -652,12 +739,14 @@ const readPullRequestReviewComments = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const pullRequest = parseGitHubPullRequestUrl(source.url); await assertPullRequestMatchesRepository(repoRoot, pullRequest); - const comments = await readPullRequestComments(repoRoot, pullRequest); - const metadata = await readPullRequestMetadata(repoRoot, pullRequest); - if (source.headSha && metadata.head?.sha !== source.headSha) { - throw new Error('The pull request head changed. Refresh before loading review comments.'); - } - return comments; + const { range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { + expectedHeadSha: source.headSha, + }); + const [reviewComments, generalComments] = await Promise.all([ + readPullRequestComments(repoRoot, pullRequest, range.baseSha), + readPullRequestGeneralComments(repoRoot, pullRequest), + ]); + return { generalComments, reviewComments }; }; const { submitPullRequestComment, submitPullRequestReview } = createGitHubReviewMutations({ @@ -674,6 +763,7 @@ module.exports = { createPullRequestHistoryFetchRefspecs, createPullRequestSource, listPullRequestHistory, + normalizeGitHubGeneralComment, normalizeGitHubCommit, normalizeGitHubPullRequestCommit, normalizeGitHubReviewComment, @@ -681,6 +771,7 @@ module.exports = { parseGitHubPullRequestUrl, readPullRequestReviewComments, readPullRequestState, + selectGitHubGeneralCommentThreads, selectPullRequestRemote, selectUnresolvedReviewComments, submitPullRequestComment, From 76628b8e682e20c24d0b1dadeda9028fc01d449d Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 14:38:37 -0500 Subject: [PATCH 16/17] Attribute startup Git and gh/glab calls to the window that started them Record `git`, `gh`, and `glab` child processes against the window action that started them, use `initial-load` for startup, redact tokens from argv, and preserve cancellation for shared processes. Have `scripts/check-startup-trace.mjs` verify first-usable and deferred-completion ordering and budget. --- electron/__tests__/command-log.test.ts | 302 +++++++++++++++ .../__tests__/gh-github-transport.test.ts | 99 ++++- electron/__tests__/git-identity.test.ts | 66 +++- .../__tests__/glab-gitlab-transport.test.ts | 130 ++++++- electron/command-log.cjs | 353 ++++++++++++++++++ electron/git-state/common.cjs | 161 ++++++-- .../github-history/gh-github-transport.cjs | 176 ++++++--- electron/git-state/glab-gitlab-transport.cjs | 166 ++++++-- electron/git-state/working-tree.cjs | 63 +++- electron/main.cjs | 168 +++++++-- electron/main/command-line.cjs | 6 +- electron/review-source.cjs | 8 +- electron/walkthrough-commit.cjs | 16 +- electron/window-identity.cjs | 24 +- 14 files changed, 1535 insertions(+), 203 deletions(-) create mode 100644 electron/__tests__/command-log.test.ts create mode 100644 electron/command-log.cjs diff --git a/electron/__tests__/command-log.test.ts b/electron/__tests__/command-log.test.ts new file mode 100644 index 00000000..a25d49cc --- /dev/null +++ b/electron/__tests__/command-log.test.ts @@ -0,0 +1,302 @@ +import { chmod, readFile, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import process from 'node:process'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { createTemporaryGitRepository } from './helpers/git-repository.ts'; + +const require = createRequire(import.meta.url); +const { + configureCommandLog, + disableCommandLog, + flushCommandLog, + recordCommandMilestone, + runWithCommandAction, + sanitizeArgs, + startCommandAction, + startCommandTiming, +} = require('../command-log.cjs') as { + configureCommandLog: (logsDirectory: string) => string; + disableCommandLog: () => void; + flushCommandLog: () => Promise; + recordCommandMilestone: (name: string) => void; + runWithCommandAction: ( + input: { command: string; details?: Record }, + callback: () => Promise, + ) => Promise; + startCommandAction: (input: { command: string; details?: Record }) => { + cancel: () => void; + run: (callback: () => Value) => Value; + signal: AbortSignal; + }; + sanitizeArgs: (args: ReadonlyArray) => ReadonlyArray; + startCommandTiming: (input: { + args?: ReadonlyArray; + command: string; + cwd?: string; + details?: Record; + kind?: 'action' | 'command'; + }) => { finish: (result?: { error?: unknown; exitCode?: number | null }) => void }; +}; +const { git } = require('../git-state/common.cjs') as { + git: ( + repoPath: string, + args: ReadonlyArray, + options?: { signal?: AbortSignal }, + ) => Promise; +}; + +type CommandLogRecord = { + args?: ReadonlyArray; + command?: string; + details?: Record; + durationMs?: number; + exitCode?: number; + actionId?: number; + event: 'finish' | 'milestone' | 'session-start' | 'start'; + id?: number; + kind?: 'action' | 'command'; + status?: 'canceled' | 'error' | 'ok' | 'timeout'; +}; + +type TemporaryGitRepository = Awaited>; + +const temporaryRepositories: Array = []; + +const createCommandLogRepository = async () => { + const repository = await createTemporaryGitRepository('codiff-command-log-'); + temporaryRepositories.push(repository); + return repository.path; +}; + +afterEach(async () => { + await flushCommandLog(); + disableCommandLog(); + await Promise.all( + temporaryRepositories.splice(0).map((repository) => repository[Symbol.asyncDispose]()), + ); +}); + +test('records action and Git command timings without affecting command results', async () => { + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + + await runWithCommandAction( + { + command: 'review-load', + details: { sourceType: 'pull-request' }, + }, + async () => { + recordCommandMilestone('evidence-ready'); + await expect(git(repository, ['rev-parse', '--show-toplevel'])).resolves.toContain( + 'codiff-command-log-', + ); + await expect( + git(repository, ['rev-parse', '--verify', 'refs/codiff-tests/definitely-missing']), + ).rejects.toThrow(); + }, + ); + await flushCommandLog(); + + const records = (await readFile(path, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CommandLogRecord); + expect(records[0]).toEqual( + expect.objectContaining({ + event: 'session-start', + }), + ); + + const actionStart = records.find( + (record) => record.event === 'start' && record.command === 'review-load', + ); + expect(actionStart).toEqual( + expect.objectContaining({ + details: { sourceType: 'pull-request' }, + kind: 'action', + }), + ); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'review-load', + durationMs: expect.any(Number), + event: 'finish', + id: actionStart?.id, + status: 'ok', + }), + ); + + const gitStarts = records.filter( + (record) => record.event === 'start' && record.command === 'git', + ); + expect(gitStarts).toHaveLength(2); + expect(gitStarts.every((record) => record.actionId === actionStart?.id)).toBe(true); + expect(gitStarts[0]?.args).toEqual(expect.arrayContaining(['rev-parse', '--show-toplevel'])); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'git', + durationMs: expect.any(Number), + event: 'finish', + exitCode: 0, + id: gitStarts[0]?.id, + status: 'ok', + }), + ); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'git', + durationMs: expect.any(Number), + event: 'finish', + exitCode: expect.any(Number), + id: gitStarts[1]?.id, + status: 'error', + }), + ); +}); + +test('records canceled Git commands at a standalone action boundary', async () => { + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + const controller = new AbortController(); + controller.abort(); + + await expect( + git(repository, ['rev-parse', '--show-toplevel'], { signal: controller.signal }), + ).rejects.toThrow(); + await flushCommandLog(); + + const records = (await readFile(path, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CommandLogRecord); + const commandStart = records.find( + (record) => record.event === 'start' && record.command === 'git', + ); + expect(commandStart?.actionId).toEqual(expect.any(Number)); + expect(records).toContainEqual( + expect.objectContaining({ + actionId: commandStart?.actionId, + command: 'git', + event: 'finish', + id: commandStart?.id, + status: 'canceled', + }), + ); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'standalone-command', + event: 'finish', + id: commandStart?.actionId, + status: 'canceled', + }), + ); +}); + +test('cancels Git commands through their enclosing action signal', async () => { + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + const action = startCommandAction({ command: 'initial-load' }); + + action.cancel(); + expect(action.signal.aborted).toBe(true); + await expect( + action.run(() => git(repository, ['rev-parse', '--show-toplevel'])), + ).rejects.toMatchObject({ name: 'AbortError' }); + await flushCommandLog(); + + const records = (await readFile(path, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CommandLogRecord); + const actionStart = records.find( + (record) => record.event === 'start' && record.command === 'initial-load', + ); + expect(records).toContainEqual( + expect.objectContaining({ + actionId: actionStart?.id, + command: 'git', + event: 'finish', + status: 'canceled', + }), + ); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'initial-load', + event: 'finish', + id: actionStart?.id, + status: 'canceled', + }), + ); +}); + +test('redacts inline headers, URL userinfo, arrays, and nested details', async () => { + const secrets = [ + 'header-secret', + 'inline-header-secret', + 'short-header-secret', + 'url-user-secret', + 'url-password-secret', + 'query-secret', + 'array-secret', + 'nested-secret', + ]; + const args = sanitizeArgs([ + 'api', + '--header', + 'Authorization: Bearer header-secret', + '--header=PRIVATE-TOKEN: inline-header-secret', + '-HAuthorization: Bearer short-header-secret', + 'https://url-user-secret:url-password-secret@provider.example.test/path?private_token=query-secret&ref=main', + ]); + expect(JSON.stringify(args)).not.toContain(secrets.join('|')); + for (const secret of secrets.slice(0, 6)) { + expect(JSON.stringify(args)).not.toContain(secret); + } + + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + const timing = startCommandTiming({ + command: 'security-shape', + details: { + array: ['safe', { token: 'array-secret' }], + nested: { authorization: 'nested-secret' }, + }, + }); + timing.finish(); + await flushCommandLog(); + const contents = await readFile(path, 'utf8'); + expect(contents).not.toContain('array-secret'); + expect(contents).not.toContain('nested-secret'); + expect(contents).toContain('[REDACTED]'); +}); + +test('command log initialization is idempotent', async () => { + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + const timing = startCommandTiming({ command: 'primary-sentinel' }); + timing.finish(); + await flushCommandLog(); + expect(configureCommandLog(repository)).toBe(path); + await flushCommandLog(); + const contents = await readFile(path, 'utf8'); + expect(contents.match(/"event":"session-start"/g)).toHaveLength(1); + expect(contents).toContain('primary-sentinel'); +}); + +test('creates command logs with owner-only permissions under a permissive umask', async () => { + if (process.platform === 'win32') { + return; + } + const repository = await createCommandLogRepository(); + await chmod(repository, 0o777); + const previousUmask = process.umask(0); + let path = ''; + try { + path = configureCommandLog(repository); + await flushCommandLog(); + } finally { + process.umask(previousUmask); + } + expect((await stat(repository)).mode & 0o077).toBe(0); + expect((await stat(path)).mode & 0o077).toBe(0); +}); diff --git a/electron/__tests__/gh-github-transport.test.ts b/electron/__tests__/gh-github-transport.test.ts index 2b7a5fef..459092bb 100644 --- a/electron/__tests__/gh-github-transport.test.ts +++ b/electron/__tests__/gh-github-transport.test.ts @@ -25,6 +25,7 @@ const { createGhGitHubTransport } = maxBytes?: number; path: string; query?: Record; + signal?: AbortSignal; }) => Promise; requestText: (request: { accept?: string; @@ -35,11 +36,24 @@ const { createGhGitHubTransport } = }) => Promise; }; }; +const { configureCommandLog, disableCommandLog, flushCommandLog, startCommandAction } = + require('../command-log.cjs') as { + configureCommandLog: (logsDirectory: string) => string; + disableCommandLog: () => void; + flushCommandLog: () => Promise; + startCommandAction: (input: { command: string }) => { + cancel: () => void; + run: (callback: () => Value) => Value; + signal: AbortSignal; + }; + }; const previousGhPath = process.env.CODIFF_GH_PATH; const previousCallsPath = process.env.CODIFF_GH_TEST_CALLS; -afterEach(() => { +afterEach(async () => { + await flushCommandLog(); + disableCommandLog(); if (previousGhPath == null) delete process.env.CODIFF_GH_PATH; else process.env.CODIFF_GH_PATH = previousGhPath; if (previousCallsPath == null) delete process.env.CODIFF_GH_TEST_CALLS; @@ -229,3 +243,86 @@ process.stdout.write('[\\n {"id": 1, "label": "nested } and \\\\"quoted\\\\" te ]); expect((await readFile(callsPath, 'utf8')).trim()).toBe('call'); }); + +test('createGhGitHubTransport inherits cancellation from its enclosing action', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +setInterval(() => {}, 1_000); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + const commandLogPath = configureCommandLog(directory); + + const transport = createGhGitHubTransport({ repoRoot: directory }); + const action = startCommandAction({ command: 'initial-load' }); + const pending = action.run(() => + transport.requestBuffer({ + path: '/repos/nkzw-tech/codiff/git/blobs/deadbeef', + }), + ); + setTimeout(() => action.cancel(), 25); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(action.signal.aborted).toBe(true); + await flushCommandLog(); + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + const actionStart = commandLog.find( + (record) => record.event === 'start' && record.command === 'initial-load', + ); + const finishes = commandLog.filter( + (record) => record.event === 'finish' && record.command === fakeGhPath, + ); + expect(finishes).toEqual([ + expect.objectContaining({ + actionId: actionStart?.id, + canceled: true, + errorName: 'AbortError', + status: 'canceled', + }), + ]); +}); + +test('createGhGitHubTransport records cancellation before spawning a provider process', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + await writeFile(fakeGhPath, '#!/usr/bin/env node\n', 'utf8'); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + const commandLogPath = configureCommandLog(directory); + const controller = new AbortController(); + controller.abort(); + + const transport = createGhGitHubTransport({ repoRoot: directory }); + await expect( + transport.requestBuffer({ + path: '/repos/nkzw-tech/codiff/git/blobs/deadbeef', + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError' }); + await flushCommandLog(); + + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect( + commandLog.filter((record) => record.event === 'start' && record.command === fakeGhPath), + ).toHaveLength(1); + expect(commandLog).toContainEqual( + expect.objectContaining({ + canceled: true, + command: fakeGhPath, + errorName: 'AbortError', + event: 'finish', + status: 'canceled', + }), + ); +}); diff --git a/electron/__tests__/git-identity.test.ts b/electron/__tests__/git-identity.test.ts index 6c839e0e..89746a49 100644 --- a/electron/__tests__/git-identity.test.ts +++ b/electron/__tests__/git-identity.test.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; -import { writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; -import { join } from 'node:path'; +import { delimiter, join } from 'node:path'; import { promisify } from 'node:util'; import { expect, test } from 'vite-plus/test'; import { @@ -13,6 +13,13 @@ const require = createRequire(import.meta.url); const { readGitIdentity } = require('../git-state/working-tree.cjs') as { readGitIdentity: (path: string) => Promise<{ email: string; name: string }>; }; +const { startCommandAction } = require('../command-log.cjs') as { + startCommandAction: (input: { command: string }) => { + cancel: () => void; + run: (callback: () => Value) => Value; + signal: AbortSignal; + }; +}; const execFileAsync = promisify(execFile); const git = async (repo: string, args: ReadonlyArray) => { @@ -47,7 +54,7 @@ test('uses configured git identity without inferring it from the current commit email: '', name: '', }); -}); +}, 30_000); test('reads the global git identity outside a repository', async () => { await using directory = await createTemporaryDirectory('codiff-global-git-identity-'); @@ -60,3 +67,56 @@ test('reads the global git identity outside a repository', async () => { name: 'Global User', }); }); + +test.sequential('single-flights concurrent Git identity reads without retaining stale values', async () => { + await using directory = await createTemporaryDirectory('codiff-git-identity-single-flight-'); + const fakeBin = join(directory.path, 'bin'); + const fakeGit = join(fakeBin, 'git'); + const callsPath = join(directory.path, 'calls.txt'); + await mkdir(fakeBin); + await writeFile( + fakeGit, + `#!/usr/bin/env node +const fs = require('node:fs'); +const key = process.argv.at(-1); +fs.appendFileSync(process.env.CODIFF_GIT_IDENTITY_CALLS, key + '\\n'); +setTimeout(() => process.stdout.write(key === 'user.name' ? 'Codiff User\\n' : 'codiff@example.com\\n'), 150); +`, + 'utf8', + ); + await chmod(fakeGit, 0o755); + await using _environment = createTemporaryEnvironment({ + CODIFF_GIT_IDENTITY_CALLS: callsPath, + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ''}`, + }); + + await expect( + Promise.all([readGitIdentity(directory.path), readGitIdentity(directory.path)]), + ).resolves.toEqual([ + expect.objectContaining({ email: 'codiff@example.com', name: 'Codiff User' }), + expect.objectContaining({ email: 'codiff@example.com', name: 'Codiff User' }), + ]); + expect((await readFile(callsPath, 'utf8')).trim().split('\n').sort()).toEqual([ + 'user.email', + 'user.name', + ]); + + await expect(readGitIdentity(directory.path)).resolves.toMatchObject({ + email: 'codiff@example.com', + name: 'Codiff User', + }); + expect((await readFile(callsPath, 'utf8')).trim().split('\n')).toHaveLength(4); + + const canceledAction = startCommandAction({ command: 'initial-load' }); + const activeAction = startCommandAction({ command: 'initial-load' }); + const canceledRead = canceledAction.run(() => readGitIdentity(directory.path)); + const activeRead = activeAction.run(() => readGitIdentity(directory.path)); + setTimeout(() => canceledAction.cancel(), 25); + + await expect(canceledRead).rejects.toMatchObject({ name: 'AbortError' }); + expect(canceledAction.signal.aborted).toBe(true); + await expect(activeRead).resolves.toMatchObject({ + email: 'codiff@example.com', + name: 'Codiff User', + }); +}); diff --git a/electron/__tests__/glab-gitlab-transport.test.ts b/electron/__tests__/glab-gitlab-transport.test.ts index 62a516ed..cba5b9cf 100644 --- a/electron/__tests__/glab-gitlab-transport.test.ts +++ b/electron/__tests__/glab-gitlab-transport.test.ts @@ -5,6 +5,17 @@ import { join } from 'node:path'; import { afterEach, expect, test } from 'vite-plus/test'; const require = createRequire(import.meta.url); +const { configureCommandLog, disableCommandLog, flushCommandLog, startCommandAction } = + require('../command-log.cjs') as { + configureCommandLog: (logsDirectory: string) => string; + disableCommandLog: () => void; + flushCommandLog: () => Promise; + startCommandAction: (input: { command: string }) => { + cancel: () => void; + run: (callback: () => Value) => Value; + signal: AbortSignal; + }; + }; const { createGlabGitLabTransport } = require('../git-state/glab-gitlab-transport.cjs') as { createGlabGitLabTransport: (options: { hostname: string; repoRoot: string }) => { request: (request: { @@ -23,6 +34,7 @@ const { createGlabGitLabTransport } = require('../git-state/glab-gitlab-transpor maxBytes?: number; path: string; query?: Record; + signal?: AbortSignal; }) => Promise; requestText: (request: { maxBytes?: number; @@ -35,7 +47,9 @@ const { createGlabGitLabTransport } = require('../git-state/glab-gitlab-transpor const previousGlabPath = process.env.CODIFF_GLAB_PATH; const previousCallsPath = process.env.CODIFF_GLAB_TEST_CALLS; -afterEach(() => { +afterEach(async () => { + await flushCommandLog(); + disableCommandLog(); if (previousGlabPath == null) { delete process.env.CODIFF_GLAB_PATH; } else { @@ -84,6 +98,7 @@ process.stdin.on('end', () => { await chmod(fakeGlabPath, 0o755); process.env.CODIFF_GLAB_PATH = fakeGlabPath; process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + const commandLogPath = configureCommandLog(directory); const transport = createGlabGitLabTransport({ hostname: 'gitlab.example.com', @@ -96,6 +111,30 @@ process.stdin.on('end', () => { const calls = JSON.parse((await readFile(callsPath, 'utf8')).trim()) as { args: Array }; expect(calls.args).toContain('/projects/group%2Fproject/merge_requests/7/versions'); expect(calls.args).not.toContain('/api/v4/projects/group%2Fproject/merge_requests/7/versions'); + await flushCommandLog(); + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + const start = commandLog.find( + (record) => record.event === 'start' && record.command === fakeGlabPath, + ); + expect(start).toEqual( + expect.objectContaining({ + args: calls.args, + kind: 'command', + }), + ); + expect(commandLog).toContainEqual( + expect.objectContaining({ + command: fakeGlabPath, + durationMs: expect.any(Number), + event: 'finish', + exitCode: 0, + id: start?.id, + status: 'ok', + }), + ); }); test('createGlabGitLabTransport drains oversized JSON, text, binary, and paginated responses', async () => { @@ -316,6 +355,95 @@ process.stdout.write(Buffer.from([0, 255, 10, 128])); expect(calls.args).toContain('/projects/group%2Fproject/repository/blobs/deadbeef/raw'); }); +test('createGlabGitLabTransport inherits cancellation from its enclosing action', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +setInterval(() => {}, 1_000); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + const commandLogPath = configureCommandLog(directory); + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const action = startCommandAction({ command: 'initial-load' }); + const pending = action.run(() => + transport.requestBuffer({ + path: '/api/v4/projects/group%2Fproject/repository/blobs/deadbeef/raw', + }), + ); + setTimeout(() => action.cancel(), 25); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(action.signal.aborted).toBe(true); + await flushCommandLog(); + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + const actionStart = commandLog.find( + (record) => record.event === 'start' && record.command === 'initial-load', + ); + const finishes = commandLog.filter( + (record) => record.event === 'finish' && record.command === fakeGlabPath, + ); + expect(finishes).toEqual([ + expect.objectContaining({ + actionId: actionStart?.id, + canceled: true, + errorName: 'AbortError', + status: 'canceled', + }), + ]); +}); + +test('createGlabGitLabTransport records cancellation before spawning a provider process', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + await writeFile(fakeGlabPath, '#!/usr/bin/env node\n', 'utf8'); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + const commandLogPath = configureCommandLog(directory); + const controller = new AbortController(); + controller.abort(); + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + await expect( + transport.requestBuffer({ + path: '/api/v4/projects/group%2Fproject/repository/blobs/deadbeef/raw', + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError' }); + await flushCommandLog(); + + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect( + commandLog.filter((record) => record.event === 'start' && record.command === fakeGlabPath), + ).toHaveLength(1); + expect(commandLog).toContainEqual( + expect.objectContaining({ + canceled: true, + command: fakeGlabPath, + errorName: 'AbortError', + event: 'finish', + status: 'canceled', + }), + ); +}); + test('createGlabGitLabTransport flattens all glab paginated response pages', async () => { const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); const fakeGlabPath = join(directory, 'glab'); diff --git a/electron/command-log.cjs b/electron/command-log.cjs new file mode 100644 index 00000000..5c2431b0 --- /dev/null +++ b/electron/command-log.cjs @@ -0,0 +1,353 @@ +// @ts-check + +const { appendFile, chmod, mkdir, writeFile } = require('node:fs/promises'); +const { AsyncLocalStorage } = require('node:async_hooks'); +const { dirname, join } = require('node:path'); +const { performance } = require('node:perf_hooks'); + +/** @type {string | null} */ +let commandLogPath = null; +let nextTimingId = 0; +let pendingWrites = Promise.resolve(); +/** @type {AsyncLocalStorage<{actionId: number, signal: AbortSignal}>} */ +const actionContext = new AsyncLocalStorage(); + +const secretFlag = /(?:authorization|password|private[-_]?token|secret|token)/i; + +/** @param {unknown} error */ +const processErrorOutcome = (error) => { + if (!error || typeof error !== 'object') { + return { exitCode: null, signal: null }; + } + const result = /** @type {{code?: unknown, exitCode?: unknown, signal?: unknown}} */ (error); + return { + exitCode: + typeof result.exitCode === 'number' + ? result.exitCode + : typeof result.code === 'number' + ? result.code + : null, + signal: typeof result.signal === 'string' ? result.signal : null, + }; +}; + +/** @param {string} value */ +const sanitizeValue = (value) => { + if (/^https?:/i.test(value) || (value.startsWith('/') && value.includes('?'))) { + try { + const absolute = /^https?:/i.test(value); + const url = new URL(value, 'https://codiff.invalid'); + if (url.username) url.username = '[REDACTED]'; + if (url.password) url.password = '[REDACTED]'; + for (const key of url.searchParams.keys()) { + if (secretFlag.test(key)) { + url.searchParams.set(key, '[REDACTED]'); + } + } + return absolute ? url.toString() : `${url.pathname}${url.search}`; + } catch { + return value; + } + } + const header = /^([^:]+):\s*(.*)$/.exec(value); + if (header && secretFlag.test(header[1])) { + return `${header[1]}: [REDACTED]`; + } + const equals = value.indexOf('='); + return equals > 0 && secretFlag.test(value.slice(0, equals)) + ? `${value.slice(0, equals + 1)}[REDACTED]` + : value; +}; + +/** @param {ReadonlyArray} args */ +const sanitizeArgs = (args) => { + let redactNext = false; + return args.map((argument) => { + if (redactNext) { + redactNext = false; + return '[REDACTED]'; + } + const lower = argument.toLowerCase(); + if (lower === '--header' || lower === '-h') { + redactNext = true; + return argument; + } + if (lower.startsWith('--header=')) { + return `${argument.slice(0, argument.indexOf('=') + 1)}${sanitizeValue( + argument.slice(argument.indexOf('=') + 1), + )}`; + } + if (lower.startsWith('-h') && argument.length > 2) { + return `${argument.slice(0, 2)}${sanitizeValue(argument.slice(2))}`; + } + if (secretFlag.test(argument.split('=')[0])) { + return argument.includes('=') ? sanitizeValue(argument) : argument; + } + return sanitizeValue(argument); + }); +}; + +/** @param {unknown} value @param {string} [key] */ +const sanitizeDetailValue = (value, key = '') => { + if (secretFlag.test(key)) return '[REDACTED]'; + if (typeof value === 'string') return sanitizeValue(value); + if (Array.isArray(value)) return value.map((item) => sanitizeDetailValue(item)); + if (value instanceof Error) { + return { + message: sanitizeValue(value.message), + name: value.name, + ...(value.cause == null ? {} : { cause: sanitizeDetailValue(value.cause) }), + }; + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([entryKey, item]) => [ + entryKey, + sanitizeDetailValue(item, entryKey), + ]), + ); + } + return value; +}; + +/** @param {Record} details */ +const sanitizeDetails = (details) => + /** @type {Record} */ (sanitizeDetailValue(details)); + +/** @param {() => Promise} write */ +const enqueueWrite = (write) => { + pendingWrites = pendingWrites.then(write, write).catch(() => { + // Diagnostics must never interrupt the action being measured. + }); +}; + +/** @param {string} path @param {Record} record */ +const appendRecord = (path, record) => { + enqueueWrite(async () => { + await appendFile(path, `${JSON.stringify(record)}\n`, 'utf8'); + }); +}; + +/** + * Start a fresh command log for this Electron session. + * @param {string} logsDirectory + * @param {{processStartedAt?: string}} [options] + */ +const configureCommandLog = (logsDirectory, options = {}) => { + const path = process.env.CODIFF_COMMAND_LOG_PATH || join(logsDirectory, 'commands.jsonl'); + if (commandLogPath === path) { + return path; + } + commandLogPath = path; + nextTimingId = 0; + enqueueWrite(async () => { + const directory = dirname(path); + await mkdir(directory, { mode: 0o700, recursive: true }); + await chmod(directory, 0o700); + await writeFile( + path, + `${JSON.stringify({ + event: 'session-start', + monotonicMs: 0, + pid: process.pid, + timestamp: options.processStartedAt || new Date().toISOString(), + })}\n`, + { encoding: 'utf8', mode: 0o600 }, + ); + await chmod(path, 0o600); + }); + return path; +}; + +/** + * @param {{ + * args?: ReadonlyArray; + * command: string; + * cwd?: string; + * details?: Record; + * kind?: 'action' | 'command'; + * }} input + */ +const startCommandTiming = (input) => { + const path = commandLogPath; + const contextActionId = actionContext.getStore()?.actionId; + const implicitActionId = + path && input.kind !== 'action' && !contextActionId ? (nextTimingId += 1) : null; + const id = (nextTimingId += 1); + const startedAt = performance.now(); + const startedTimestamp = new Date().toISOString(); + const parentActionId = contextActionId || implicitActionId; + let finished = false; + if (path) { + if (implicitActionId) { + appendRecord(path, { + command: 'standalone-command', + details: sanitizeDetails({ executable: input.command }), + event: 'start', + id: implicitActionId, + kind: 'action', + timestamp: startedTimestamp, + }); + } + appendRecord(path, { + ...(input.args ? { args: sanitizeArgs(input.args) } : {}), + ...(parentActionId ? { actionId: parentActionId } : {}), + command: input.command, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.details ? { details: sanitizeDetails(input.details) } : {}), + event: 'start', + id, + kind: input.kind || 'command', + timestamp: startedTimestamp, + }); + } + + return { + id, + /** @param {{canceled?: boolean, error?: unknown, exitCode?: number | null, signal?: string | null, timedOut?: boolean}} [result] */ + finish: (result = {}) => { + if (finished) { + return; + } + finished = true; + if (!path) { + return; + } + const failed = result.error != null || (result.exitCode != null && result.exitCode !== 0); + const errorOutcome = processErrorOutcome(result.error); + const exitCode = + result.exitCode ?? + errorOutcome.exitCode ?? + (input.kind !== 'action' && !failed && !result.canceled && !result.timedOut ? 0 : null); + const signal = result.signal || errorOutcome.signal; + const durationMs = Math.round((performance.now() - startedAt) * 100) / 100; + const status = result.timedOut + ? 'timeout' + : result.canceled + ? 'canceled' + : failed + ? 'error' + : 'ok'; + appendRecord(path, { + ...(parentActionId ? { actionId: parentActionId } : {}), + ...(result.canceled ? { canceled: true } : {}), + command: input.command, + durationMs, + ...(result.error != null + ? { errorName: result.error instanceof Error ? result.error.name : 'Error' } + : {}), + event: 'finish', + ...(exitCode != null ? { exitCode } : {}), + id, + kind: input.kind || 'command', + ...(signal ? { signal } : {}), + status, + timestamp: new Date().toISOString(), + ...(result.timedOut ? { timedOut: true } : {}), + }); + if (implicitActionId) { + appendRecord(path, { + command: 'standalone-command', + durationMs, + event: 'finish', + id: implicitActionId, + kind: 'action', + status, + timestamp: new Date().toISOString(), + }); + } + }, + }; +}; + +/** @param {string} name @param {{actionId?: number, details?: Record, monotonicMs?: number, timestamp?: string}} [options] */ +const recordCommandMilestone = (name, options = {}) => { + if (!commandLogPath) { + return; + } + appendRecord(commandLogPath, { + actionId: options.actionId || actionContext.getStore()?.actionId, + ...(options.details ? { details: sanitizeDetails(options.details) } : {}), + event: 'milestone', + monotonicMs: options.monotonicMs ?? performance.now(), + name, + timestamp: options.timestamp || new Date().toISOString(), + }); +}; + +/** + * Return the cancellation boundary for the enclosing command action, if one + * exists. Canonical process runners use this as their default signal so a + * caller cannot accidentally leave a startup command outside its action. + * + * @returns {AbortSignal | undefined} + */ +const getCommandActionSignal = () => actionContext.getStore()?.signal; + +/** @param {{command: string, cwd?: string, details?: Record}} input */ +const startCommandAction = (input) => { + const timing = startCommandTiming({ ...input, kind: 'action' }); + const controller = new AbortController(); + let finished = false; + + /** @param {{canceled?: boolean, error?: unknown, exitCode?: number | null, signal?: string | null, timedOut?: boolean}} [result] */ + const finish = (result = {}) => { + if (finished) { + return; + } + finished = true; + if (result.canceled && !controller.signal.aborted) { + controller.abort(); + } + timing.finish(result); + }; + + return { + ...timing, + cancel: () => finish({ canceled: true }), + finish, + signal: controller.signal, + /** @template Value @param {() => Value} callback */ + run: (callback) => + actionContext.run({ actionId: timing.id, signal: controller.signal }, callback), + }; +}; + +/** + * @template Value + * @param {{command: string, cwd?: string, details?: Record}} input + * @param {() => Promise} callback + */ +const runWithCommandAction = async (input, callback) => { + const timing = startCommandAction(input); + return timing.run(async () => { + try { + const value = await callback(); + timing.finish(); + return value; + } catch (error) { + timing.finish({ + canceled: error instanceof Error && error.name === 'AbortError', + error, + }); + throw error; + } + }); +}; + +const flushCommandLog = () => pendingWrites; +const disableCommandLog = () => { + commandLogPath = null; +}; + +module.exports = { + configureCommandLog, + disableCommandLog, + flushCommandLog, + getCommandActionSignal, + recordCommandMilestone, + runWithCommandAction, + sanitizeArgs, + startCommandAction, + startCommandTiming, +}; diff --git a/electron/git-state/common.cjs b/electron/git-state/common.cjs index 7081028d..6a4e5f69 100644 --- a/electron/git-state/common.cjs +++ b/electron/git-state/common.cjs @@ -1,17 +1,16 @@ // @ts-check const { AsyncLocalStorage } = require('node:async_hooks'); -const { execFile, spawn } = require('node:child_process'); +const { execFile, execFileSync, spawn } = require('node:child_process'); const { promises: fs } = require('node:fs'); const { createHash } = require('node:crypto'); const { isAbsolute, join, normalize, sep } = require('node:path'); const { promisify } = require('node:util'); +const { getCommandActionSignal, startCommandTiming } = require('../command-log.cjs'); const execFileAsync = promisify(execFile); const commandSignalStorage = new AsyncLocalStorage(); - const getCurrentCommandSignal = () => commandSignalStorage.getStore(); - /** @template Value @param {AbortSignal} signal @param {() => Value} callback */ const runWithCommandSignal = (signal, callback) => commandSignalStorage.run(signal, callback); @@ -61,22 +60,71 @@ const getGravatarHash = (email) => * @returns {Promise} */ const git = async (repoPath, args, options = {}) => { - const { stdout } = await execFileAsync('git', ['-C', repoPath, ...args], { - encoding: options.encoding || 'utf8', - maxBuffer: 1024 * 1024 * 64, - signal: options.signal ?? getCurrentCommandSignal(), - }); - return stdout; + const commandArgs = ['-C', repoPath, ...args]; + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); + const timing = startCommandTiming({ args: commandArgs, command: 'git', cwd: repoPath }); + try { + const { stdout } = await execFileAsync('git', commandArgs, { + encoding: options.encoding || 'utf8', + maxBuffer: 1024 * 1024 * 64, + signal, + }); + timing.finish(); + return stdout; + } catch (error) { + timing.finish({ canceled: error instanceof Error && error.name === 'AbortError', error }); + throw error; + } }; /** @param {string} repoPath @param {ReadonlyArray} args @param {{signal?: AbortSignal}} [options] @returns {Promise} */ const gitBuffer = async (repoPath, args, options = {}) => { - const { stdout } = await execFileAsync('git', ['-C', repoPath, ...args], { - encoding: 'buffer', - maxBuffer: 1024 * 1024 * 64, - signal: options.signal ?? getCurrentCommandSignal(), - }); - return stdout; + const commandArgs = ['-C', repoPath, ...args]; + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); + const timing = startCommandTiming({ args: commandArgs, command: 'git', cwd: repoPath }); + try { + const { stdout } = await execFileAsync('git', commandArgs, { + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 64, + signal, + }); + timing.finish(); + return stdout; + } catch (error) { + timing.finish({ canceled: error instanceof Error && error.name === 'AbortError', error }); + throw error; + } +}; + +/** + * Canonical synchronous Git boundary for startup paths that must resolve + * before a window exists. + * @param {string} repoPath + * @param {ReadonlyArray} args + * @param {{encoding?: BufferEncoding}} [options] + */ +const gitSync = (repoPath, args, options = {}) => { + (getCurrentCommandSignal() || getCommandActionSignal())?.throwIfAborted(); + const commandArgs = ['-C', repoPath, ...args]; + const timing = startCommandTiming({ args: commandArgs, command: 'git', cwd: repoPath }); + try { + const output = execFileSync('git', commandArgs, { + encoding: options.encoding || 'utf8', + maxBuffer: 1024 * 1024 * 64, + stdio: ['ignore', 'pipe', 'ignore'], + }); + timing.finish({ exitCode: 0 }); + return output; + } catch (error) { + timing.finish({ + error, + exitCode: + error && typeof error === 'object' && 'status' in error && typeof error.status === 'number' + ? error.status + : null, + }); + throw error; + } }; /** @@ -88,36 +136,80 @@ const gitBuffer = async (repoPath, args, options = {}) => { */ const gitBufferWithInput = (repoPath, args, input, options = {}) => new Promise((resolve, reject) => { - const child = spawn('git', ['-C', repoPath, ...args], { - env: options.env, - signal: options.signal ?? getCurrentCommandSignal(), - stdio: ['pipe', 'pipe', 'pipe'], - }); + const commandArgs = ['-C', repoPath, ...args]; + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); + const timing = startCommandTiming({ args: commandArgs, command: 'git', cwd: repoPath }); /** @type {Array} */ const stdout = []; /** @type {Array} */ const stderr = []; + let settled = false; + + /** @param {unknown} reason @param {{canceled?: boolean, exitCode?: number | null, signal?: string | null}} [result] */ + const fail = (reason, result = {}) => { + if (settled) return; + settled = true; + const error = reason instanceof Error ? reason : new Error(String(reason)); + timing.finish({ ...result, error }); + reject(error); + }; + + const abortError = () => { + const reason = signal?.reason; + if (reason instanceof Error) return reason; + const error = new Error('Git command was aborted.'); + error.name = 'AbortError'; + return error; + }; + + let child; + try { + signal?.throwIfAborted(); + child = spawn('git', commandArgs, { + env: options.env, + signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + fail(error, { canceled: signal?.aborted }); + return; + } child.stdout.on('data', (chunk) => stdout.push(chunk)); child.stderr.on('data', (chunk) => stderr.push(chunk)); child.stdin.on('error', (error) => { - if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE') { - reject(error); + if (!settled && /** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE') { + fail(error, { canceled: signal?.aborted }); + child.kill(); } }); - child.on('error', reject); - child.on('close', (code) => { + child.on('error', (error) => + fail(error, { canceled: error.name === 'AbortError' || signal?.aborted }), + ); + child.on('close', (code, childSignal) => { + if (settled) return; + if (signal?.aborted) { + fail(abortError(), { canceled: true, exitCode: code, signal: childSignal }); + return; + } if (code === 0) { + settled = true; + timing.finish({ exitCode: code }); resolve(Buffer.concat(stdout)); } else { const error = new Error( Buffer.concat(stderr).toString('utf8') || `git exited with status ${code}`, ); - reject(error); + fail(error, { exitCode: code, signal: childSignal }); } }); - child.stdin.end(input); + try { + child.stdin.end(input); + } catch (error) { + fail(error, { canceled: signal?.aborted }); + child.kill(); + } }); const EAGER_TEXT_FILE_LIMIT = 1024 * 1024; @@ -796,17 +888,13 @@ const normalizeStatus = (statusCode) => ? 'renamed' : 'modified'; -/** @param {string} repoRoot @param {ReadonlyArray} args */ -const gitOrEmpty = async (repoRoot, args) => { +/** @param {string} repoRoot @param {ReadonlyArray} args @param {{signal?: AbortSignal}} [options] */ +const gitOrEmpty = async (repoRoot, args, options = {}) => { + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); try { - return await git(repoRoot, args); - } catch (error) { - if ( - getCurrentCommandSignal()?.aborted || - (error instanceof Error && error.name === 'AbortError') - ) { - throw error; - } + return await git(repoRoot, args, { ...options, signal }); + } catch { + signal?.throwIfAborted(); return ''; } }; @@ -829,6 +917,7 @@ module.exports = { git, gitBufferWithInput, gitOrEmpty, + gitSync, normalizeStatus, parseStatus, readFileStat, diff --git a/electron/git-state/github-history/gh-github-transport.cjs b/electron/git-state/github-history/gh-github-transport.cjs index 7361caf3..39422010 100644 --- a/electron/git-state/github-history/gh-github-transport.cjs +++ b/electron/git-state/github-history/gh-github-transport.cjs @@ -6,6 +6,7 @@ */ const { spawn } = require('node:child_process'); +const { getCommandActionSignal, startCommandTiming } = require('../../command-log.cjs'); const { realpathSync } = require('node:fs'); const { homedir } = require('node:os'); const { join } = require('node:path'); @@ -55,18 +56,26 @@ const enforceOutputLimit = (bytes, maxBytes) => { }; /** - * Share only uncancelable GETs, briefly retaining completed bytes so each - * consumer can enforce its own response bound. Concurrent consumers may raise - * the acquisition bound until output crosses it; a later larger consumer - * starts a new read only after an earlier bounded read discarded bytes. + * Share GETs within one cancellation boundary, briefly retaining completed + * bytes so each consumer can enforce its own response bound. Concurrent + * consumers may raise the acquisition bound until output crosses it; a later + * larger consumer starts a new read only after an earlier bounded read + * discarded bytes. * @param {string} key * @param {number | undefined} maxBytes * @param {(options: {getMaxBytes: () => number | undefined, onOutputLimit: () => void}) => Promise} read + * @param {AbortSignal | undefined} signal * @returns {Promise} */ -const readSharedGet = (key, maxBytes, read) => { +const readSharedGet = (key, maxBytes, read, signal) => { + let requestsBySignal = sharedGetRequests.get(key); + if (!requestsBySignal) { + requestsBySignal = new Map(); + sharedGetRequests.set(key, requestsBySignal); + } + const signalKey = signal || null; /** @type {SharedGetRequest | undefined} */ - const existing = sharedGetRequests.get(key); + const existing = requestsBySignal.get(signalKey); if (existing) { if (existing.status === 'fulfilled') { return existing.promise; @@ -103,11 +112,14 @@ const readSharedGet = (key, maxBytes, read) => { }), ); entry.promise = request; - sharedGetRequests.set(key, entry); + requestsBySignal.set(signalKey, entry); const expire = () => { const timeout = setTimeout(() => { - if (sharedGetRequests.get(key) === entry) { - sharedGetRequests.delete(key); + if (requestsBySignal.get(signalKey) === entry) { + requestsBySignal.delete(signalKey); + if (requestsBySignal.size === 0 && sharedGetRequests.get(key) === requestsBySignal) { + sharedGetRequests.delete(key); + } } }, SHARED_GET_RETENTION_MS); timeout.unref?.(); @@ -175,6 +187,7 @@ const runGhApiBuffer = async (repoRoot, args, input, options = {}) => { const environment = await getCommandEnvironment(); return new Promise((resolve, reject) => { const fixedMaxBytes = normalizeMaxBytes(options.maxBytes); + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); let command; try { command = getGhCommand(); @@ -183,12 +196,21 @@ const runGhApiBuffer = async (repoRoot, args, input, options = {}) => { return; } - const child = spawn(command, ['api', ...args], { - cwd: repoRoot, - env: environment, - signal: options.signal ?? getCurrentCommandSignal(), - stdio: ['pipe', 'pipe', 'pipe'], - }); + const timing = startCommandTiming({ args: ['api', ...args], command, cwd: repoRoot }); + let child; + try { + signal?.throwIfAborted(); + child = spawn(command, ['api', ...args], { + cwd: repoRoot, + env: environment, + signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + timing.finish({ canceled: signal?.aborted, error }); + reject(error); + return; + } /** @type {Array} */ const stdout = []; /** @type {Array} */ @@ -198,12 +220,42 @@ const runGhApiBuffer = async (repoRoot, args, input, options = {}) => { let forceKillTimeout; let outputBytes = 0; let outputLimit; + let settled = false; + const terminate = () => { child.kill('SIGTERM'); forceKillTimeout ??= setTimeout(() => child.kill('SIGKILL'), 1_000); forceKillTimeout.unref?.(); }; - child.stdout.on('data', (chunk) => { + + /** + * @param {unknown} reason + * @param {{canceled?: boolean, exitCode?: number | null, signal?: string | null}} [result] + */ + const fail = (reason, result = {}) => { + if (settled) return; + settled = true; + const error = reason instanceof Error ? reason : new Error(String(reason)); + timing.finish({ + canceled: result.canceled, + error, + exitCode: result.exitCode, + signal: result.signal, + }); + reject(error); + }; + + const abortError = () => { + const reason = signal?.reason; + if (reason instanceof Error) return reason; + const error = new Error('gh api request was aborted.'); + error.name = 'AbortError'; + return error; + }; + + child.stdout.on('data', (value) => { + if (settled) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); outputBytes += chunk.length; if (outputLimit != null) { return; @@ -218,44 +270,75 @@ const runGhApiBuffer = async (repoRoot, args, input, options = {}) => { } stdout.push(chunk); }); - child.stderr.on('data', (chunk) => { - if (stderrBytes >= MAX_STDERR_BYTES) return; + child.stderr.on('data', (value) => { + if (settled || stderrBytes >= MAX_STDERR_BYTES) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); const retained = chunk.subarray(0, MAX_STDERR_BYTES - stderrBytes); stderr.push(Buffer.from(retained)); stderrBytes += retained.length; }); + child.stdin.on('error', (error) => { + if ( + !settled && + outputLimit == null && + /** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE' + ) { + fail(error, { canceled: signal?.aborted }); + child.kill(); + } + }); child.on('error', (error) => { + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + } + if (settled) return; if (outputLimit != null) { - reject(new ProviderOutputLimitError(outputLimit)); + fail(new ProviderOutputLimitError(outputLimit), { canceled: signal?.aborted }); return; } - reject( + fail( /** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' ? createGhNotFoundError() : error, + { canceled: error.name === 'AbortError' || signal?.aborted }, ); }); - child.on('close', (code) => { + child.on('close', (code, childSignal) => { if (forceKillTimeout) { clearTimeout(forceKillTimeout); } + if (settled) return; if (outputLimit != null) { - reject(new ProviderOutputLimitError(outputLimit)); + fail(new ProviderOutputLimitError(outputLimit), { + exitCode: code, + signal: childSignal, + }); + return; + } + if (signal?.aborted) { + fail(abortError(), { canceled: true, exitCode: code, signal: childSignal }); return; } if (code === 0) { + settled = true; + timing.finish({ exitCode: code }); resolve(Buffer.concat(stdout, outputBytes)); } else { const error = new Error( Buffer.concat(stderr).toString('utf8').trim() || `gh api exited with code ${code}.`, ); - reject(error); + fail(error, { exitCode: code, signal: childSignal }); } }); - if (input == null) { - child.stdin.end(); - } else { - child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + try { + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + } catch (error) { + fail(error, { canceled: signal?.aborted }); + child.kill(); } }); }; @@ -342,9 +425,9 @@ const withoutPageSize = (query) => query && Object.fromEntries(Object.entries(query).filter(([key]) => key !== 'per_page')); /** - * @param {{ repoRoot: string }} options + * @param {{ repoRoot: string, signal?: AbortSignal }} options */ -const createGhGitHubTransport = ({ repoRoot }) => { +const createGhGitHubTransport = ({ repoRoot, signal: defaultSignal }) => { const repositoryIdentity = realpathSync.native(repoRoot); /** @@ -354,18 +437,21 @@ const createGhGitHubTransport = ({ repoRoot }) => { */ const readApiBuffer = async (args, input, options) => { const maxBytes = normalizeMaxBytes(options.maxBytes); - const signal = options.signal ?? getCurrentCommandSignal(); const bytes = options.sharedKey - ? await readSharedGet(options.sharedKey, maxBytes, ({ getMaxBytes, onOutputLimit }) => - runGhApiBuffer(repoRoot, args, input, { - getMaxBytes, - onOutputLimit, - signal, - }), + ? await readSharedGet( + options.sharedKey, + maxBytes, + ({ getMaxBytes, onOutputLimit }) => + runGhApiBuffer(repoRoot, args, input, { + getMaxBytes, + onOutputLimit, + signal: options.signal, + }), + options.signal, ) : await runGhApiBuffer(repoRoot, args, input, { maxBytes, - signal, + signal: options.signal, }); return enforceOutputLimit(bytes, maxBytes); }; @@ -374,6 +460,8 @@ const createGhGitHubTransport = ({ repoRoot }) => { * @param {{maxBytes?: number, query: string, signal?: AbortSignal, variables: Readonly>}} request */ const graphql = async (request) => { + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); /** @type {Array} */ const args = ['graphql', '-f', `query=${request.query}`]; for (const [key, value] of Object.entries(request.variables)) { @@ -385,7 +473,7 @@ const createGhGitHubTransport = ({ repoRoot }) => { ( await readApiBuffer(args, undefined, { maxBytes: request.maxBytes, - signal: request.signal, + signal, }) ).toString('utf8'), ); @@ -404,7 +492,8 @@ const createGhGitHubTransport = ({ repoRoot }) => { * }} request */ const requestText = async (request) => { - const signal = request.signal ?? getCurrentCommandSignal(); + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); /** @type {Array} */ const args = []; if (request.paginate) { @@ -421,7 +510,7 @@ const createGhGitHubTransport = ({ repoRoot }) => { args.push('--input', '-'); } const sharedKey = - request.body == null && (!request.method || request.method === 'GET') && !signal + request.body == null && (!request.method || request.method === 'GET') ? `${repositoryIdentity}\0text\0${request.paginate ? 'paginate' : 'single'}\0${request.accept || ''}\0${appendQuery(request.path, request.paginate ? withoutPageSize(request.query) : request.query)}` : undefined; return ( @@ -462,7 +551,8 @@ const createGhGitHubTransport = ({ repoRoot }) => { return /** @type {T} */ (JSON.parse(text)); }, async requestBuffer(request) { - const signal = request.signal ?? getCurrentCommandSignal(); + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); /** @type {Array} */ const args = []; if (request.accept) { @@ -471,9 +561,7 @@ const createGhGitHubTransport = ({ repoRoot }) => { args.push(appendQuery(request.path, request.query)); return readApiBuffer(args, undefined, { maxBytes: request.maxBytes, - sharedKey: !signal - ? `${repositoryIdentity}\0buffer\0single\0${request.accept || ''}\0${appendQuery(request.path, request.query)}` - : undefined, + sharedKey: `${repositoryIdentity}\0buffer\0single\0${request.accept || ''}\0${appendQuery(request.path, request.query)}`, signal, }); }, diff --git a/electron/git-state/glab-gitlab-transport.cjs b/electron/git-state/glab-gitlab-transport.cjs index a1e5bddb..914e3067 100644 --- a/electron/git-state/glab-gitlab-transport.cjs +++ b/electron/git-state/glab-gitlab-transport.cjs @@ -6,6 +6,7 @@ */ const { spawn } = require('node:child_process'); +const { getCommandActionSignal, startCommandTiming } = require('../command-log.cjs'); const { homedir } = require('node:os'); const { join } = require('node:path'); const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); @@ -27,6 +28,7 @@ class ProviderOutputLimitError extends Error { /** @typedef {{errorName?: string, maxBytes?: number, outputLimitExceeded: boolean, promise: Promise, status: 'pending' | 'fulfilled' | 'rejected'}} SharedGetRequest */ const sharedGetRequests = new Map(); +const MAX_STDERR_BYTES = 1024 * 1024; /** @param {number | undefined} maxBytes */ const normalizeMaxBytes = (maxBytes) => { @@ -52,19 +54,26 @@ const enforceOutputLimit = (bytes, maxBytes) => { }; /** - * Share only uncancelable GETs, briefly retaining completed bytes so the - * initial loader and history enrichment can apply their own response bounds - * to one provider read. Concurrent consumers can raise the acquisition bound - * until output crosses it; a later consumer with a larger bound starts a new - * read only when an earlier bounded read has already discarded bytes. + * Share GETs within one cancellation boundary, briefly retaining completed + * bytes. The initial loader and history enrichment can apply their own + * response bounds to one provider read. Concurrent consumers can raise the + * acquisition bound until output crosses it; a later consumer with a larger + * bound starts a new read only when an earlier bounded read discarded bytes. * @param {string} key * @param {number | undefined} maxBytes * @param {(options: {getMaxBytes: () => number | undefined, onOutputLimit: () => void}) => Promise} read + * @param {AbortSignal | undefined} signal * @returns {Promise} */ -const readSharedGet = (key, maxBytes, read) => { +const readSharedGet = (key, maxBytes, read, signal) => { + let requestsBySignal = sharedGetRequests.get(key); + if (!requestsBySignal) { + requestsBySignal = new Map(); + sharedGetRequests.set(key, requestsBySignal); + } + const signalKey = signal || null; /** @type {SharedGetRequest | undefined} */ - const existing = sharedGetRequests.get(key); + const existing = requestsBySignal.get(signalKey); if (existing) { if (existing.status === 'fulfilled') { return existing.promise; @@ -101,11 +110,14 @@ const readSharedGet = (key, maxBytes, read) => { }), ); entry.promise = request; - sharedGetRequests.set(key, entry); + requestsBySignal.set(signalKey, entry); const expire = () => { const timeout = setTimeout(() => { - if (sharedGetRequests.get(key) === entry) { - sharedGetRequests.delete(key); + if (requestsBySignal.get(signalKey) === entry) { + requestsBySignal.delete(signalKey); + if (requestsBySignal.size === 0 && sharedGetRequests.get(key) === requestsBySignal) { + sharedGetRequests.delete(key); + } } }, 1000); timeout.unref?.(); @@ -202,6 +214,7 @@ const runGlabApiBuffer = async (repoRoot, hostname, args, input, options = {}) = const environment = await getCommandEnvironment(); return new Promise((resolve, reject) => { const fixedMaxBytes = normalizeMaxBytes(options.maxBytes); + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); let command; try { command = getGlabCommand(); @@ -210,17 +223,56 @@ const runGlabApiBuffer = async (repoRoot, hostname, args, input, options = {}) = return; } - const child = spawn(command, args, { - cwd: repoRoot, - env: environment, - signal: options.signal ?? getCurrentCommandSignal(), - stdio: ['pipe', 'pipe', 'pipe'], - }); + const timing = startCommandTiming({ args, command, cwd: repoRoot }); + let child; + try { + signal?.throwIfAborted(); + child = spawn(command, args, { + cwd: repoRoot, + env: environment, + signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + timing.finish({ canceled: signal?.aborted, error }); + reject(error); + return; + } const stdout = []; const stderr = []; + let stderrBytes = 0; let outputBytes = 0; let outputLimit; - child.stdout.on('data', (chunk) => { + let settled = false; + + /** + * @param {unknown} reason + * @param {{canceled?: boolean, exitCode?: number | null, signal?: string | null}} [result] + */ + const fail = (reason, result = {}) => { + if (settled) return; + settled = true; + const error = reason instanceof Error ? reason : new Error(String(reason)); + timing.finish({ + canceled: result.canceled, + error, + exitCode: result.exitCode, + signal: result.signal, + }); + reject(error); + }; + + const abortError = () => { + const reason = signal?.reason; + if (reason instanceof Error) return reason; + const error = new Error('glab api request was aborted.'); + error.name = 'AbortError'; + return error; + }; + + child.stdout.on('data', (value) => { + if (settled) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); outputBytes += chunk.length; if (outputLimit != null) { return; @@ -234,32 +286,60 @@ const runGlabApiBuffer = async (repoRoot, hostname, args, input, options = {}) = } stdout.push(chunk); }); - child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.stderr.on('data', (value) => { + if (settled || stderrBytes >= MAX_STDERR_BYTES) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + const retained = chunk.subarray(0, MAX_STDERR_BYTES - stderrBytes); + stderr.push(Buffer.from(retained)); + stderrBytes += retained.length; + }); + child.stdin.on('error', (error) => { + if (!settled && /** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE') { + fail(error, { canceled: signal?.aborted }); + child.kill(); + } + }); child.on('error', (error) => { - reject( + fail( /** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' ? createGlabNotFoundError() : error, + { canceled: error.name === 'AbortError' || signal?.aborted }, ); }); - child.on('close', (code, signal) => { + child.on('close', (code, childSignal) => { + if (settled) return; + if (signal?.aborted) { + fail(abortError(), { canceled: true, exitCode: code, signal: childSignal }); + return; + } if (code === 0) { if (outputLimit != null) { - reject(new ProviderOutputLimitError(outputLimit)); + fail(new ProviderOutputLimitError(outputLimit), { + exitCode: code, + signal: childSignal, + }); } else { + settled = true; + timing.finish({ exitCode: code }); resolve(Buffer.concat(stdout, outputBytes)); } } else { const error = new Error( Buffer.concat(stderr).toString('utf8').trim() || `glab api exited with code ${code}.`, ); - reject(error); + fail(error, { exitCode: code, signal: childSignal }); } }); - if (input == null) { - child.stdin.end(); - } else { - child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + try { + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + } catch (error) { + fail(error, { canceled: signal?.aborted }); + child.kill(); } }); }; @@ -285,18 +365,21 @@ const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal } */ const readApiBuffer = async (args, input, options) => { const maxBytes = normalizeMaxBytes(options.maxBytes); - const signal = options.signal ?? getCurrentCommandSignal(); const bytes = options.sharedKey - ? await readSharedGet(options.sharedKey, maxBytes, ({ getMaxBytes, onOutputLimit }) => - runGlabApiBuffer(repoRoot, hostname, args, input, { - getMaxBytes, - onOutputLimit, - signal, - }), + ? await readSharedGet( + options.sharedKey, + maxBytes, + ({ getMaxBytes, onOutputLimit }) => + runGlabApiBuffer(repoRoot, hostname, args, input, { + getMaxBytes, + onOutputLimit, + signal: options.signal, + }), + options.signal, ) : await runGlabApiBuffer(repoRoot, hostname, args, input, { maxBytes, - signal, + signal: options.signal, }); return enforceOutputLimit(bytes, maxBytes); }; @@ -319,9 +402,10 @@ const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal } request.method, request.body, ); - const signal = request.signal || defaultSignal || getCurrentCommandSignal(); + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); const sharedKey = - request.body == null && (!request.method || request.method === 'GET') && !signal + request.body == null && (!request.method || request.method === 'GET') ? `${repoRoot}\0${hostname}\0text\0${args.join('\0')}` : undefined; return ( @@ -413,22 +497,24 @@ const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal } }, async requestBuffer(request) { const args = createGlabApiArgs(hostname, request.path, request.query, undefined, undefined); - const signal = request.signal || defaultSignal || getCurrentCommandSignal(); + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); return readApiBuffer(args, undefined, { maxBytes: request.maxBytes, - sharedKey: !signal ? `${repoRoot}\0${hostname}\0buffer\0${args.join('\0')}` : undefined, + sharedKey: `${repoRoot}\0${hostname}\0buffer\0${args.join('\0')}`, signal, }); }, async requestPages(request) { const args = createGlabApiArgs(hostname, request.path, request.query, undefined, undefined); args.splice(-1, 0, '--paginate'); - const signal = request.signal || defaultSignal || getCurrentCommandSignal(); + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); const pages = parseJsonPages( ( await readApiBuffer(args, undefined, { maxBytes: request.maxBytes, - sharedKey: !signal ? `${repoRoot}\0${hostname}\0${args.join('\0')}` : undefined, + sharedKey: `${repoRoot}\0${hostname}\0${args.join('\0')}`, signal, }) ).toString('utf8'), diff --git a/electron/git-state/working-tree.cjs b/electron/git-state/working-tree.cjs index 36733d49..4441ebe0 100644 --- a/electron/git-state/working-tree.cjs +++ b/electron/git-state/working-tree.cjs @@ -18,6 +18,7 @@ const { MAX_UNTRACKED_INITIAL_ITEMS, normalizeStatus, } = require('./common.cjs'); +const { getCommandActionSignal } = require('../command-log.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile @@ -400,41 +401,67 @@ const readWorkingTreeState = async (launchPath, options = {}) => { /** @param {string} repoRoot @param {ReadonlyArray} args */ const gitOrEmpty = async (repoRoot, args) => { + const signal = getCommandActionSignal(); try { - return await git(repoRoot, args); + return await git(repoRoot, args, { signal }); } catch { + signal?.throwIfAborted(); return ''; } }; +/** @type {Map>>} */ const gitIdentityReads = new Map(); /** @param {string} launchPath */ const readGitIdentity = (launchPath) => { - const existing = gitIdentityReads.get(launchPath); + const signal = getCommandActionSignal(); + let readsBySignal = gitIdentityReads.get(launchPath); + if (!readsBySignal) { + readsBySignal = new Map(); + gitIdentityReads.set(launchPath, readsBySignal); + } + const signalKey = signal || null; + const existing = readsBySignal.get(signalKey); if (existing) { return existing; } const read = Promise.all([ gitOrEmpty(launchPath, ['config', '--get', 'user.name']), gitOrEmpty(launchPath, ['config', '--get', 'user.email']), - ]) - .then(([configuredName, configuredEmail]) => { - const email = configuredEmail.trim(); - const name = configuredName.trim(); - return { - email, - gravatarUrl: email - ? `https://www.gravatar.com/avatar/${getGravatarHash(email)}?s=80&d=identicon` - : undefined, - name, - }; - }) - .finally(() => { + ]).then(([configuredName, configuredEmail]) => { + const email = configuredEmail.trim(); + const name = configuredName.trim(); + return { + email, + gravatarUrl: email + ? `https://www.gravatar.com/avatar/${getGravatarHash(email)}?s=80&d=identicon` + : undefined, + name, + }; + }); + let pending; + const clear = () => { + if (readsBySignal.get(signalKey) !== pending) { + return; + } + readsBySignal.delete(signalKey); + if (readsBySignal.size === 0 && gitIdentityReads.get(launchPath) === readsBySignal) { gitIdentityReads.delete(launchPath); - }); - gitIdentityReads.set(launchPath, read); - return read; + } + }; + pending = read.then( + (identity) => { + clear(); + return identity; + }, + (error) => { + clear(); + throw error; + }, + ); + readsBySignal.set(signalKey, pending); + return pending; }; module.exports = { diff --git a/electron/main.cjs b/electron/main.cjs index 8073b29c..8095a8e3 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,5 +1,7 @@ // @ts-check +const electronProcessStartedAt = new Date(Date.now() - process.uptime() * 1000).toISOString(); + const { existsSync, readFileSync, writeFileSync } = require('node:fs'); const { basename, dirname, join, relative, resolve } = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -15,6 +17,14 @@ const { screen, shell, } = require('electron'); +const { + configureCommandLog, + recordCommandMilestone, + runWithCommandAction, + startCommandAction, +} = require('./command-log.cjs'); + +app.setName('Codiff'); const squirrelStartup = require('electron-squirrel-startup'); const { listRepositoryHistory, @@ -165,10 +175,18 @@ const markdownDocumentWatchers = new Map(); const completedPlanWindows = new Set(); /** @type {Set} */ const openWindows = new Set(); +/** @type {Map>} */ +const initialLoadActions = new Map(); const pendingCommentsClipboardController = createPendingCommentsClipboardController({ clipboard }); /** @type {CodiffConfig} */ let config = createDefaultConfig(); +/** @template Value @param {number} webContentsId @param {() => Value} callback */ +const runInInitialLoadAction = (webContentsId, callback) => { + const action = initialLoadActions.get(webContentsId); + return action ? action.run(callback) : callback(); +}; + /** * @type {Map>} */ @@ -916,11 +934,13 @@ ipcMain.on( * @param {string} repositoryPath * @param {CodiffLaunchOptions} [launchOptions] * @param {WindowIdentity | null} [identity] + * @param {ReturnType} initialLoadAction */ const createWindow = ( repositoryPath, launchOptions = { repositoryPathProvided: true, walkthrough: false }, - identity = getWindowIdentity(repositoryPath, launchOptions), + identity, + initialLoadAction, ) => { const savedState = readWindowState(); const validatedState = savedState @@ -969,6 +989,7 @@ const createWindow = ( attachExternalLinkHandling(window.webContents, (url) => shell.openExternal(url)); const webContentsId = window.webContents.id; + initialLoadActions.set(webContentsId, initialLoadAction); openWindows.add(window); if (identity) { windowIdentities.set(webContentsId, identity); @@ -977,13 +998,31 @@ const createWindow = ( windowLaunchOptions.set(webContentsId, launchOptions); const initialRepositoryStatePromise = launchOptions.planFile ? null - : readInitialRepositoryStateWithConfig(repositoryPath, launchOptions, identity?.repositoryRoot); - const initialRepositoryState = initialRepositoryStatePromise?.then((state) => { - if (!window.isDestroyed()) { - storeResolvedRepositoryState(webContentsId, state); - } - return state; - }); + : initialLoadAction.run(() => + readInitialRepositoryStateWithConfig( + repositoryPath, + launchOptions, + identity?.repositoryRoot, + ), + ); + const initialRepositoryState = initialRepositoryStatePromise?.then( + (state) => { + recordCommandMilestone('repository-review-state-available', { + actionId: initialLoadAction.id, + }); + if (!window.isDestroyed()) { + storeResolvedRepositoryState(webContentsId, state); + } + return state; + }, + (error) => { + initialLoadAction.finish({ error }); + if (initialLoadActions.get(webContentsId) === initialLoadAction) { + initialLoadActions.delete(webContentsId); + } + throw error; + }, + ); initialRepositoryState?.catch(() => {}); if (initialRepositoryState) { windowInitialRepositoryStates.set(webContentsId, initialRepositoryState); @@ -1017,7 +1056,13 @@ const createWindow = ( window.on('minimize', () => repositoryWatcherCoordinator.visibilityChanged(webContentsId)); window.on('restore', () => repositoryWatcherCoordinator.focus(webContentsId)); window.on('show', () => repositoryWatcherCoordinator.focus(webContentsId)); - window.once('ready-to-show', () => window.show()); + window.once('ready-to-show', () => { + window.show(); + if (launchOptions.planFile) { + initialLoadAction.finish(); + initialLoadActions.delete(webContentsId); + } + }); let allowClose = false; let copyingPendingCommentsBeforeClose = false; window.on('close', (event) => { @@ -1066,6 +1111,8 @@ const createWindow = ( }); }); window.on('closed', () => { + initialLoadActions.get(webContentsId)?.cancel(); + initialLoadActions.delete(webContentsId); openWindows.delete(window); definitionSearchCoordinator.cancel(webContentsId); repositoryWatcherCoordinator.detach(webContentsId); @@ -1163,7 +1210,9 @@ const focusWindow = (window) => { /** @param {number} webContentsId */ const getWalkthroughShareContext = async (webContentsId) => { const repositoryPath = windowRepositories.get(webContentsId) || getLaunchPath(); - const uploader = await readGitIdentity(repositoryPath); + const uploader = await runInInitialLoadAction(webContentsId, () => + readGitIdentity(repositoryPath), + ); return { target: resolveWalkthroughShareTarget({ @@ -1239,7 +1288,15 @@ const focusOrCreateWindow = ( repositoryPath, launchOptions = { repositoryPathProvided: true, walkthrough: false }, ) => { - const identity = getWindowIdentity(repositoryPath, launchOptions); + const initialLoadAction = startCommandAction({ + command: 'initial-load', + cwd: repositoryPath, + details: { + explicitSource: Boolean(launchOptions.source), + sourceType: launchOptions.source?.type, + }, + }); + const identity = initialLoadAction.run(() => getWindowIdentity(repositoryPath, launchOptions)); const matchingWebContentsId = findMatchingWindowIdentity(identity, windowIdentities); const matchingWindow = matchingWebContentsId == null @@ -1250,6 +1307,8 @@ const focusOrCreateWindow = ( if (matchingWindow) { if (launchOptions.planFile || launchOptions.walkthrough || launchOptions.walkthroughFile) { + initialLoadActions.get(matchingWebContentsId)?.cancel(); + initialLoadActions.set(matchingWebContentsId, initialLoadAction); windowRepositories.set(matchingWebContentsId, identity?.repositoryRoot || repositoryPath); windowLaunchOptions.set(matchingWebContentsId, launchOptions); if (launchOptions.planFile) { @@ -1257,14 +1316,26 @@ const focusOrCreateWindow = ( readyPlanWindows.delete(matchingWebContentsId); windowInitialRepositoryStates.delete(matchingWebContentsId); } else { - windowInitialRepositoryStates.set( - matchingWebContentsId, + const initialState = initialLoadAction.run(() => readInitialRepositoryStateWithConfig( repositoryPath, launchOptions, identity?.repositoryRoot, ), ); + windowInitialRepositoryStates.set(matchingWebContentsId, initialState); + initialState.then( + () => + recordCommandMilestone('repository-review-state-available', { + actionId: initialLoadAction.id, + }), + (error) => { + initialLoadAction.finish({ error }); + if (initialLoadActions.get(matchingWebContentsId) === initialLoadAction) { + initialLoadActions.delete(matchingWebContentsId); + } + }, + ); } if (identity) { windowIdentities.set(matchingWebContentsId, identity); @@ -1275,12 +1346,18 @@ const focusOrCreateWindow = ( ); abortDiffContentRequests(matchingWebContentsId); matchingWindow.reload(); + if (launchOptions.planFile) { + initialLoadAction.finish(); + initialLoadActions.delete(matchingWebContentsId); + } + } else { + initialLoadAction.finish(); } focusWindow(matchingWindow); return matchingWindow; } - return createWindow(repositoryPath, launchOptions, identity); + return createWindow(repositoryPath, launchOptions, identity, initialLoadAction); }; const INITIAL_UPDATE_CHECK_DELAY_MS = 10 * 1000; @@ -1367,8 +1444,11 @@ const lock = if (squirrelStartup || !lock) { app.quit(); } else { - app.setName('Codiff'); - + configureCommandLog(app.getPath('logs'), { processStartedAt: electronProcessStartedAt }); + recordCommandMilestone('electron-process-start', { + monotonicMs: 0, + timestamp: electronProcessStartedAt, + }); app.on('second-instance', (event, commandLine, workingDirectory, additionalData) => { const data = /** @type {SingleInstanceAdditionalData} */ (additionalData || {}); const launchOptions = @@ -1953,15 +2033,24 @@ ipcMain.handle('codiff:askReviewAssistant', async (event, request) => { ipcMain.handle('codiff:createWalkthroughCommit', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - const result = await createWalkthroughCommit(repositoryPath, request, (chunk) => { - if (!event.sender.isDestroyed()) { - event.sender.send('codiff:walkthroughCommitOutput', chunk); - } - }); - if (result.status === 'committed') { - await resetRepositoryWatcher(event.sender.id, repositoryPath); - } - return result; + return runWithCommandAction( + { + command: 'walkthrough-commit', + cwd: repositoryPath, + details: { fileCount: Array.isArray(request?.paths) ? request.paths.length : 0 }, + }, + async () => { + const result = await createWalkthroughCommit(repositoryPath, request, (chunk) => { + if (!event.sender.isDestroyed()) { + event.sender.send('codiff:walkthroughCommitOutput', chunk); + } + }); + if (result.status === 'committed') { + await resetRepositoryWatcher(event.sender.id, repositoryPath); + } + return result; + }, + ); }); ipcMain.handle('codiff:updateWalkthroughCommitMessage', async (event, request) => { @@ -2002,13 +2091,32 @@ ipcMain.on('codiff:cancelDiffContentRequest', (event, requestId) => { if (typeof requestId !== 'string') { return; } - const requests = diffContentRequests.get(event.sender.id); - requests?.get(requestId)?.abort(new DOMException('Diff content request canceled.', 'AbortError')); + diffContentRequests + .get(event.sender.id) + ?.get(requestId) + ?.abort(new DOMException('Diff content request canceled.', 'AbortError')); }); ipcMain.handle('codiff:getRepositoryHistory', async (event, limit, source) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return listRepositoryHistory(repositoryPath, limit, source); + return runInInitialLoadAction(event.sender.id, () => + listRepositoryHistory(repositoryPath, limit, source), + ); +}); + +ipcMain.on('codiff:initialLoadMilestone', (event, name) => { + if (name !== 'first-usable-review-rendered' && name !== 'deferred-review-data-complete') { + return; + } + const action = initialLoadActions.get(event.sender.id); + if (!action) { + return; + } + recordCommandMilestone(name, { actionId: action.id }); + if (name === 'deferred-review-data-complete') { + action.finish(); + initialLoadActions.delete(event.sender.id); + } }); ipcMain.handle('codiff:getReviewComments', async (event, source, requestId) => { @@ -2017,13 +2125,13 @@ ipcMain.handle('codiff:getReviewComments', async (event, source, requestId) => { } const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); return runDiffContentRequest(event, { requestId }, () => - readReviewComments(repositoryPath, source), + runInInitialLoadAction(event.sender.id, () => readReviewComments(repositoryPath, source)), ); }); ipcMain.handle('codiff:getGitIdentity', async (event) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return readGitIdentity(repositoryPath); + return runInInitialLoadAction(event.sender.id, () => readGitIdentity(repositoryPath)); }); ipcMain.handle('codiff:getPreferences', () => configToPreferences(config)); diff --git a/electron/main/command-line.cjs b/electron/main/command-line.cjs index b2406b17..daae6c6a 100644 --- a/electron/main/command-line.cjs +++ b/electron/main/command-line.cjs @@ -1,9 +1,9 @@ // @ts-check -const { execFileSync } = require('node:child_process'); const { existsSync } = require('node:fs'); const { resolve } = require('node:path'); const { parseArgs } = require('node:util'); +const { gitSync } = require('../git-state/common.cjs'); const { readWalkthroughContext } = require('../walkthrough-context.cjs'); const { parseReviewUrl, resolveReviewUrl } = require('../review-source.cjs'); @@ -43,9 +43,7 @@ const parseRangeArgument = (arg) => { /** @param {string} repositoryPath @param {ReadonlyArray} args */ const gitSucceeds = (repositoryPath, args) => { try { - execFileSync('git', ['-C', repositoryPath, ...args], { - stdio: ['ignore', 'ignore', 'ignore'], - }); + gitSync(repositoryPath, args); return true; } catch { return false; diff --git a/electron/review-source.cjs b/electron/review-source.cjs index 0e51d270..6a123261 100644 --- a/electron/review-source.cjs +++ b/electron/review-source.cjs @@ -1,6 +1,6 @@ // @ts-check -const { execFileSync } = require('node:child_process'); +const { gitSync } = require('./git-state/common.cjs'); /** @typedef {'github' | 'gitlab'} ReviewProvider */ @@ -123,10 +123,8 @@ const parseRemoteUrl = (value) => { /** @param {string} repositoryPath */ const readReviewRemotes = (repositoryPath) => { - const root = execFileSync('git', ['-C', repositoryPath, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - }).trim(); - const raw = execFileSync('git', ['-C', root, 'remote', '-v'], { encoding: 'utf8' }); + const root = gitSync(repositoryPath, ['rev-parse', '--show-toplevel']).trim(); + const raw = gitSync(root, ['remote', '-v']); const remotes = []; for (const line of raw.split('\n')) { const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); diff --git a/electron/walkthrough-commit.cjs b/electron/walkthrough-commit.cjs index b9cb26f5..16c5b8fd 100644 --- a/electron/walkthrough-commit.cjs +++ b/electron/walkthrough-commit.cjs @@ -9,6 +9,7 @@ const { accessSync, chmodSync, constants, mkdtempSync, rmSync, writeFileSync } = const { tmpdir } = require('node:os'); const { dirname, join } = require('node:path'); +const { startCommandTiming } = require('./command-log.cjs'); const { git, validateRepositoryPath } = require('./git-state/common.cjs'); // `node-pty` is a native addon and only a walkthrough commit needs it, so it is @@ -79,10 +80,17 @@ const gitStreaming = (repoPath, args, onOutput) => new Promise((resolve, reject) => { const pty = loadPty(); ensureSpawnHelperIsExecutable(); + const commandArgs = ['-C', repoPath, ...args]; + const timing = startCommandTiming({ + args: commandArgs, + command: 'git', + cwd: repoPath, + details: { interactive: true }, + }); /** @type {import('node-pty').IPty} */ let child; try { - child = pty.spawn('git', ['-C', repoPath, ...args], { + child = pty.spawn('git', commandArgs, { cols: TERMINAL_COLS, cwd: repoPath, env: process.env, @@ -90,6 +98,7 @@ const gitStreaming = (repoPath, args, onOutput) => rows: TERMINAL_ROWS, }); } catch (error) { + timing.finish({ error }); reject(error instanceof Error ? error : new Error(String(error))); return; } @@ -100,10 +109,13 @@ const gitStreaming = (repoPath, args, onOutput) => }); child.onExit(({ exitCode }) => { if (exitCode === 0) { + timing.finish({ exitCode }); resolve(); } else { const output = normalizeTerminalOutput(combined).trim(); - reject(new Error(output || `git exited with status ${exitCode}`)); + const error = new Error(output || `git exited with status ${exitCode}`); + timing.finish({ error, exitCode }); + reject(error); } }); }); diff --git a/electron/window-identity.cjs b/electron/window-identity.cjs index 8f03043e..35086fc9 100644 --- a/electron/window-identity.cjs +++ b/electron/window-identity.cjs @@ -1,6 +1,5 @@ // @ts-check -const { execFileSync } = require('node:child_process'); const { realpathSync } = require('node:fs'); const { dirname, resolve } = require('node:path'); const { @@ -8,6 +7,7 @@ const { decodeResolvedReviewSource, formatReviewSourceIdentity, } = require('../core/lib/review-source-codec.cjs'); +const { gitSync } = require('./git-state/common.cjs'); /** * @typedef {import('../core/types.ts').ReviewSource} ReviewSource @@ -29,11 +29,7 @@ const resolveRepositoryRoot = (repositoryPath) => { const resolvedPath = resolve(repositoryPath); try { - return getRealPath( - execFileSync('git', ['-C', resolvedPath, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - }).trim(), - ); + return getRealPath(gitSync(resolvedPath, ['rev-parse', '--show-toplevel']).trim()); } catch { return getRealPath(resolvedPath); } @@ -42,9 +38,7 @@ const resolveRepositoryRoot = (repositoryPath) => { /** @param {string} repositoryRoot @param {string} ref */ const resolveCommitRef = (repositoryRoot, ref) => { try { - return execFileSync('git', ['-C', repositoryRoot, 'rev-parse', '--verify', `${ref}^{commit}`], { - encoding: 'utf8', - }) + return gitSync(repositoryRoot, ['rev-parse', '--verify', `${ref}^{commit}`]) .trim() .toLowerCase(); } catch { @@ -56,11 +50,7 @@ const resolveCommitRef = (repositoryRoot, ref) => { const hasWorkingTreeChanges = (repositoryRoot) => { try { return Boolean( - execFileSync( - 'git', - ['-C', repositoryRoot, 'status', '--porcelain=v1', '-z', '--untracked-files=normal'], - { encoding: 'utf8' }, - ), + gitSync(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']), ); } catch { return false; @@ -70,11 +60,7 @@ const hasWorkingTreeChanges = (repositoryRoot) => { /** @param {string} repositoryRoot @param {string} baseRef @param {string} headRef */ const resolveMergeBase = (repositoryRoot, baseRef, headRef) => { try { - return execFileSync('git', ['-C', repositoryRoot, 'merge-base', baseRef, headRef], { - encoding: 'utf8', - }) - .trim() - .toLowerCase(); + return gitSync(repositoryRoot, ['merge-base', baseRef, headRef]).trim().toLowerCase(); } catch { return null; } From 099bb37a5ce9351c4b60428583200254d5f3d759 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 4 Aug 2026 14:46:51 -0500 Subject: [PATCH 17/17] Keep .jj, evals, and tests out of packaged apps Ignore `.jj/**` in Vite and exclude repository metadata, evals, test scenarios, Electron tests, service/web sources, scripts, and selected docs from Forge packages. Retain `electron/main.cjs`, `dist/index.html`, and generated provider runtimes required by the packaged application. --- core/App.tsx | 10 ++++------ core/app/components/ReviewCodeView.tsx | 2 +- electron/__tests__/forge-package.test.ts | 23 ++++++++++++++++++++--- forge.config.cjs | 13 +++++++++++++ vite.config.ts | 6 ++++++ 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/core/App.tsx b/core/App.tsx index 878b67a0..e022baaf 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -156,12 +156,10 @@ export default function App() { state.source, bootstrap.forceInitialWalkthrough ? { force: true } : undefined, ) - .catch( - (error: unknown): NarrativeWalkthroughResult => ({ - reason: error instanceof Error ? error.message : String(error), - status: 'unavailable', - }), - ) + .catch((error: unknown): NarrativeWalkthroughResult => ({ + reason: error instanceof Error ? error.message : String(error), + status: 'unavailable', + })) .then((result) => { if (canceled) { return; diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index f3f6591a..45e816f7 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -117,11 +117,11 @@ import { } from '../../lib/review-context-expansion.ts'; import { getReviewIdentity, isReviewIdentityViewed } from '../../lib/review-identity.ts'; import { applySearchHighlights } from '../../lib/search-highlights.ts'; -import { getSourceKey } from '../../lib/source.ts'; import { buildSourceDescriptionModel, type SourceDescriptionAuthor, } from '../../lib/source-description.ts'; +import { getSourceKey } from '../../lib/source.ts'; import type { ChangedFile, CodiffPreferences, diff --git a/electron/__tests__/forge-package.test.ts b/electron/__tests__/forge-package.test.ts index bcd5024f..2d068dbb 100644 --- a/electron/__tests__/forge-package.test.ts +++ b/electron/__tests__/forge-package.test.ts @@ -17,9 +17,26 @@ const forge = require('../../forge.config.cjs') as { const isIgnored = (path: string) => forge.packagerConfig.ignore.some((pattern) => pattern.test(path)); -test('Forge excludes provider sources and retains application entry points', () => { - expect(isIgnored('/github/src/index.ts')).toBe(true); - expect(isIgnored('/gitlab/src/index.ts')).toBe(true); +test('Forge excludes development, eval, scenario, and test inputs', () => { + for (const path of [ + '/.jj/repo/store', + '/AGENTS.md', + '/CONTRIBUTING.md', + '/core/src.ts', + '/electron-squirrel-startup.d.ts', + '/electron/__tests__/command-log.test.ts', + '/evals/fixtures/test-scenario-provider-mocks/current/github.json', + '/github/src/index.ts', + '/gitlab/src/index.ts', + '/scripts/test-scenarios.mjs', + '/service/api.ts', + '/test/sharing.integration.ts', + '/test-scenarios/shared/patches/000-base.diff', + '/vitest.cloudflare.config.ts', + '/web/src/index.tsx', + ]) { + expect(isIgnored(path), path).toBe(true); + } expect(isIgnored('/electron/main.cjs')).toBe(false); expect(isIgnored('/dist/index.html')).toBe(false); }); diff --git a/forge.config.cjs b/forge.config.cjs index 06b99e4c..1b911d82 100644 --- a/forge.config.cjs +++ b/forge.config.cjs @@ -128,12 +128,19 @@ module.exports = { /^\/\.enum_manifest\.json$/, /^\/\.env(?:$|[.])/, /^\/\.git(?:$|\/)/, + /^\/\.jj(?:$|\/)/, /^\/\.gitignore$/, /^\/\.github(?:$|\/)/, /^\/\.vite-hooks(?:$|\/)/, /^\/\.vscode(?:$|\/)/, + /^\/AGENTS\.md$/, + /^\/CONTRIBUTING\.md$/, /^\/README\.md$/, /^\/coverage(?:$|\/)/, + /^\/electron\/__tests__(?:$|\/)/, + /^\/electron\/.*\.test\.[cm]?[jt]sx?$/, + /^\/electron-squirrel-startup\.d\.ts$/, + /^\/evals(?:$|\/)/, /^\/docs(?:$|\/)/, /^\/examples(?:$|\/)/, /^\/forge\.config\.cjs$/, @@ -143,9 +150,15 @@ module.exports = { /^\/out(?:$|\/)/, /^\/pnpm-workspace\.yaml$/, /^\/public(?:$|\/)/, + /^\/scripts(?:$|\/)/, + /^\/service(?:$|\/)/, + /^\/test(?:$|\/)/, + /^\/test-scenarios(?:$|\/)/, /^\/core(?:$|\/)/, /^\/tsconfig/, /^\/vite\.config\./, + /^\/vitest\.cloudflare\.config\.ts$/, + /^\/web(?:$|\/)/, ], name: 'Codiff', ...(osxNotarize ? { osxNotarize } : {}), diff --git a/vite.config.ts b/vite.config.ts index cdc68bca..835c4e67 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -105,6 +105,12 @@ export default defineConfig({ }, }, }, + server: { + watch: { + // Jujutsu operation files change with local history, not product source. + ignored: ['**/.jj/**'], + }, + }, staged: { '*': 'vp check --fix', },