diff --git a/packages/board-protocol/package.json b/packages/board-protocol/package.json index 00d5e4915d..95f14bdb0e 100644 --- a/packages/board-protocol/package.json +++ b/packages/board-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@dev.fast/board-protocol", - "version": "0.1.3", + "version": "0.1.4", "description": "Wire contract between a hosted whiteboard board server and its clients.", "license": "MIT", "repository": { diff --git a/packages/review/app/src/share/peeks.real-diffs.browser.test.ts b/packages/review/app/src/share/peeks.real-diffs.browser.test.ts index f47e083d8b..063dfe7c9c 100644 --- a/packages/review/app/src/share/peeks.real-diffs.browser.test.ts +++ b/packages/review/app/src/share/peeks.real-diffs.browser.test.ts @@ -6,7 +6,7 @@ import type { ShareAlignment, ShareSource, } from "@dev.fast/review-share-protocol"; -import { textualRows } from "@review/review-api/lens-alignment"; +import { patchRows } from "@dev.fast/whiteboard-document"; import { type DiffHunk, parseUnifiedPatch } from "@review/unified-diff"; import { afterEach, describe, expect, it } from "vitest"; @@ -60,8 +60,7 @@ const lineCount = (text: string | null) => function share(fixture: Fixture) { const pins = { repositoryId: "repo", base: fixture.base, head: fixture.head }; - const rows = textualRows( - fixture.file, + const rows = patchRows( fixture.patch, lineCount(fixture.baseText), lineCount(fixture.headText), diff --git a/packages/review/src/lens-selection.test.ts b/packages/review/src/lens-selection.test.ts index 0f4d05b584..4ef3dbc464 100644 --- a/packages/review/src/lens-selection.test.ts +++ b/packages/review/src/lens-selection.test.ts @@ -3,6 +3,7 @@ import { anchorSelection, formatAnchor, parseAnchor, + patchRows, resolveDiffSelection, selectSource, selectionKey, @@ -11,7 +12,6 @@ import { } from "@dev.fast/whiteboard-document"; import { expect, it } from "vitest"; -import { textualRows } from "./review-api/lens-alignment.js"; import { type CoverageFile, coverageProgress, @@ -122,12 +122,7 @@ it("rejects stale or reversed endpoints instead of inventing correspondence", () }); it("resolves selections over existing textual hunks, including unchanged gaps", () => { - const alignment = textualRows( - "a", - "@@ -2,2 +2,1 @@\n-deleted\n context", - 4, - 3, - ); + const alignment = patchRows("@@ -2,2 +2,1 @@\n-deleted\n context", 4, 3); expect( resolveDiffSelection( diff --git a/packages/review/src/review-api/comparison-coverage.ts b/packages/review/src/review-api/comparison-coverage.ts index 5900974778..6f9e1d2ff6 100644 --- a/packages/review/src/review-api/comparison-coverage.ts +++ b/packages/review/src/review-api/comparison-coverage.ts @@ -7,10 +7,11 @@ import { applyStructuralUpdate, structuralRows, } from "@dev.fast/review-protocol"; -import type { - AlignmentRow, - FileLineRange, - Pins, +import { + type AlignmentRow, + type FileLineRange, + type Pins, + patchRows, } from "@dev.fast/whiteboard-document"; import { parseUnifiedPatch } from "@review/unified-diff.js"; import { @@ -23,7 +24,6 @@ import { unionIntervals, } from "@review/viewed-coverage.js"; -import { textualRows } from "./lens-alignment.js"; import type { LocalReviewData } from "./local-data.js"; const hash = (parts: (string | null)[]) => @@ -91,8 +91,7 @@ export async function comparisonCoverage( alignments.set( file.path, - textualRows( - file.path, + patchRows( patch, base === null ? 0 : base.split("\n").length, head === null ? 0 : head.split("\n").length, diff --git a/packages/review/src/review-api/lens-alignment.ts b/packages/review/src/review-api/lens-alignment.ts deleted file mode 100644 index 4b910503d2..0000000000 --- a/packages/review/src/review-api/lens-alignment.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { AlignmentRow } from "@dev.fast/whiteboard-document"; -import { parseUnifiedPatch } from "@review/unified-diff.js"; - -/** Project the already-computed patch's runs, including unchanged gaps. */ -export function textualRows( - path: string, - patch: string, - baseCount: number, - headCount: number, -): AlignmentRow[] { - const rows: AlignmentRow[] = []; - - let base = 0, - head = 0; - - const append = (baseEnd: number, headEnd: number) => { - while (base < baseEnd || head < headEnd) - rows.push([ - base < baseEnd ? base++ : null, - head < headEnd ? head++ : null, - ]); - }; - - for (const hunk of parseUnifiedPatch(path, patch)) { - append( - hunk.oldLines ? hunk.oldStart - 1 : hunk.oldStart, - hunk.newLines ? hunk.newStart - 1 : hunk.newStart, - ); - - let removed = 0, - added = 0; - - const flush = () => { - append(base + removed, head + added); - removed = added = 0; - }; - - for (const line of hunk.lines) { - if (line.kind === "context") { - flush(); - append(base + 1, head + 1); - } else if (line.kind === "add") added++; - else removed++; - } - - flush(); - } - - append(baseCount, headCount); - - return rows; -} diff --git a/packages/review/src/review-api/review-progress.ts b/packages/review/src/review-api/review-progress.ts index 47a0fcb215..615e01fb2f 100644 --- a/packages/review/src/review-api/review-progress.ts +++ b/packages/review/src/review-api/review-progress.ts @@ -13,6 +13,7 @@ import { comparisonKey, fileLineRangeSchema, lensSelections, + patchRows, resolveDiffSelection, selectionKey, selectionReferences, @@ -26,7 +27,6 @@ import { resolveFileLens, uncategorizedSources, } from "./file-lenses.js"; -import { textualRows } from "./lens-alignment.js"; import type { LocalReviewData } from "./local-data.js"; import type { ReviewStore, Snapshot } from "./store.js"; @@ -208,8 +208,7 @@ export async function reviewProgress( "no_alignment", "Changed context file has no alignment.", ); - rows = textualRows( - source.file, + rows = patchRows( "", base?.split("\n").length ?? 0, head?.split("\n").length ?? 0, diff --git a/packages/whiteboard-document/package.json b/packages/whiteboard-document/package.json index 92815052fe..d851695ec5 100644 --- a/packages/whiteboard-document/package.json +++ b/packages/whiteboard-document/package.json @@ -1,6 +1,6 @@ { "name": "@dev.fast/whiteboard-document", - "version": "0.2.1", + "version": "0.2.2", "description": "The whiteboard document: block schemas, the edit reducer and its summary, for any runtime.", "license": "MIT", "repository": { diff --git a/packages/whiteboard-document/src/index.ts b/packages/whiteboard-document/src/index.ts index 693096603c..3aa92e0949 100644 --- a/packages/whiteboard-document/src/index.ts +++ b/packages/whiteboard-document/src/index.ts @@ -118,6 +118,7 @@ export { comparisonKey, formatAnchor, parseAnchor, + patchRows, rangeAnchor, resolveDiffSelection, selectSource, diff --git a/packages/whiteboard-document/src/lens-selection.test.ts b/packages/whiteboard-document/src/lens-selection.test.ts new file mode 100644 index 0000000000..85788cb084 --- /dev/null +++ b/packages/whiteboard-document/src/lens-selection.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "vitest"; + +import { patchRows } from "./lens-selection.js"; + +it("pairs a patch's lines, unchanged gaps and the tail included", () => { + expect( + patchRows( + "@@ -2,2 +2,1 @@\n-deleted\n context\n\\ No newline at end of file", + 4, + 3, + ), + ).toEqual([ + [0, 0], + [1, null], + [2, 1], + [3, 2], + ]); +}); + +it("pairs an added file's lines with nothing, skipping the git headers", () => { + expect( + patchRows("--- /dev/null\n+++ b/a\n@@ -0,0 +1,2 @@\n+a\n+b", 0, 2), + ).toEqual([ + [null, 0], + [null, 1], + ]); +}); + +it("pairs each line with itself without a patch", () => { + expect(patchRows("", 2, 2)).toEqual([ + [0, 0], + [1, 1], + ]); +}); diff --git a/packages/whiteboard-document/src/lens-selection.ts b/packages/whiteboard-document/src/lens-selection.ts index bf8ef7bc54..4ae61ef5f9 100644 --- a/packages/whiteboard-document/src/lens-selection.ts +++ b/packages/whiteboard-document/src/lens-selection.ts @@ -140,6 +140,63 @@ export type LensSource = DiffSelection; export type AlignmentRow = readonly [number | null, number | null]; +const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; + +/** The 0-based line pairs a unified patch aligns, unchanged gaps included, + * for files of `baseCount` and `headCount` lines. An empty patch pairs each + * line with itself. */ +export function patchRows( + patch: string, + baseCount: number, + headCount: number, +): AlignmentRow[] { + const rows: AlignmentRow[] = []; + let base = 0; + let head = 0; + let removed = 0; + let added = 0; + let inHunk = false; + + const append = (baseEnd: number, headEnd: number) => { + while (base < baseEnd || head < headEnd) + rows.push([ + base < baseEnd ? base++ : null, + head < headEnd ? head++ : null, + ]); + }; + + const flush = () => { + append(base + removed, head + added); + removed = added = 0; + }; + + for (const line of patch.split(/\r?\n/)) { + const hunk = HUNK.exec(line); + + if (hunk) { + inHunk = true; + flush(); + const [, oldStart, oldLines = "1", newStart, newLines = "1"] = hunk; + + append( + Number(oldLines) ? Number(oldStart) - 1 : Number(oldStart), + Number(newLines) ? Number(newStart) - 1 : Number(newStart), + ); + } else if (!inHunk) continue; + else if (line.startsWith("+")) added++; + else if (line.startsWith("-")) removed++; + else if (line.startsWith(" ")) { + flush(); + append(base + 1, head + 1); + } + } + + flush(); + append(baseCount, headCount); + + return rows; +} + /** Pins-less selections keep the key they always had. */ export function selectionKey(source: LensSource): string { return JSON.stringify([