feat(f11): SLICE-2 — block-level data-src emission (#43, INV-36)

The pure render layer now emits data-src-start/data-src-end (source char offsets
from BlockWithRange) on every LIVE-source rendered block, in BOTH modes — the
contract the webview's selection→source mapping (SLICE-4) walks the DOM for.
Per spec §6.1 INV-36 / §7.2 SLICE-2.

- trackChangesModel: shared `srcAttr(blk)` helper; threaded through renderOp +
  renderReviewOp + the renderReview loop (removed blocks → "" / no data-src, as
  they have no live source). renderPlain switches from a single whole-document
  markdown pass to per-block `<div data-src-start/end>` wrappers — bare divs (no
  cw- class) so the off/clean preview stays visually clean while becoming a
  selection→source surface. Pure, vscode-free, deterministic (extends INV-22).
- unit (test/trackChangesModel.test.ts): data-src offsets equal
  splitBlocksWithRanges in both modes; removed + proposal blocks carry none;
  off-mode stays cw--free; determinism.

200 unit + 47 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-12 13:45:02 -07:00
parent 8b9e61a1da
commit 1ef9451e89
2 changed files with 82 additions and 11 deletions
+34 -11
View File
@@ -247,7 +247,17 @@ function chip(message: string): string {
return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
}
function renderOp(op: BlockOp, render: (src: string) => string): string {
/**
* F11 (INV-36): the source-range attributes a live block's wrapping element
* carries, so a preview selection can map back to a source markdown range. `""`
* when the block has no live source (a baseline-only deletion / a proposal
* block) — those are skipped by the selection mapper. Pure + deterministic.
*/
function srcAttr(blk: { start: number; end: number } | undefined): string {
return blk ? ` data-src-start="${blk.start}" data-src-end="${blk.end}"` : "";
}
function renderOp(op: BlockOp, render: (src: string) => string, src = ""): string {
const safe = (src: string): string => {
try {
return render(src);
@@ -293,7 +303,7 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
}
break;
}
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
return `<div class="cw-blk ${cls}"${src}>${badge}${inner}</div>`;
}
export type AuthorKind = "claude" | "human";
@@ -367,14 +377,25 @@ export function colorByAuthor(
return sentinelsToSpans(render(injected));
}
/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */
/**
* Off-state body: the current buffer as plain markdown, no annotations (INV-33).
* Each block is wrapped in a bare `<div data-src-start/end>` so the off-mode
* preview is still a selection→source mapping surface (INV-36) while staying
* visually clean — no `cw-` annotation classes. A doc with no blocks (empty /
* whitespace-only) renders whole. Pure + deterministic.
*/
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
const render = opts.render ?? defaultRender;
try {
return render(currentText);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
const safe = (src: string): string => {
try {
return render(src);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
};
const blocks = splitBlocksWithRanges(currentText);
if (blocks.length === 0) return safe(currentText);
return blocks.map((b) => `<div${srcAttr(b)}>${safe(b.raw)}</div>`).join("\n");
}
export interface ProposalView {
@@ -411,11 +432,13 @@ function renderReviewOp(
op: BlockOp,
render: (src: string) => string,
colored: (raw: string) => string,
src: string,
): string {
// removed blocks and any changed block (atomic fences diffed whole; non-atomic prose
// word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7).
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render);
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}">${colored(op.block.raw)}</div>`;
// `src` is "" for a removed block (no live source — INV-36).
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
}
/**
@@ -471,7 +494,7 @@ export function renderReview(
const blk = op.kind === "removed" ? undefined : ranges[ci++];
const colored = (raw: string): string =>
blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
bodyParts.push(renderReviewOp(op, render, colored));
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
}