diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 930772e..3326d39 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -474,6 +474,76 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string { .join(""); } +/** The author whose span covers `offset` (half-open [start,end)), or null. */ +export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null { + for (const s of spans) if (offset >= s.start && offset < s.end) return s.author; + return null; +} + +const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`; +const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`; + +/** + * Author-colored word diff for a changed PROSE block in the review. + * `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's + * offset maps into `spans`. Insertions are colored by the author covering their + * offset; deletions inherit the author of the insertion they are paired with in + * the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a + * cluster has no insertion. Pure, deterministic. + */ +export function wordDiffByAuthor( + beforeRaw: string, + afterRaw: string, + afterStart: number, + spans: AuthorSpan[], +): string { + const parts = diffWordsWithSpace(beforeRaw, afterRaw); + // First pass: find each change cluster's insertion author (first added run). + // A cluster is a maximal run of added/removed parts bounded by unchanged parts. + let afterOff = afterStart; + const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author + { + let off = afterStart; + let i = 0; + while (i < parts.length) { + const p = parts[i]; + if (!p.added && !p.removed) { + clusterAuthor[i] = null; + off += p.value.length; + i++; + continue; + } + // a cluster: scan to its end, capturing the first added run's author + let j = i; + let author: AuthorKind | null = null; + let scanOff = off; + while (j < parts.length && (parts[j].added || parts[j].removed)) { + if (parts[j].added && author === null) author = authorAt(scanOff, spans); + if (parts[j].added) scanOff += parts[j].value.length; + j++; + } + for (let k = i; k < j; k++) clusterAuthor[k] = author; + off = scanOff; + i = j; + } + } + // Second pass: emit. + const out: string[] = []; + parts.forEach((p, i) => { + if (p.added) { + const a = authorAt(afterOff, spans); + out.push(`${p.value}`); + afterOff += p.value.length; + } else if (p.removed) { + out.push(`${p.value}`); + } else { + out.push(p.value); + afterOff += p.value.length; + } + }); + return out.join(""); +} + export interface RenderOptions { /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ render?: (src: string) => string; diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index a6b42f4..87d8f13 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, test, expect } from "vitest"; -import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel"; +import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { @@ -209,6 +209,46 @@ describe("colorByAuthor", () => { }); }); +describe("authorAt", () => { + const spans: AuthorSpan[] = [ + { start: 0, end: 5, author: "human" }, + { start: 5, end: 10, author: "claude" }, + ]; + test("returns the covering span's author (half-open)", () => { + expect(authorAt(0, spans)).toBe("human"); + expect(authorAt(4, spans)).toBe("human"); + expect(authorAt(5, spans)).toBe("claude"); + expect(authorAt(9, spans)).toBe("claude"); + }); + test("returns null outside any span", () => { + expect(authorAt(10, spans)).toBeNull(); + expect(authorAt(-1, spans)).toBeNull(); + }); +}); + +describe("wordDiffByAuthor", () => { + test("an inserted run is colored by the author covering its landed offset", () => { + // before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12 + const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }]; + const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans); + expect(html).toContain('brave '); + expect(html).toContain("hello "); + }); + test("a paired deletion inherits the paired insertion's author (adjacency)", () => { + // "light" -> "dark", both in one cluster; "dark" is claude-inserted + const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }]; + const html = wordDiffByAuthor("light", "dark", 0, spans); + expect(html).toContain('light'); + expect(html).toContain('dark'); + }); + test("a standalone deletion (no paired insertion) is neutral", () => { + const html = wordDiffByAuthor("keep this word", "keep word", 0, []); + expect(html).toContain('this '); + expect(html).not.toContain("cw-del-human"); + expect(html).not.toContain("cw-del-claude"); + }); +}); + import { renderPlain } from "../src/trackChangesModel"; describe("renderPlain", () => {