diff --git a/CHANGELOG.md b/CHANGELOG.md index b752911..9f69d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,12 @@ All notable changes to **Rich Markdown Diff** will be documented in this file. - **Quick Edit**: Fixed a bug where the Quick Edit overlay loaded incorrect source text when editing documents that contain frontmatter metadata. - **Obsidian Tags**: Expanded the tag parser to support Japanese, Chinese, and other multi-byte characters. - **Marp Support**: Fixed slide transition animations and restored accurate dark/light theme styling in the webview. -- **Stability & Rendering**: Resolved layout and event-handling bugs, including table scroll wrapping, code block placeholder collisions, and duplicate event listeners. +- **Stability & Rendering**: + - Fixed horizontal scroll clipping of line highlights on code blocks and tables. + - Resolved visual alignment and active highlight issues for embedded code blocks and task list checkboxes. + - Fixed potential memory leaks in clipboard comparisons and child process hangs in Git Blame. +- **Compare Commands**: Fixed a bug where comparing a file with itself opened a blank diff panel. + ## [1.3.1] - 2026-05-24 diff --git a/fixtures/expected/comprehensive.html b/fixtures/expected/comprehensive.html index b0e5e17..fd681be 100644 --- a/fixtures/expected/comprehensive.html +++ b/fixtures/expected/comprehensive.html @@ -25,14 +25,14 @@

Task List

  • Task 2
  • Task 3
  • Code Blocks

    -
    function greet(name) {// Updated comment in v2
    +
    function greet(name) {// Updated comment in v2
       console.log(`Hello, ${name}! Welcome!`);
     }
     
     function farewell(name) {
       console.log(`Goodbye, ${name}!`);
     }
    -

    Tables

    +

    Tables

    @@ -53,8 +53,7 @@

    Code Blocks

    -
    Feature
    -

    Links and Images

    +

    Links and Images

    Visit GitHubVisit VS Code Marketplace

    Blockquotes

    diff --git a/src/extension.ts b/src/extension.ts index f950005..4cfb93f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -935,6 +935,14 @@ async function createAndBindDiffPanel( getDiffPanelOptions(context), ); + context.subscriptions.push(panel); + panel.onDidDispose(() => { + const idx = context.subscriptions.indexOf(panel); + if (idx > -1) { + context.subscriptions.splice(idx, 1); + } + }); + await bindDiffPanel(panel, context, resolveState); return panel; } @@ -1170,10 +1178,12 @@ export function activate(context: vscode.ExtensionContext) { // Helper to track active panel for shortcuts attachPanelTracking(panel); + context.subscriptions.push(panel); + panel.webview.html = webviewContent; // Handle Double Click - panel.webview.onDidReceiveMessage((message) => { + const messageDisposable = panel.webview.onDidReceiveMessage((message) => { if (message.command === "openSource") { const side = message.side; const line = message.line; @@ -1198,6 +1208,14 @@ export function activate(context: vscode.ExtensionContext) { } } }); + + panel.onDidDispose(() => { + messageDisposable.dispose(); + const idx = context.subscriptions.indexOf(panel); + if (idx > -1) { + context.subscriptions.splice(idx, 1); + } + }); }, ); @@ -1373,6 +1391,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.window.showInformationMessage( l10n.t("You are comparing the same file."), ); + return; } void showTwoFilesDiff(selectedForCompareUri, targetUri, context); diff --git a/src/gitBlameResolver.ts b/src/gitBlameResolver.ts index 6425189..f390212 100644 --- a/src/gitBlameResolver.ts +++ b/src/gitBlameResolver.ts @@ -67,6 +67,8 @@ export async function resolveBlameInfo( // \t const child = child_process.spawn("git", ["blame", "--porcelain", fileName], { cwd }); + child.stderr?.resume(); + const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity, diff --git a/src/markdown/renderer.ts b/src/markdown/renderer.ts index 0fef07a..220f581 100644 --- a/src/markdown/renderer.ts +++ b/src/markdown/renderer.ts @@ -99,7 +99,11 @@ function configureRules(md: MarkdownIt) { return `
    \n${escapedContent}\n
    `; } - return defaultFence(tokens, idx, options, env, self); + // Wrap code blocks in a container div so that the ::after gutter marker + // (positioned at left: -16px) is not clipped by
    's overflow-x: auto,
    +    // which creates a scroll container that clips absolutely-positioned children.
    +    const rendered = defaultFence(tokens, idx, options, env, self);
    +    return `
    ${rendered}
    `; }; // Image Resolver Support diff --git a/src/markdown/structuralDiff.ts b/src/markdown/structuralDiff.ts index 58e7865..8cf8e09 100644 --- a/src/markdown/structuralDiff.ts +++ b/src/markdown/structuralDiff.ts @@ -960,6 +960,20 @@ export function replaceBalancedTags( } } + if (options.tokenizeCodeBlocks !== false && html.startsWith('
    ', i)) { + const start = i; + const end = findClosing(html, i, "div"); + if (end > -1) { + const content = html.substring(start, end); + const token = createToken(content, "CODEBLOCK", tokens); + result += token; + i = end; + continue; + } + } + + // Fallback: also tokenize bare
     elements (e.g. from Marp or other renderers
    +    // that do not use the code-block-wrapper div).
         if (options.tokenizeCodeBlocks !== false && html.startsWith(" -1) {
    @@ -1400,57 +1414,89 @@ export function refineBlockDiffs(
     
       // NOTE: We cannot use a simple adjacency regex (del
    del ins
    ins) because
       // other diff elements (e.g. a deleted section) may sit between the del-pre and ins-pre blocks.
    -  // Instead, collect all del-wrapped and ins-wrapped 
     blocks globally, pair them by index,
    +  // Instead, collect all del-wrapped and ins-wrapped code blocks globally, pair them by index,
       // re-diff each pair, and substitute back via placeholder tokens.
    +  //
    +  // Code blocks may appear as:
    +  //   A) 
    ...
    (standard renderer) + // B)
    ...
    (bare, e.g. Marp) { - const delPreRegex = - /]*)>\s*]*)>([\s\S]*?)<\/pre>\s*<\/del>/gi; - const insPreRegex = - /]*)>\s*]*)>([\s\S]*?)<\/pre>\s*<\/ins>/gi; - interface PreBlock { full: string; + wrapperAttrs: string; // div attrs (empty string for bare
    )
           preAttrs: string;
           inner: string;
    +      isWrapped: boolean;   // true → code-block-wrapper present
         }
    -    const delBlocks: PreBlock[] = [];
    -    const insBlocks: PreBlock[] = [];
    +    const delWrappedBlocks: PreBlock[] = [];
    +    const insWrappedBlocks: PreBlock[] = [];
    +    const delBareBlocks: PreBlock[] = [];
    +    const insBareBlocks: PreBlock[] = [];
    +
    +    // Pattern A: del/ins wrapping the code-block-wrapper div
    +    const delWrappedRegex =
    +      /]*)>\s*
    ]*)>\s*]*)>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/del>/gi; + const insWrappedRegex = + /]*)>\s*
    ]*)>\s*]*)>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/ins>/gi; let m: RegExpExecArray | null; + while ((m = delWrappedRegex.exec(resultHtml)) !== null) { + delWrappedBlocks.push({ full: m[0], wrapperAttrs: m[2], preAttrs: m[3], inner: m[4], isWrapped: true }); + } + while ((m = insWrappedRegex.exec(resultHtml)) !== null) { + insWrappedBlocks.push({ full: m[0], wrapperAttrs: m[2], preAttrs: m[3], inner: m[4], isWrapped: true }); + } + + // Pattern B: del/ins wrapping a bare
     (fallback for Marp etc.)
    +    const delPreRegex =
    +      /]*)>\s*]*)>([\s\S]*?)<\/pre>\s*<\/del>/gi;
    +    const insPreRegex =
    +      /]*)>\s*]*)>([\s\S]*?)<\/pre>\s*<\/ins>/gi;
    +
         while ((m = delPreRegex.exec(resultHtml)) !== null) {
    -      delBlocks.push({
    -        full: m[0],
    -        preAttrs: m[2],
    -        inner: m[3],
    -      });
    +      delBareBlocks.push({ full: m[0], wrapperAttrs: "", preAttrs: m[2], inner: m[3], isWrapped: false });
         }
         while ((m = insPreRegex.exec(resultHtml)) !== null) {
    -      insBlocks.push({
    -        full: m[0],
    -        preAttrs: m[2],
    -        inner: m[3],
    +      insBareBlocks.push({ full: m[0], wrapperAttrs: "", preAttrs: m[2], inner: m[3], isWrapped: false });
    +    }
    +
    +    const diffedPairs: Array<{
    +      delFull: string;
    +      insFull: string;
    +      diffed: string;
    +    }> = [];
    +
    +    const pairWrappedCount = Math.min(delWrappedBlocks.length, insWrappedBlocks.length);
    +    for (let i = 0; i < pairWrappedCount; i++) {
    +      const innerDiff = diffCodeBlocks(
    +        delWrappedBlocks[i].inner,
    +        insWrappedBlocks[i].inner,
    +      );
    +      const diffedPre = `${innerDiff}
    `; + const diffed = `
    ${diffedPre}
    `; + diffedPairs.push({ + delFull: delWrappedBlocks[i].full, + insFull: insWrappedBlocks[i].full, + diffed, + }); + } + + const pairBareCount = Math.min(delBareBlocks.length, insBareBlocks.length); + for (let i = 0; i < pairBareCount; i++) { + const innerDiff = diffCodeBlocks( + delBareBlocks[i].inner, + insBareBlocks[i].inner, + ); + const diffedPre = `${innerDiff}
    `; + diffedPairs.push({ + delFull: delBareBlocks[i].full, + insFull: insBareBlocks[i].full, + diffed: diffedPre, }); } - const pairCount = Math.min(delBlocks.length, insBlocks.length); + const pairCount = diffedPairs.length; if (pairCount > 0) { - const diffedPairs: Array<{ - delFull: string; - insFull: string; - diffed: string; - }> = []; - for (let i = 0; i < pairCount; i++) { - // Diff ONLY the inner content of the pre block - const innerDiff = diffCodeBlocks( - delBlocks[i].inner, - insBlocks[i].inner, - ); - diffedPairs.push({ - delFull: delBlocks[i].full, - insFull: insBlocks[i].full, - diffed: `${innerDiff}
    `, - }); - } const uniqueRunId = Math.random().toString(36).slice(2, 10); for (let i = 0; i < diffedPairs.length; i++) { @@ -1771,10 +1817,15 @@ export function normalizeMathBlockDiffs(html: string): string { } export function cleanupCheckboxArtifacts(html: string): string { - return html.replace( - /(]+class="task-list-item-checkbox"[^>]*>)(\s*)(?=(?:]*>\s*\[))/gi, + let result = html.replace( + /(]+class="task-list-item-checkbox"[^>]*>)(\s*)(?=(?:]*>\s*\[))/gi, '$1$2', ); + result = result.replace( + /(]+class="task-list-item-checkbox"[^>]*>)(\s*)(?=(?:]*>\s*\[))/gi, + '$1$2', + ); + return result; } export function stripDataLineAttributes(html: string): string { @@ -2213,42 +2264,38 @@ export function verifyDiffIntegrity( return true; // Nothing to check } - const diffWordsSet = new Set(getWords(diffText)); + const diffWordsMap = new Map(); + for (const word of getWords(diffText)) { + diffWordsMap.set(word, (diffWordsMap.get(word) || 0) + 1); + } - // Sample a subset of words to check to avoid performance issues on huge documents - // but ensure we check enough to detect truncation. - const sampleSize = Math.min(newWords.length, 200); - const step = Math.max(1, Math.floor(newWords.length / sampleSize)); + const newWordsMap = new Map(); + for (const word of newWords) { + newWordsMap.set(word, (newWordsMap.get(word) || 0) + 1); + } let missingCount = 0; - let checkedCount = 0; - for (let i = 0; i < newWords.length; i += step) { - const word = newWords[i]; - checkedCount++; - if (!diffWordsSet.has(word)) { - missingCount++; + let totalChecked = 0; + const missingWords: string[] = []; + + for (const [word, newCount] of newWordsMap.entries()) { + const diffCount = diffWordsMap.get(word) || 0; + if (diffCount < newCount) { + missingCount += (newCount - diffCount); + if (missingWords.length < 10) { + missingWords.push(word); + } } + totalChecked += newCount; } - // Allow a very small margin of error (e.g. 1.0%) for edge cases where htmldiff - // might legitimately combine or slightly transform words (e.g. case changes, punctuation). - const failureThreshold = 0.01; // 1.0% margin of error - const missingRatio = checkedCount > 0 ? missingCount / checkedCount : 0; + const failureThreshold = 0.02; // 2.0% margin of error + const missingRatio = totalChecked > 0 ? missingCount / totalChecked : 0; const isBroken = missingRatio > failureThreshold; if (isBroken) { - const missingWords = []; - for (let i = 0; i < newWords.length; i += step) { - const word = newWords[i]; - if (!diffWordsSet.has(word)) { - missingWords.push(word); - if (missingWords.length >= 10) { - break; - } - } - } console.warn( - `Integrity check failed: missing ${missingCount}/${checkedCount} words (${(missingRatio * 100).toFixed(1)}%). Missing: ${missingWords.join(", ")}`, + `Integrity check failed: missing ${missingCount}/${totalChecked} words (${(missingRatio * 100).toFixed(1)}%). Missing: ${missingWords.join(", ")}`, ); return false; } diff --git a/src/markdown/webviewTemplate.ts b/src/markdown/webviewTemplate.ts index ee5113c..c4b97f7 100644 --- a/src/markdown/webviewTemplate.ts +++ b/src/markdown/webviewTemplate.ts @@ -450,6 +450,8 @@ export function getWebviewContent( dl, blockquote, pre, + .code-block-wrapper, + .table-block-wrapper, .table-scroll, hr, .markdown-alert, @@ -512,6 +514,17 @@ export function getWebviewContent( padding: 0; border-radius: 0; } + /* Wrapper around
     code blocks to avoid the gutter marker being clipped
    +           by pre's overflow-x: auto scroll container. The wrapper stays overflow:
    +           visible so the ::after gutter bar can appear in the left gutter padding. */
    +        .code-block-wrapper {
    +            position: relative;
    +            overflow: visible;
    +            margin-bottom: var(--markdown-block-spacing);
    +        }
    +        .code-block-wrapper pre {
    +            margin-bottom: 0;
    +        }
             pre {
                 background-color: var(--vscode-textCodeBlock-background, var(--markdown-raised-background));
                 padding: 8px 10px;
    @@ -690,6 +703,11 @@ export function getWebviewContent(
             p > img:only-child {
               display: block;
             }
    +        .table-block-wrapper {
    +            position: relative;
    +            overflow: visible;
    +            margin-bottom: var(--markdown-block-spacing);
    +        }
             .table-scroll {
               width: 100%;
               max-width: 100%;
    @@ -834,6 +852,7 @@ export function getWebviewContent(
             ins:has(.footnote-item), del:has(.footnote-item),
             ins:has(li), del:has(li),
             ins:has(pre), del:has(pre),
    +        ins:has(.code-block-wrapper), del:has(.code-block-wrapper),
             ins:has(table), del:has(table),
             ins:has(h1), del:has(h1),
             ins:has(h2), del:has(h2),
    @@ -862,7 +881,7 @@ export function getWebviewContent(
     
     
             /* Container Borders for Block Diffs (Alerts, Code, Mermaid) */
    -        :is(.markdown-alert, .mermaid, pre):is(:has(ins), :has(del), :parent(ins), :parent(del), .diffins, .diffdel) {
    +        :is(.markdown-alert, .mermaid, pre, .code-block-wrapper):is(:has(ins), :has(del), :parent(ins), :parent(del), .diffins, .diffdel) {
                 position: relative;
             }
     
    @@ -955,8 +974,8 @@ export function getWebviewContent(
     
     
     
    -        ins:has(> h1, > h2, > h3, > h4, > h5, > h6, > p, > img, > table, > .table-scroll, > ul, > ol, > dl, > li, > blockquote, > div, > pre, > hr, > section, > details, > summary, > figure),
    -        del:has(> h1, > h2, > h3, > h4, > h5, > h6, > p, > img, > table, > .table-scroll, > ul, > ol, > dl, > li, > blockquote, > div, > pre, > hr, > section, > details, > summary, > figure) {
    +        ins:has(> h1, > h2, > h3, > h4, > h5, > h6, > p, > img, > table, > .table-block-wrapper, > .table-scroll, > ul, > ol, > dl, > li, > blockquote, > div, > pre, > hr, > section, > details, > summary, > figure),
    +        del:has(> h1, > h2, > h3, > h4, > h5, > h6, > p, > img, > table, > .table-block-wrapper, > .table-scroll, > ul, > ol, > dl, > li, > blockquote, > div, > pre, > hr, > section, > details, > summary, > figure) {
                 display: block;
               width: fit-content;
               max-width: 100%;
    @@ -1344,14 +1363,14 @@ export function getWebviewContent(
             }
     
             /* Specific High Visibility for Complex Blocks (Code/Mermaid/Math) */
    -        pre.selected-change,
    +        :is(pre, .code-block-wrapper).selected-change,
             .mermaid.selected-change, 
             .katex-block.selected-change {
                 overflow: visible !important;
                 display: block; 
             }
     
    -        pre.selected-change.selected-ins,
    +        :is(pre, .code-block-wrapper).selected-change.selected-ins,
             .mermaid.selected-change.selected-ins,
             .katex-block.selected-change.selected-ins {
               background-color: rgba(34, 197, 94, 0.25) !important;
    @@ -1359,7 +1378,7 @@ export function getWebviewContent(
               box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.8) !important;
             }
     
    -        pre.selected-change.selected-del,
    +        :is(pre, .code-block-wrapper).selected-change.selected-del,
             .mermaid.selected-change.selected-del,
             .katex-block.selected-change.selected-del {
               background-color: rgba(248, 113, 113, 0.2) !important;
    @@ -1367,7 +1386,7 @@ export function getWebviewContent(
               box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.8) !important;
             }
     
    -        pre.selected-change.selected-mod,
    +        :is(pre, .code-block-wrapper).selected-change.selected-mod,
             .mermaid.selected-change.selected-mod,
             .katex-block.selected-change.selected-mod {
               background-color: rgba(59, 130, 246, 0.08) !important;
    @@ -1375,6 +1394,10 @@ export function getWebviewContent(
               box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3) !important;
             }
     
    +        .code-block-wrapper.selected-change pre {
    +            background-color: transparent !important;
    +        }
    +
             /* Image Focus Style (same as Mermaid) */
             .selected-change img {
               border: 1px solid rgba(255, 165, 0, 0.9) !important;
    @@ -2730,13 +2753,13 @@ export function getWebviewContent(
                     const getComplexContainer = (el) => {
                       // Case 1: The change is inside a complex visual block like Mermaid or KaTeX.
                       // Table and stable code-block diffs stay granular so the ruler and selection can stay local.
    -                  const ancestor = el.closest('.mermaid') || el.closest('.katex-block') || el.closest('svg');
    +                  const ancestor = el.closest('.mermaid') || el.closest('.katex-block') || el.closest('svg') || el.closest('.code-block-wrapper');
                         if (ancestor) return ancestor;
     
                       // Case 2: The change wraps a complex visual block (e.g. a new code block or diagram added)
                       // We prefer the child container for highlighting as it's the visual element.
                         if (el.querySelector) {
    -                     let child = el.querySelector('pre') || el.querySelector('.mermaid') || el.querySelector('.katex-block');
    +                     let child = el.querySelector('.code-block-wrapper') || el.querySelector('pre') || el.querySelector('.mermaid') || el.querySelector('.katex-block');
                              if (!child) {
                                  // Only treat SVG as complex if it's NOT an alert icon
                                  const svg = el.querySelector('svg');
    diff --git a/src/markdownDiff.ts b/src/markdownDiff.ts
    index f499f05..47b3f93 100644
    --- a/src/markdownDiff.ts
    +++ b/src/markdownDiff.ts
    @@ -55,18 +55,18 @@ function wrapTablesForScrolling(html: string): string {
       return html.replace(
         tableRegex,
         (match, tagType, tagAttrs, offset, fullString) => {
    -      // Check if this match is already wrapped in a div.table-scroll
    +      // Check if this match is already wrapped in a div.table-scroll/table-block-wrapper
           const preceding = fullString.slice(0, offset).trim();
           const following = fullString.slice(offset + match.length).trim();
     
    -      const isPrecededByScrollDiv = /]*\bclass=["'][^"']*\btable-scroll\b[^"']*["'][^>]*>\s*$/i.test(preceding);
    -      const isFollowedByCloseDiv = /^\s*<\/div>/i.test(following);
    +      const isPrecededByScrollDiv = /]*\bclass=["'][^"']*\b(table-scroll|table-block-wrapper)\b[^"']*["'][^>]*>\s*(?:]*\bclass=["'][^"']*\btable-scroll\b[^"']*["'][^>]*>\s*)?$/i.test(preceding);
    +      const isFollowedByCloseDiv = /^\s*<\/div>\s*(?:<\/div>)?/i.test(following);
     
           if (isPrecededByScrollDiv && isFollowedByCloseDiv) {
             return match;
           }
     
    -      return `
    ${match}
    `; + return `
    ${match}
    `; } ); } diff --git a/src/test/runTest.ts b/src/test/runTest.ts index 00723f8..c6c3369 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -66,12 +66,14 @@ async function main() { "index", ); const runtimeRoot = path.join( - os.tmpdir(), - `rich-markdown-diff-host-${Date.now()}`, + extensionDevelopmentPath, + ".vscode-test", + "user-data-dir", ); const userDataDir = path.join(runtimeRoot, "user-data"); const extensionsDir = path.join(runtimeRoot, "extensions"); + await fs.rm(runtimeRoot, { recursive: true, force: true }); await fs.mkdir(userDataDir, { recursive: true }); await fs.mkdir(extensionsDir, { recursive: true }); diff --git a/src/test/unit/edgeCases.test.ts b/src/test/unit/edgeCases.test.ts index 7a99899..2b7c0d1 100644 --- a/src/test/unit/edgeCases.test.ts +++ b/src/test/unit/edgeCases.test.ts @@ -31,7 +31,12 @@ describe("MarkdownDiffProvider - Edge Cases", () => { // It should be wrapped in a single del with diff-block class assert.ok(diff.includes("diff-block"), "Should have diff-block class for code block deletion"); assert.ok(diff.includes("]*class="[^"]*diff-block[^"]*"[^>]*>\s*
    ]*class="[^"]*diff-block[^"]*"[^>]*>\s*
    ]*>\s*
    ]*class="[^"]*diff-block[^"]*"[^>]*>\s*
     {
    @@ -147,7 +152,7 @@ describe("MarkdownDiffProvider - Edge Cases", () => {
         // 2. Table inside del should wrap the del container, not the table inside it
         const delInput = `${table}`;
         const delOutput = provider.getWebviewContent(delInput, "", "", "", "");
    -    assert.ok(delOutput.includes(`
    ${table}
    `), "Should wrap outer del rather than nesting block-in-inline"); + assert.ok(delOutput.includes(`
    ${table}
    `), "Should wrap outer del rather than nesting block-in-inline"); }); it("BUG-03: should not double-wrap tables even if table-scroll div has attributes and trailing whitespace/newlines", () => { diff --git a/src/test/unit/markdownDiff.test.ts b/src/test/unit/markdownDiff.test.ts index abf63e3..3066a6c 100644 --- a/src/test/unit/markdownDiff.test.ts +++ b/src/test/unit/markdownDiff.test.ts @@ -852,7 +852,7 @@ describe("MarkdownDiffProvider", () => { ); assert.ok( webviewContent.includes( - "const ancestor = el.closest('.mermaid') || el.closest('.katex-block') || el.closest('svg');", + "const ancestor = el.closest('.mermaid') || el.closest('.katex-block') || el.closest('svg') || el.closest('.code-block-wrapper');", ), "Complex visual blocks like Mermaid and KaTeX should still be promoted to their rendered container while stable code blocks stay granular", ); @@ -992,15 +992,15 @@ describe("MarkdownDiffProvider", () => { ); assert.ok( webviewContent.includes( - "const ancestor = el.closest('.mermaid') || el.closest('.katex-block') || el.closest('svg');", + "const ancestor = el.closest('.mermaid') || el.closest('.katex-block') || el.closest('svg') || el.closest('.code-block-wrapper');", ), - "Stable code blocks with inner line diffs should stay granular instead of being promoted through closest('pre')", + "Stable code blocks with inner line diffs should be promoted to .code-block-wrapper as a stable anchor", ); assert.ok( webviewContent.includes( - "let child = el.querySelector('pre') || el.querySelector('.mermaid') || el.querySelector('.katex-block');", + "let child = el.querySelector('.code-block-wrapper') || el.querySelector('pre') || el.querySelector('.mermaid') || el.querySelector('.katex-block');", ), - "Wrapped code-block insertions should resolve to the pre container for active highlighting", + "Wrapped code-block insertions should resolve to the .code-block-wrapper container for active highlighting", ); assert.ok( webviewContent.includes(".selected-change.selected-ins {") && @@ -1010,7 +1010,7 @@ describe("MarkdownDiffProvider", () => { "Inserted active changes should use green selection styling instead of the old yellow overlay", ); assert.ok( - webviewContent.includes("pre.selected-change.selected-ins,") && + webviewContent.includes(":is(pre, .code-block-wrapper).selected-change.selected-ins,") && webviewContent.includes("border: 1px solid #22c55e !important;") && webviewContent.includes( "box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.8) !important;", @@ -1052,10 +1052,10 @@ describe("MarkdownDiffProvider", () => { "heading-prefix class should prevent number prefixes from wrapping", ); assert.ok( - /ins:has\(> h1, > h2, > h3, > h4, > h5, > h6, > p, > img, > table, > \.table-scroll, > ul, > ol, > dl, > li, > blockquote, > div, > pre, > hr, > section, > details, > summary, > figure\),[\s\S]*del:has\(> h1, > h2, > h3, > h4, > h5, > h6, > p, > img, > table, > \.table-scroll, > ul, > ol, > dl, > li, > blockquote, > div, > pre, > hr, > section, > details, > summary, > figure\) \{[\s\S]*display: block;[\s\S]*width: fit-content;/m.test( + /ins:has\(> h1, > h2, > h3, > h4, > h5, > h6, > p, > img, > table, > \.table-block-wrapper, > \.table-scroll, > ul, > ol, > dl, > li, > blockquote, > div, > pre, > hr, > section, > details, > summary, > figure\),[\s\S]*del:has\(> h1, > h2, > h3, > h4, > h5, > h6, > p, > img, > table, > \.table-block-wrapper, > \.table-scroll, > ul, > ol, > dl, > li, > blockquote, > div, > pre, > hr, > section, > details, > summary, > figure\) \{[\s\S]*display: block;[\s\S]*width: fit-content;/m.test( webviewContent, ), - "Modified headings should be set as block elements so the background color spans the full pane width", + "Modified headings should be set as block elements so the background color spans the full width", ); }); @@ -1175,7 +1175,7 @@ describe("MarkdownDiffProvider", () => { ); assert.ok( - /
    \s*/m.test(webviewContent), + /
    \s*
    /m.test(webviewContent), "Tables should be wrapped in a dedicated scroll container so wide tables scroll locally instead of stretching the pane", ); assert.ok( diff --git a/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-v2-split-light-chromium-linux.png b/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-v2-split-light-chromium-linux.png index ad929a6..fd09c34 100644 Binary files a/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-v2-split-light-chromium-linux.png and b/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-v2-split-light-chromium-linux.png differ diff --git a/src/test/visual/__screenshots__/vrt.test.js-snapshots/marp-v1-v2-split-dark-chromium-linux.png b/src/test/visual/__screenshots__/vrt.test.js-snapshots/marp-v1-v2-split-dark-chromium-linux.png index 6c104ac..b5fe25c 100644 Binary files a/src/test/visual/__screenshots__/vrt.test.js-snapshots/marp-v1-v2-split-dark-chromium-linux.png and b/src/test/visual/__screenshots__/vrt.test.js-snapshots/marp-v1-v2-split-dark-chromium-linux.png differ