F12: inline editable proposed-change diff in the Markdown editor + Accept/Reject control parity (#64) (#66)
This commit was merged in pull request #66.
This commit is contained in:
+102
-7
@@ -263,6 +263,42 @@ export function wordEditHunks(currentText: string, rewrittenText: string): EditH
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** F12/#64 (INV-49/52): the editor render of one optimistically-applied proposal. */
|
||||
export interface DecorationPlan {
|
||||
/** buffer ranges of inserted (proposed) text → tinted (INV-52). */
|
||||
insertions: { start: number; end: number }[];
|
||||
/** struck original text shown as a non-editable hint at a buffer offset (INV-52). */
|
||||
deletions: { at: number; text: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-49): compute the editor decoration plan for a proposal whose applied
|
||||
* text occupies `[anchorStart, anchorStart+replacement.length)` in the buffer. The
|
||||
* SAME word diff the webview uses (`wordEditHunks`) drives it, so both surfaces show
|
||||
* the identical diff. A changed run maps to (a) an insertion range over the run's
|
||||
* applied text and (b) a deletion hint carrying the run's removed original text at
|
||||
* the run start; a pure insertion has no deletion hint; a pure deletion has only a
|
||||
* hint. Pure, vscode-free, deterministic.
|
||||
*/
|
||||
export function decorationPlan(anchorStart: number, original: string, replacement: string): DecorationPlan {
|
||||
const insertions: { start: number; end: number }[] = [];
|
||||
const deletions: { at: number; text: string }[] = [];
|
||||
// Walk the word diff once, tracking the applied-side offset as we consume parts.
|
||||
let appliedOffset = anchorStart;
|
||||
for (const part of diffWordsWithSpace(original, replacement)) {
|
||||
if (part.added) {
|
||||
insertions.push({ start: appliedOffset, end: appliedOffset + part.value.length });
|
||||
appliedOffset += part.value.length;
|
||||
} else if (part.removed) {
|
||||
deletions.push({ at: appliedOffset, text: part.value });
|
||||
// removed text is NOT in the applied buffer → appliedOffset does not advance
|
||||
} else {
|
||||
appliedOffset += part.value.length;
|
||||
}
|
||||
}
|
||||
return { insertions, deletions };
|
||||
}
|
||||
|
||||
const isWs = (c: string): boolean => /\s/.test(c);
|
||||
|
||||
/**
|
||||
@@ -701,6 +737,8 @@ export interface ProposalView {
|
||||
replaced: string;
|
||||
/** the proposed replacement text. */
|
||||
replacement: string;
|
||||
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
|
||||
original?: string;
|
||||
}
|
||||
|
||||
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
|
||||
@@ -716,8 +754,14 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
|
||||
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
|
||||
const actions =
|
||||
`<span class="cw-actions">` +
|
||||
`<button class="cw-accept" data-action="accept">✓</button>` +
|
||||
`<button class="cw-reject" data-action="reject">✗</button>` +
|
||||
`<span class="cw-btngroup">` +
|
||||
`<button class="cw-accept" data-action="accept">Accept</button>` +
|
||||
`<button class="cw-caret" data-action="acceptAll" title="Accept all pending proposals">▾</button>` +
|
||||
`</span>` +
|
||||
`<span class="cw-btngroup">` +
|
||||
`<button class="cw-reject" data-action="reject">Reject</button>` +
|
||||
`<button class="cw-caret" data-action="rejectAll" title="Reject all pending proposals">▾</button>` +
|
||||
`</span>` +
|
||||
`</span>`;
|
||||
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
|
||||
}
|
||||
@@ -747,6 +791,26 @@ function renderReviewOp(
|
||||
* INV-34). Deterministic: proposals in the same block are ordered by anchorStart
|
||||
* then id; trailing proposals keep input order.
|
||||
*/
|
||||
/**
|
||||
* F12/#64 (INV-50): `currentText` with every resolved pending proposal's applied
|
||||
* span reverted to its original (`replaced`) — the "landed" text the baseline diff
|
||||
* should run against, so a pending proposal renders ONCE (as a proposal), never also
|
||||
* as a landed change. Reverts high→low so earlier offsets stay valid. Pure. The
|
||||
* preview's summary tally diffs against this too, so the toolbar count matches the
|
||||
* body (a pending change is not double-counted as both a landed add/remove and a
|
||||
* proposal).
|
||||
*/
|
||||
export function landedTextOf(currentText: string, proposals: ProposalView[]): string {
|
||||
const pendingApplied = proposals
|
||||
.filter((p) => p.anchorStart !== null)
|
||||
.sort((a, b) => b.anchorStart! - a.anchorStart!);
|
||||
let landedText = currentText;
|
||||
for (const p of pendingApplied) {
|
||||
landedText = landedText.slice(0, p.anchorStart!) + p.replaced + landedText.slice(p.anchorEnd!);
|
||||
}
|
||||
return landedText;
|
||||
}
|
||||
|
||||
export function renderReview(
|
||||
baselineText: string,
|
||||
currentText: string,
|
||||
@@ -755,8 +819,18 @@ export function renderReview(
|
||||
opts: RenderOptions = {},
|
||||
): string {
|
||||
const render = opts.render ?? defaultRender;
|
||||
const ranges = splitBlocksWithRanges(currentText);
|
||||
const ops = diffBlocks(baselineText, currentText);
|
||||
|
||||
// F12/#64 (INV-50): with optimistic apply the proposed text is already in
|
||||
// `currentText`, so a naive baseline→current diff would render each proposed
|
||||
// change BOTH as a landed diff and as its proposal block. Diff against the
|
||||
// "landed" text (current minus pending proposals) so they render once.
|
||||
const pendingApplied = proposals
|
||||
.filter((p) => p.anchorStart !== null)
|
||||
.sort((a, b) => b.anchorStart! - a.anchorStart!);
|
||||
const landedText = landedTextOf(currentText, proposals);
|
||||
|
||||
const ranges = splitBlocksWithRanges(landedText);
|
||||
const ops = diffBlocks(baselineText, landedText);
|
||||
// #48: right after a PIN (baseline reason "pinned") with no changes since, the
|
||||
// panel is fully clean: no change marks (already absent) AND no authorship
|
||||
// coloring, so the pin reads as "this is my clean starting point". Skip
|
||||
@@ -766,7 +840,28 @@ export function renderReview(
|
||||
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
|
||||
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
|
||||
|
||||
// Associate each resolved proposal with the current-side block index whose range
|
||||
// Map a currentText offset to its landedText offset (account for reverted spans
|
||||
// that precede it; reverts were applied high→low so the cumulative delta is stable).
|
||||
const toLanded = (curOff: number): number => {
|
||||
let delta = 0;
|
||||
for (const p of [...pendingApplied].sort((a, b) => a.anchorStart! - b.anchorStart!)) {
|
||||
if (p.anchorStart! < curOff) delta += p.replaced.length - (p.anchorEnd! - p.anchorStart!);
|
||||
}
|
||||
return curOff + delta;
|
||||
};
|
||||
|
||||
// F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a
|
||||
// landedText offset — but `authorSpans` arrive in `currentText` coordinates. A
|
||||
// colored block sitting AFTER a length-changing pending proposal would otherwise
|
||||
// be mis-colored (the offsets diverge by the revert delta). Map the spans into
|
||||
// landedText coordinates once so authorship coloring stays aligned.
|
||||
const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({
|
||||
...s,
|
||||
start: toLanded(s.start),
|
||||
end: toLanded(s.end),
|
||||
}));
|
||||
|
||||
// Associate each resolved proposal with the landedText block index whose range
|
||||
// it anchors into: the largest block with start <= anchorStart (the containing
|
||||
// block, or the nearest preceding block when the anchor sits in a gap). A
|
||||
// resolved anchor before all blocks, and every unresolved proposal, trails.
|
||||
@@ -778,7 +873,7 @@ export function renderReview(
|
||||
const byBlock = new Map<number, ProposalView[]>();
|
||||
const trailing: ProposalView[] = [];
|
||||
for (const p of proposals) {
|
||||
const j = p.anchorStart === null ? -1 : blockOf(p.anchorStart);
|
||||
const j = p.anchorStart === null ? -1 : blockOf(toLanded(p.anchorStart));
|
||||
if (j < 0) {
|
||||
trailing.push(p);
|
||||
continue;
|
||||
@@ -795,7 +890,7 @@ export function renderReview(
|
||||
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||
const colored = (raw: string): string =>
|
||||
blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
||||
blk && !clean ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user