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:
+94
-36
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user