dsh-diff-approval 0.36.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -2503,7 +2503,7 @@ var CommentAsker = class {
2503
2503
  const comment = this.comments.get(commentId);
2504
2504
  if (comment === void 0 || !inScope(comment.sessionId)) return { outcome: "missing" };
2505
2505
  const controller = this.ctx.get("sessionController");
2506
- if (typeof controller?.prompt !== "function") {
2506
+ if (typeof controller?.prompt !== "function" && typeof controller?.resolveAgent !== "function") {
2507
2507
  this.ctx.logger.warn("diff-approval: no sessionController.prompt in this host, so a comment cannot be asked");
2508
2508
  return { outcome: "no-agent" };
2509
2509
  }
@@ -2514,6 +2514,22 @@ var CommentAsker = class {
2514
2514
  this.watch(sessionId);
2515
2515
  const requestId = randomUUID();
2516
2516
  this.comments.recordAsk(sessionId, commentId, requestId, void 0, text);
2517
+ const source = {
2518
+ kind: "diff-comment",
2519
+ rpcId: requestId,
2520
+ commentId,
2521
+ path: comment.path,
2522
+ startLine: comment.anchor.startLine,
2523
+ endLine: comment.anchor.endLine
2524
+ };
2525
+ if (await this.deliverAsCard(sessionId, prompt, source)) return {
2526
+ outcome: "asked",
2527
+ requestId
2528
+ };
2529
+ if (typeof controller.prompt !== "function") return {
2530
+ outcome: "no-agent",
2531
+ requestId
2532
+ };
2517
2533
  try {
2518
2534
  await controller.prompt({
2519
2535
  requestId,
@@ -2539,6 +2555,53 @@ var CommentAsker = class {
2539
2555
  }
2540
2556
  }
2541
2557
  /**
2558
+ * Deliver one comment through the host's WAKE-UP seam, so the shell draws it as a trigger card.
2559
+ *
2560
+ * The seam is the one the host's own reminders use: resolve the live agent, publish a user message on
2561
+ * it, then flush. The message's `source` is what the shell's closed switch turns into a card, and this
2562
+ * plugin's client retitles its own source on the rendered DOM (an unknown kind falls back to a generic
2563
+ * title). A host without the seam — or any failure asking for it — answers false, and the caller falls
2564
+ * back to an ordinary prompt rather than losing the question.
2565
+ *
2566
+ * @param sessionId - the session whose live agent wakes.
2567
+ * @param prompt - the prompt text to deliver verbatim.
2568
+ * @param source - the comment's source marker, carried onto the message.
2569
+ * @returns whether the message was published on the live agent.
2570
+ */
2571
+ async deliverAsCard(sessionId, prompt, source) {
2572
+ const controller = this.ctx.get("sessionController");
2573
+ if (typeof controller?.resolveAgent !== "function") return false;
2574
+ let resolved;
2575
+ try {
2576
+ resolved = await controller.resolveAgent(sessionId);
2577
+ } catch {
2578
+ return false;
2579
+ }
2580
+ if (resolved === void 0 || resolved.error !== void 0 || resolved.agent === void 0) return false;
2581
+ const agent = resolved.agent;
2582
+ if (typeof agent.followup !== "function") return false;
2583
+ try {
2584
+ agent.followup({
2585
+ id: randomUUID(),
2586
+ role: "user",
2587
+ content: [{
2588
+ type: "text",
2589
+ text: prompt
2590
+ }],
2591
+ source
2592
+ });
2593
+ } catch {
2594
+ return false;
2595
+ }
2596
+ const sessions = this.ctx.get("sessions");
2597
+ if (typeof sessions?.flush === "function" && agent.session !== void 0) try {
2598
+ if (!await sessions.flush(agent.session)) this.ctx.logger.warn("diff-approval: the host did not persist the comment delivery");
2599
+ } catch (error) {
2600
+ this.ctx.logger.warn(`diff-approval: flushing the comment delivery failed: ${rejectionText(error)}`);
2601
+ }
2602
+ return true;
2603
+ }
2604
+ /**
2542
2605
  * One scope's questions, grouped by THE TRANSCRIPT each was submitted into.
2543
2606
  *
2544
2607
  * A lineage's threads may hold questions asked from several seats, and an answer lives in the
@@ -6548,8 +6611,17 @@ function apply(ctx, config) {
6548
6611
  ok: true,
6549
6612
  value: { outcome: "missing" }
6550
6613
  };
6551
- const accepted = contentRangeOf(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd);
6552
- const updatedOld = replaceContentLines(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd, accepted);
6614
+ const chars = charSpansOf(blockTarget.block);
6615
+ let updatedOld;
6616
+ if (chars === void 0 || !charSpansStayOnOneLine(entry.oldText, entry.newText, chars)) {
6617
+ const accepted = contentRangeOf(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd);
6618
+ updatedOld = replaceContentLines(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd, accepted);
6619
+ } else {
6620
+ const accepted = entry.newText.slice(chars.new.start, chars.new.end);
6621
+ const spliced = spliceChars(entry.oldText, chars.old.start, chars.old.end, accepted);
6622
+ if (spliced === void 0) return rpcError("block character span does not fit the file");
6623
+ updatedOld = spliced;
6624
+ }
6553
6625
  store.update(blockTarget.id, { oldText: updatedOld });
6554
6626
  const afterEntry = {
6555
6627
  ...entry,
@@ -6602,9 +6674,18 @@ function apply(ctx, config) {
6602
6674
  ok: true,
6603
6675
  value: { outcome: "missing" }
6604
6676
  };
6605
- const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
6606
- const updatedNew = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
6607
- const content = reencodeEol(updatedNew, detectEol(entry.newText));
6677
+ const revertChars = charSpansOf(blockTarget.block);
6678
+ let revertedText;
6679
+ if (revertChars === void 0 || !charSpansStayOnOneLine(entry.oldText, entry.newText, revertChars)) {
6680
+ const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
6681
+ revertedText = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
6682
+ } else {
6683
+ const restored = entry.oldText.slice(revertChars.old.start, revertChars.old.end);
6684
+ const spliced = spliceChars(entry.newText, revertChars.new.start, revertChars.new.end, restored);
6685
+ if (spliced === void 0) return rpcError("block character span does not fit the file");
6686
+ revertedText = spliced;
6687
+ }
6688
+ const content = reencodeEol(revertedText, detectEol(entry.newText));
6608
6689
  store.update(blockTarget.id, { newText: content });
6609
6690
  const afterEntry = {
6610
6691
  ...entry,
@@ -6639,7 +6720,7 @@ function apply(ctx, config) {
6639
6720
  }
6640
6721
  if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
6641
6722
  persistSession();
6642
- const fullyResolved = contentEqual(updatedNew, entry.oldText);
6723
+ const fullyResolved = contentEqual(revertedText, entry.oldText);
6643
6724
  if (fullyResolved && blockTarget.removeWhenResolved === true) {
6644
6725
  const resolvedDrop = droppingComments({
6645
6726
  id: entry.id,
@@ -7233,6 +7314,8 @@ function blockTargetOf(payload) {
7233
7314
  newStart,
7234
7315
  newEnd
7235
7316
  ].every((value) => typeof value === "number" && Number.isFinite(value))) return void 0;
7317
+ const oldChars = charSpanOf(block.oldChars);
7318
+ const newChars = charSpanOf(block.newChars);
7236
7319
  const removeWhenResolved = payload.removeWhenResolved;
7237
7320
  return {
7238
7321
  ...target,
@@ -7240,11 +7323,78 @@ function blockTargetOf(payload) {
7240
7323
  oldStart,
7241
7324
  oldEnd,
7242
7325
  newStart,
7243
- newEnd
7326
+ newEnd,
7327
+ ...oldChars !== void 0 && newChars !== void 0 ? {
7328
+ oldChars,
7329
+ newChars
7330
+ } : {}
7244
7331
  },
7245
7332
  removeWhenResolved: typeof removeWhenResolved === "boolean" ? removeWhenResolved : void 0
7246
7333
  };
7247
7334
  }
7335
+ /** Narrow one wire character span: two finite, non-negative, ordered offsets. */
7336
+ function charSpanOf(value) {
7337
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
7338
+ const { start, end } = value;
7339
+ if (typeof start !== "number" || typeof end !== "number") return void 0;
7340
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return void 0;
7341
+ if (start < 0 || end < start) return void 0;
7342
+ return {
7343
+ start,
7344
+ end
7345
+ };
7346
+ }
7347
+ /**
7348
+ * The pair of character spans a block names, when it names both.
7349
+ *
7350
+ * A block carries either both spans (a preview's sub-line change) or neither (every line-based caller); one
7351
+ * without the other is meaningless and is treated as absent.
7352
+ *
7353
+ * @param block - the incoming block range.
7354
+ * @returns the two spans, or undefined for a line-based block.
7355
+ */
7356
+ function charSpansOf(block) {
7357
+ if (block.oldChars === void 0 || block.newChars === void 0) return void 0;
7358
+ return {
7359
+ old: block.oldChars,
7360
+ new: block.newChars
7361
+ };
7362
+ }
7363
+ /**
7364
+ * Whether character spans really stay inside ONE line on both sides.
7365
+ *
7366
+ * A character-level keep/revert is only safe within a line. A caller can (and an older renderer did) send a
7367
+ * span that crosses a newline — splicing that accepted text into the middle of a line corrupts the baseline,
7368
+ * duplicating or swallowing lines and making the block impossible to keep afterwards. Such a span falls back
7369
+ * to its line range, which the block always carries.
7370
+ *
7371
+ * @param oldText - the entry's baseline.
7372
+ * @param newText - the entry's current content.
7373
+ * @param spans - the character spans to check.
7374
+ * @returns true when both spanned slices are single-line (or empty).
7375
+ */
7376
+ function charSpansStayOnOneLine(oldText, newText, spans) {
7377
+ const oldSlice = oldText.slice(spans.old.start, spans.old.end);
7378
+ const newSlice = newText.slice(spans.new.start, spans.new.end);
7379
+ return !oldSlice.includes("\n") && !newSlice.includes("\n");
7380
+ }
7381
+ /**
7382
+ * Replace `[start, end)` of `text` with `insert`, refusing a span that does not fit.
7383
+ *
7384
+ * The character-level counterpart of `replaceContentLines` (which works in whole lines): a Markdown preview
7385
+ * block can be smaller than a line — a changed table cell beside an unchanged one — and keeping or reverting
7386
+ * just that change must not touch its neighbours on the same line.
7387
+ *
7388
+ * @param text - the file the span indexes.
7389
+ * @param start - first character to replace, 0-based.
7390
+ * @param end - offset just past the last character to replace.
7391
+ * @param insert - what takes its place.
7392
+ * @returns the new text, or undefined when the span is out of range.
7393
+ */
7394
+ function spliceChars(text, start, end, insert) {
7395
+ if (start > end || end > text.length) return void 0;
7396
+ return text.slice(0, start) + insert + text.slice(end);
7397
+ }
7248
7398
  /** Narrow a wire payload to one keep/revert target. */
7249
7399
  function targetOf(payload) {
7250
7400
  const sessionId = sessionOf(payload);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The session-scoped seat that publishes the shown conversation to the comment-card bridge.
3
+ *
4
+ * It renders nothing: the shell hands every session-scope slot component `sessionId` and `useChat`, and
5
+ * the published snapshot is what lets the DOM side join a rendered trigger card's node key back to the
6
+ * comment it delivered. Both props are read defensively, so a shell that hands only one of them still
7
+ * leaves the other facts intact.
8
+ *
9
+ * @module dsh-diff-approval/client/comment-card-seat
10
+ */
11
+ import type { ReactNode } from 'react';
12
+ /** Props the shell injects into a session-scoped seat. */
13
+ export interface CommentCardSeatProps {
14
+ /** The session this seat's page is showing. */
15
+ sessionId?: unknown;
16
+ /** The chat snapshot selector, when this shell merges it onto session-scope slots. */
17
+ useChat?: unknown;
18
+ }
19
+ /**
20
+ * Publish the shown conversation's snapshot for the comment-card titles.
21
+ * @param props - the shell-injected session id and chat hook.
22
+ * @returns null; this seat is invisible by design.
23
+ */
24
+ export declare function CommentCardSeat(props: CommentCardSeatProps): ReactNode;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Title the shell's trigger card for a COMMENT delivered from the review panel.
3
+ *
4
+ * A comment asked of an idle agent is delivered through the host's wake-up seam (see
5
+ * `src/comment-ask.ts`), so the shell draws that turn's wake message as its own `turn-trigger` card —
6
+ * the same card a scheduled reminder or a shortcut gets. The card's title comes from a CLOSED switch
7
+ * over `source.kind` inside the shell, and a kind it does not know falls back to the generic "execution
8
+ * requested"; there is no seam to teach it a new kind without taking over the renderer for EVERY wake
9
+ * source. So the title is corrected here, in the DOM, joined through the node key the shell already
10
+ * puts on the card.
11
+ *
12
+ * Only cards whose source kind is this plugin's own (`diff-comment`) are touched: a reminder, a goal
13
+ * or another plugin's delivery keeps exactly the title it had.
14
+ *
15
+ * @module dsh-diff-approval/client/comment-card
16
+ */
17
+ /** One delivered comment, as the chat snapshot exposes it. */
18
+ export interface CommentCardMeta {
19
+ /** The reviewed file, as the comment recorded it. */
20
+ path: string;
21
+ /** First new-file line of the comment's anchor. */
22
+ startLine: number;
23
+ /** Last new-file line of the comment's anchor. */
24
+ endLine: number;
25
+ }
26
+ /**
27
+ * Build the node key → comment map from one chat snapshot.
28
+ * @param snapshot - the chat snapshot handed to the seat.
29
+ * @returns the delivered comments, by the node key the shell puts on their card.
30
+ */
31
+ export declare function buildCommentCards(snapshot: unknown): Map<string, CommentCardMeta>;
32
+ /**
33
+ * Publish the shown conversation's delivered comments.
34
+ * @param snapshot - the chat snapshot as the seat received it.
35
+ */
36
+ export declare function publishCommentCards(snapshot: unknown): void;
37
+ /** Forget the published comments (the seat unmounted). */
38
+ export declare function withdrawCommentCards(): void;
39
+ /** Subscribe a non-React reader to publishes. */
40
+ export declare function subscribeCommentCards(listener: () => void): () => void;
41
+ /** The comment a delivered card came from, or undefined for every other wake source. */
42
+ export declare function publishedCommentCard(nodeKey: string): CommentCardMeta | undefined;
43
+ /**
44
+ * The label a card's title carries: the file's base name and the comment's lines.
45
+ * @param meta - the delivered comment.
46
+ * @returns `README.md:40`, or `README.md:40-42` for a multi-line anchor.
47
+ */
48
+ export declare function commentCardLabel(meta: CommentCardMeta): string;
49
+ /** The translator this decoration needs (the plugin's own namespace). */
50
+ export type CommentCardTranslate = (key: 'commentCard.title') => string;
51
+ /**
52
+ * Title every rendered trigger card that belongs to one of our delivered comments.
53
+ *
54
+ * Separate from {@link attachCommentCardTitles} so the DOM pass is a plain, testable call.
55
+ * @param t - translator for the plugin's namespace.
56
+ */
57
+ export declare function titleCommentCards(t: CommentCardTranslate): void;
58
+ /**
59
+ * Title delivered comments' cards, and keep them titled as the chat re-renders.
60
+ *
61
+ * Returns early without a `document`, like every DOM bridge in this plugin: the published bundle is
62
+ * exercised in headless harnesses where those globals do not exist, and a decoration must never be the
63
+ * reason a plugin fails to load.
64
+ * @param options - the plugin's translator.
65
+ * @returns a disposer that stops observing (the cards unmount with the view).
66
+ */
67
+ export declare function attachCommentCardTitles(options: {
68
+ t: CommentCardTranslate;
69
+ }): () => void;
@@ -57,6 +57,11 @@ export declare const zh: {
57
57
  'panel.diffLineHeightDesc': string;
58
58
  'panel.mdPreview': string;
59
59
  'panel.mdPreviewDesc': string;
60
+ 'panel.mdStructure': string;
61
+ 'panel.mdStructureDesc': string;
62
+ 'panel.sourceDiff': string;
63
+ 'panel.invisibleContent': string;
64
+ 'commentCard.title': string;
60
65
  'panel.codeFont': string;
61
66
  'panel.codeFontDesc': string;
62
67
  'panel.mdMaxWidth': string;
@@ -327,6 +332,11 @@ export declare const en: {
327
332
  'panel.diffLineHeightDesc': string;
328
333
  'panel.mdPreview': string;
329
334
  'panel.mdPreviewDesc': string;
335
+ 'panel.mdStructure': string;
336
+ 'panel.mdStructureDesc': string;
337
+ 'panel.sourceDiff': string;
338
+ 'panel.invisibleContent': string;
339
+ 'commentCard.title': string;
330
340
  'panel.codeFont': string;
331
341
  'panel.codeFontDesc': string;
332
342
  'panel.mdMaxWidth': string;
@@ -1,20 +1,201 @@
1
1
  /** Self-contained Markdown preview-diff renderer.
2
2
  *
3
- * Renders the whole-file text diff at the Markdown level: the source diff is
4
- * computed first, then each added/removed/context run is rendered through
5
- * `marked` and presented single-column (merged, like the unified source diff)
6
- * or double-column (before | after). Every rendered block carries its run's
7
- * add/remove/context status so it can be tinted with the diff colors.
3
+ * Two styles, chosen by the caller:
4
+ *
5
+ * - `blocks` (the original): the source diff is cut into contiguous add/del/context runs and each run is
6
+ * parsed as a document of its own, then wrapped in a `<div>`. Simple, and wrong for any structure that
7
+ * spans a run boundary — a reworded list item becomes four lists, a changed table row falls out of the
8
+ * table and renders as text, a changed fenced line stops being code.
9
+ * - `structure` (the default): the whole document is rendered ONCE, exactly as the author wrote it, and
10
+ * the diff is mapped onto the elements that came from the changed lines. Nothing is re-parsed, no
11
+ * wrapper is inserted, so the document keeps its own layout. A deletion and the addition that replaces
12
+ * it are shown TOGETHER inside the same element (`~~old~~ new`), and a deleted element with no
13
+ * counterpart is emitted as a struck ghost beside its sibling — inside the same list, table or column.
14
+ *
15
+ * Both styles emit the same contract the panel's anchoring already reads: `data-md-block` / `data-md-kind` on
16
+ * every element that carries a difference BLOCK (what the toolbar numbers, frames and keeps), `data-md-unit`
17
+ * on every element that carries a character-level change inside one (what is highlighted), plus `mdAdd` /
18
+ * `mdDel` for the tint and `mdWordAdd` / `mdWordDel` for the changed characters inside one element.
19
+ */
20
+ /** How the preview expresses the diff. See the module comment. */
21
+ export type MarkdownPreviewStyle = 'structure' | 'blocks';
22
+ /** An inclusive span of 0-based source lines. */
23
+ interface LineSpan {
24
+ start: number;
25
+ end: number;
26
+ }
27
+ /**
28
+ * One character-level change the preview HIGHLIGHTS.
29
+ *
30
+ * The source view's blocks are lines, but a rendered document puts several changes on one line: a table row
31
+ * holds a reworded cell AND a retargeted link, and each is a separate run to highlight. The preview cuts each
32
+ * line block into these units for the drawn marks. What the reader COUNTS and keeps, though, is a BLOCK
33
+ * (`PreviewBlock`): one pure-addition hunk, one table row, or one invisible change — which may hold several
34
+ * units (see `previewBlocks`).
35
+ */
36
+ export interface PreviewUnit {
37
+ /** Index in the preview's own unit numbering: the `data-md-unit` value (its block is `data-md-block`). */
38
+ index: number;
39
+ /** The source-line block this unit was cut from. */
40
+ lineBlock: number;
41
+ /** The line block's own 1-based inclusive line ranges, carried along for the source view. */
42
+ oldLines: {
43
+ start: number;
44
+ end: number;
45
+ };
46
+ newLines: {
47
+ start: number;
48
+ end: number;
49
+ };
50
+ /** The change's absolute character span per side, `[start, end)`; `start === end` is an insertion. */
51
+ oldChars: {
52
+ start: number;
53
+ end: number;
54
+ };
55
+ newChars: {
56
+ start: number;
57
+ end: number;
58
+ };
59
+ /** The changed source itself on each side, for matching the runs that were drawn. */
60
+ oldText: string;
61
+ newText: string;
62
+ }
63
+ /**
64
+ * Cut the file's change blocks into the preview's units.
65
+ *
66
+ * A line block is worked through LINE BY LINE: its changed old lines and changed new lines are paired in
67
+ * order (the k-th removed line with the k-th added one, the same reading the side-by-side view uses), and each
68
+ * pair is diffed again character by character — every run of changed characters becomes a unit, and runs a
69
+ * few characters apart on that line are folded into one so a reworded sentence stays one change rather than
70
+ * three. A changed line with no counterpart is a pure deletion or addition and is one unit itself over its
71
+ * whole line.
72
+ *
73
+ * Working per line is what keeps a deleted paragraph from being swallowed by the retargeted link above it: a
74
+ * character diff run over a whole multi-line block would fold the newline into the change and report the link
75
+ * and the paragraph as one unit, and a unit is a hover target and a Keep/Revert action.
76
+ *
77
+ * @param oldText - the file's tracked baseline.
78
+ * @param newText - the file's current content.
79
+ * @returns the units, in document order.
8
80
  */
81
+ /** localStorage key that picks the preview's diff pipeline: `1` = tree-first, `0` = legacy line-hunk path. */
82
+ export declare const TREE_DIFF_SETTING = "diff-approval:tree-diff";
83
+ /** The pipeline the preview uses when nothing is stored: the tree-first path. */
84
+ export declare const TREE_DIFF_DEFAULT = true;
85
+ /**
86
+ * Whether the tree-first diff is active.
87
+ *
88
+ * The browser default is the TREE path (`TREE_DIFF_DEFAULT`); `localStorage` can force either pipeline (1/0).
89
+ * Inside the unit-test environment the default is the LEGACY path instead, so the old behavior's regression
90
+ * specs keep asserting the old behavior; the tree path's own specs opt in explicitly. End-to-end tests run the
91
+ * real browser default and a dedicated e2e spec pins the tree path there.
92
+ */
93
+ export declare function treeDiffEnabled(): boolean;
94
+ export declare function previewUnits(oldText: string, newText: string): PreviewUnit[];
95
+ /** A change the rendering cannot show: the toolbar lists it as source, with its own line numbers. */
96
+ export interface PreviewInvisibleDiff {
97
+ /** The removed source line(s), 1-based inclusive. */
98
+ oldLines?: {
99
+ start: number;
100
+ end: number;
101
+ };
102
+ /** The added source line(s), 1-based inclusive. */
103
+ newLines?: {
104
+ start: number;
105
+ end: number;
106
+ };
107
+ /** The removed source itself. */
108
+ oldText?: string;
109
+ /** The added source itself. */
110
+ newText?: string;
111
+ /** The grammar the source is highlighted with: YAML for front matter, Markdown otherwise. */
112
+ grammar: 'markdown' | 'yaml';
113
+ }
114
+ /**
115
+ * One DIFFERENCE BLOCK: what the preview numbers, navigates, frames, and keeps or reverts.
116
+ *
117
+ * A block is NOT the same thing as a unit. A unit is a character-level change inside one line — the thing the
118
+ * preview highlights — while a block is the thing the reader counts and acts on. One block can hold several
119
+ * units (a changed table row's cells, a whole added paragraph's lines) or none at all (a whole new row, a
120
+ * deletion the rendering only reports as source). The rules:
121
+ *
122
+ * - a pure addition or pure deletion hunk (a new paragraph, a new list, a new table, the whole of a new
123
+ * file) is ONE block, however many units its lines were cut into;
124
+ * - a mixed hunk (an edit) numbers one block per unit, except a table row, which is one block for the row;
125
+ * a table row with no counterpart in the other version is one block too, tinted as a row;
126
+ * - a change that renders nothing (front matter, an HTML comment, a link definition) is a block of its own;
127
+ * inside a merged pure block it joins that block instead (its source is listed in the block's tabs).
128
+ */
129
+ export interface PreviewBlock {
130
+ /** Document order: the preview's `data-md-block` value and the toolbar's number. */
131
+ index: number;
132
+ /** The source-line hunk this block came from. */
133
+ lineBlock: number;
134
+ /**
135
+ * The highlighted units that belong to this block. A whole-row block still lists its one whole-line unit
136
+ * (the unit→block map needs it), but the renderer draws the ROW rather than that unit's characters.
137
+ */
138
+ units: PreviewUnit[];
139
+ /** Keep/revert range on the old side, 1-based; an absent side is empty (`oldStart > oldEnd`). */
140
+ oldStart: number;
141
+ oldEnd: number;
142
+ /** Keep/revert range on the new side, 1-based; an absent side is empty (`newStart > newEnd`). */
143
+ newStart: number;
144
+ newEnd: number;
145
+ /** The character span used when this block is exactly one unit and has no invisible part. */
146
+ oldChars?: {
147
+ start: number;
148
+ end: number;
149
+ };
150
+ newChars?: {
151
+ start: number;
152
+ end: number;
153
+ };
154
+ /** The lines that actually changed (0-based, inclusive), for drawing the block: no insertion widening. */
155
+ oldChanged?: LineSpan;
156
+ newChanged?: LineSpan;
157
+ /** Whether a whole pure-addition/deletion hunk was merged into this block. */
158
+ merged: boolean;
159
+ /** A table row with no counterpart on the other side: tint the row, not its cells. */
160
+ wholeRow?: 'add' | 'del';
161
+ /** Changes inside this block that the rendering cannot show, in source order. */
162
+ invisible: PreviewInvisibleDiff[];
163
+ }
164
+ /**
165
+ * The preview's DIFFERENCE BLOCKS: the unit the reader counts, navigates, frames and keeps/reverts.
166
+ *
167
+ * @param oldText - the file's tracked baseline.
168
+ * @param newText - the file's current content.
169
+ * @returns the blocks, in document order.
170
+ */
171
+ export declare function previewBlocks(oldText: string, newText: string): PreviewBlock[];
9
172
  /**
10
173
  * Render the Markdown preview as sanitized HTML.
11
174
  * @param oldText - the file's tracked baseline (rendered as "before").
12
- * @param newText - the file's current content (rendered as "after").
13
- * @param mode - 'single' merges add/remove/context (unified-like); 'double'
14
- * shows the before and after renderings aligned row by row, each change block
15
- * tinted (deleted on the before side, added on the after side). Comparable
16
- * edits get word-level highlights on the changed words.
17
- * @returns sanitized HTML for the preview body. Local images keep their src so
18
- * a later pass can resolve them to data URIs (see the preview image resolver).
19
- */
20
- export declare function renderMarkdownPreview(oldText: string, newText: string, mode: 'single' | 'double'): string;
175
+ * @param newText - the file's current content.
176
+ * @param mode - 'single' merges both sides into one reading of the document; 'double' shows the before and
177
+ * after renderings as two whole documents.
178
+ * @param style - 'structure' (default) renders each document once and maps the diff onto its elements;
179
+ * 'blocks' is the original fragment-per-run renderer, kept behind the setting.
180
+ * @param labels - user-facing words the rendered markup must carry (none by default; the tests render bare).
181
+ * @returns sanitized HTML for the preview body. Local images keep their src so a later pass can resolve
182
+ * them to data URIs (see the preview image resolver).
183
+ */
184
+ export declare function renderMarkdownPreview(oldText: string, newText: string, mode: 'single' | 'double', style?: MarkdownPreviewStyle, labels?: {
185
+ readonly invisible?: string;
186
+ }): string;
187
+ /** The tree path's units, adapted to the panel's `PreviewUnit` shape. */
188
+ export declare function treePreviewUnits(oldText: string, newText: string): PreviewUnit[];
189
+ /** The tree path's blocks, adapted to the panel's `PreviewBlock` shape. */
190
+ export declare function treePreviewBlocks(oldText: string, newText: string): PreviewBlock[];
191
+ /** The paragraph-level blocks, adapted to the panel's `PreviewBlock` shape. */
192
+ export declare function flowPreviewBlocks(oldText: string, newText: string): PreviewBlock[];
193
+ /** Render the paragraph-level single column: old above new, tinted rows grouped into hunk blocks. */
194
+ export declare function renderFlowMerged(oldText: string, newText: string): string;
195
+ /**
196
+ * The same flow model, rendered as TWO aligned columns: old left, new right. Every difference block is one grid
197
+ * row whose two cells are the red (old) and green (new) halves of the change, so a matched block's colour reads
198
+ * as one continuous band across the gutter. Unchanged rows are context grid rows (same content on both sides).
199
+ */
200
+ export declare function renderFlowDouble(oldText: string, newText: string): string;
201
+ export {};
@@ -0,0 +1,90 @@
1
+ import { type MarkdownTreeNode } from './markdown-tree-match.ts';
2
+ /** One character-level change: what the preview highlights. Mirrors `PreviewUnit` in markdown-preview.ts. */
3
+ export interface TreePreviewUnit {
4
+ index: number;
5
+ /** Kept for source-view compatibility; the tree path has no line hunks, so this is the block's index. */
6
+ lineBlock: number;
7
+ oldLines: {
8
+ start: number;
9
+ end: number;
10
+ };
11
+ newLines: {
12
+ start: number;
13
+ end: number;
14
+ };
15
+ oldChars: {
16
+ start: number;
17
+ end: number;
18
+ };
19
+ newChars: {
20
+ start: number;
21
+ end: number;
22
+ };
23
+ oldText: string;
24
+ newText: string;
25
+ }
26
+ /** A change the rendering cannot show: mirrors `PreviewInvisibleDiff` in markdown-preview.ts. */
27
+ export interface TreePreviewInvisibleDiff {
28
+ oldLines?: {
29
+ start: number;
30
+ end: number;
31
+ };
32
+ newLines?: {
33
+ start: number;
34
+ end: number;
35
+ };
36
+ oldText?: string;
37
+ newText?: string;
38
+ grammar: 'markdown' | 'yaml';
39
+ }
40
+ /** One difference block: mirrors `PreviewBlock` in markdown-preview.ts, plus the tree nodes it came from. */
41
+ export interface TreePreviewBlock {
42
+ index: number;
43
+ lineBlock: number;
44
+ units: TreePreviewUnit[];
45
+ oldStart: number;
46
+ oldEnd: number;
47
+ newStart: number;
48
+ newEnd: number;
49
+ oldChars?: {
50
+ start: number;
51
+ end: number;
52
+ };
53
+ newChars?: {
54
+ start: number;
55
+ end: number;
56
+ };
57
+ merged: boolean;
58
+ wholeRow?: 'add' | 'del';
59
+ invisible: TreePreviewInvisibleDiff[];
60
+ /** The block's own kind, for the renderer and tests (`paragraph`, `list_item`, `table_row`, `code`, …). */
61
+ kind: string;
62
+ /** A `table_cell` block's column: which cell of its row it covers. */
63
+ cellColumn?: number;
64
+ /**
65
+ * For an INLINE-CHILD block (a text run, a changed link/strong/image inside a paragraph/cell/item): the
66
+ * container pair whose rendered element hosts the change. The block never owns the container element
67
+ * itself, so blocks cannot nest inside each other.
68
+ */
69
+ inlineContainer?: {
70
+ old?: MarkdownTreeNode;
71
+ new?: MarkdownTreeNode;
72
+ };
73
+ /** The old-side nodes this block covers (empty for a pure addition). */
74
+ oldNodes: MarkdownTreeNode[];
75
+ /** The new-side nodes this block covers (empty for a pure deletion). */
76
+ newNodes: MarkdownTreeNode[];
77
+ }
78
+ /** The tree path's whole model: units in reading order, and blocks in document order. */
79
+ export interface TreePreviewModel {
80
+ blocks: TreePreviewBlock[];
81
+ units: TreePreviewUnit[];
82
+ }
83
+ /**
84
+ * Compute the tree path's preview model for one document pair.
85
+ *
86
+ * @param oldText - the baseline.
87
+ * @param newText - the current content.
88
+ * @returns the blocks (document order) and their units, with 1-based line ranges for keep/revert.
89
+ */
90
+ export declare function treePreviewModel(oldText: string, newText: string): TreePreviewModel;