Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) (#72)

This commit was merged in pull request #72.
This commit is contained in:
2026-07-02 23:09:37 +00:00
parent 93eeaf13b8
commit 935fcc35ee
54 changed files with 3689 additions and 1995 deletions
+94 -36
View File
@@ -193,6 +193,30 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
return ops;
}
/**
* #64/Task 3 (INV-6): the number of line-grain hunks between `oldText` and
* `newText` — a run of one or more contiguous non-equal lines counts as ONE
* hunk (the status-bar "N changes" count). Same LCS walk `diffBlocks` uses
* (jsdiff `diffArrays`), at line grain instead of block grain. Pure,
* vscode-free, deterministic.
*/
export function countLineHunks(oldText: string, newText: string): number {
const before = oldText.split(/\r?\n/);
const after = newText.split(/\r?\n/);
const changes = diffArrays(before, after);
let hunks = 0;
let inHunk = false;
for (const ch of changes) {
if (ch.added || ch.removed) {
if (!inHunk) hunks++;
inHunk = true;
} else {
inHunk = false;
}
}
return hunks;
}
/** A contiguous changed region of `currentText` and its replacement (F11). */
export interface EditHunk {
/** char offset of the hunk's first changed char in currentText. */
@@ -445,8 +469,13 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
return defaultFence(tokens, idx, options, env, self);
};
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */
function mermaidFenceBody(raw: string): string | null {
/**
* Inner source of a ```mermaid fence (drops the fence lines), or null. Exported
* so `previewAnnotations.ts` can pair a `diffBlocks` mermaid `changed` op's
* before/current fence bodies for `diffMermaid` without reinventing this
* extraction (Task 7 §2.6 parity).
*/
export function mermaidFenceBody(raw: string): string | null {
const lines = raw.split(/\r?\n/);
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
@@ -455,8 +484,12 @@ function mermaidFenceBody(raw: string): string | null {
return body.join("\n");
}
/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */
const MERMAID_LEGEND =
/**
* F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block.
* Exported for reuse by `previewAnnotations.ts`'s built-in-preview mermaid
* diff wiring (Task 7 §2.6 parity — same legend markup, both surfaces).
*/
export const MERMAID_LEGEND =
'<div class="cw-mermaid-legend">' +
'<span class="cw-leg cw-leg-add">added</span>' +
'<span class="cw-leg cw-leg-chg">changed</span>' +
@@ -548,11 +581,14 @@ export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string;
/**
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes
* since that pin, the on-render is fully clean — no authorship coloring — so a
* pin reads as "this is my clean starting point". Distinct from a baseline
* advanced by a machine-landing (accept), which keeps its authorship coloring
* (F10 INV-33). Only consulted by `renderReview`.
* #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as
* Reviewed" in snapshot mode). With zero changes since that pin, the
* on-render is fully clean — no authorship coloring — so a pin reads as
* "this is my clean starting point". Distinct from an ordinary accepted
* proposal, which — since the native-surfaces migration retired the
* machine-landing baseline advance (INV-18/INV-7) — stays a visible change-
* since-baseline and keeps its authorship coloring (F10 INV-33). Only
* consulted by `renderReview`.
*/
pinned?: boolean;
}
@@ -631,15 +667,33 @@ export interface AuthorSpan {
author: AuthorKind;
}
/**
* Task 7 (preview annotations, native-surfaces migration): the sentinel
* vocabulary generalizes beyond per-author spans to a plain "del" tag — a
* re-surfaced baseline deletion has no author variant (CSS carries one fixed
* `.cw-del`, the F10 vocabulary). `injectSentinels`/`sentinelsToSpans` are
* exported so `previewAnnotations.ts` can drive the SAME token-aware
* discipline (#33/#47) for its 3-way ins-claude/ins-human/del marks;
* `colorByAuthor` (F9/F10) keeps its narrower per-author call, unaffected.
*/
export type SentinelTag = AuthorKind | "del";
export interface TaggedSpan {
start: number;
end: number;
tag: SentinelTag;
}
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
// them through as plain text). Paired open/close per author.
const SENT = {
// them through as plain text). Paired open/close per tag.
const SENT: Record<SentinelTag, { open: string; close: string }> = {
claude: { open: "", close: "" },
human: { open: "", close: "" },
} as const;
del: { open: "", close: "" },
};
const SENT_TAGS = Object.keys(SENT) as SentinelTag[];
function isCloseSentinel(m: string): boolean {
return m === SENT.claude.close || m === SENT.human.close;
return SENT_TAGS.some((t) => SENT[t].close === m);
}
// Markdown emphasis / code delimiters whose RUNS must never be split by an
@@ -685,8 +739,8 @@ function blockContentStart(raw: string): number {
return 0;
}
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
/** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */
export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string {
const inserts: { at: number; marker: string }[] = [];
// Open sentinels must not precede block-level markdown markers (heading/list/
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it
@@ -696,8 +750,8 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
inserts.push({ at: lo, marker: SENT[s.author].open });
inserts.push({ at: hi, marker: SENT[s.author].close });
inserts.push({ at: lo, marker: SENT[s.tag].open });
inserts.push({ at: hi, marker: SENT[s.tag].close });
}
// Apply high offset → low so earlier offsets stay valid. At an equal offset
// (one span's close == the next's open), apply opens BEFORE closes so the
@@ -708,34 +762,34 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
return out;
}
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = {
[SENT.claude.open]: { author: "claude", open: true },
[SENT.claude.close]: { author: "claude", open: false },
[SENT.human.open]: { author: "human", open: true },
[SENT.human.close]: { author: "human", open: false },
};
const ALL_SENTINELS = new RegExp(
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
"g",
);
const SENTINEL_OF: Record<string, { tag: SentinelTag; open: boolean } | undefined> = {};
for (const tag of SENT_TAGS) {
SENTINEL_OF[SENT[tag].open] = { tag, open: true };
SENTINEL_OF[SENT[tag].close] = { tag, open: false };
}
const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g");
/**
* #33: token-aware replacement of the rendered author sentinels with `cw-ins-*`
* spans. A naive global string-replace (the old approach) could emit a span that
* #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A
* naive global string-replace (the old approach) could emit a span that
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and
* emits the span only around TEXT runs, CLOSING it before any `<tag>` and
* REOPENING it after, so a span is always well-nested within the inline elements
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
* sentinel stripped, so no Private-Use-Area char ever leaks). `kind` picks the
* author-class prefix (`cw-by-*`/`cw-ins-*`); the tag-less "del" mark (Task 7,
* no author variant) always renders the fixed `cw-del` regardless of `kind`.
* Pure, deterministic.
*/
function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
export function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const out: string[] = [];
let current: AuthorKind | null = null; // which author region we're inside
let current: SentinelTag | null = null; // which region we're inside
let spanOpen = false; // whether a <span> is currently open in `out`
const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`);
const openSpan = () => {
if (current && !spanOpen) {
out.push(`<span class="cw-${kind}-${current}">`);
out.push(`<span class="${classFor(current)}">`);
spanOpen = true;
}
};
@@ -749,7 +803,7 @@ function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const ch = html[i];
const sentinel = SENTINEL_OF[ch];
if (sentinel) {
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
if (sentinel.open) current = sentinel.tag; // span opens lazily before the next text char
else {
closeSpan();
current = null;
@@ -786,7 +840,11 @@ export function colorByAuthor(
kind: "by" | "ins" = "by",
): string {
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
const injected = injectSentinels(raw, blockStart, overlapping);
const injected = injectSentinels(
raw,
blockStart,
overlapping.map((s) => ({ start: s.start, end: s.end, tag: s.author })),
);
return sentinelsToSpans(render(injected), kind);
}