diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3b885..6ed439d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,11 @@ All notable changes to **Rich Markdown Diff** will be documented in this file. -## [1.5.0] - 2026-08-DD +## [1.5.0] - 2026-08-11 ### Added in 1.5.0 - **Visual Conflict Resolver**: Interactive 3-way merge editor for resolving Git merge conflicts (`<<<<<<<`, `=======`, `>>>>>>>`) directly in rendered Markdown. -- **Semantic Mermaid Diff**: Element-level diff highlighting for Mermaid flowcharts with dynamic color-coding and ghost outlines. - **HTML Export**: Export rendered side-by-side or inline diffs to standalone HTML files via the `Export Rendered Diff to HTML` command. - **Custom Diff Colors & View Preferences**: Added settings for custom insertion/deletion highlight colors and default view/folding modes. - **Committed Revision Diffs**: Render visual diffs for committed revisions from Git history and the editor context menu. diff --git a/README.md b/README.md index 2e12ece..5ee187f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ A professional VS Code extension for visual Markdown comparison. Compare rendere - **Visual Diff**: Compare rendered HTML side-by-side or inline instead of raw source code. - ⚔️ **Visual Conflict Resolver**: Resolve Git merge conflicts (`<<<<<<<`, `|||||||`, `=======`, `>>>>>>>`) in rendered Markdown with an interactive 3-Way Merge View (`Accept Mine`, `Accept Theirs`, `Accept Both`). -- 📊 **Semantic Mermaid Diff**: Element-level diff highlighting for Mermaid flowcharts. Added, deleted, and modified nodes and edges are dynamically color-coded with ghost outlines for deleted elements. - **Git Integration**: Compare changes directly from the VS Code Source Control view (Workspace, Staged, or HEAD). - **Git Blame**: Hover over any line in the diff view to instantly see its commit author and history. - **Interactive Image Diff**: Compare visual changes in images using "Swipe" and "Onion Skin" comparison modes. @@ -23,7 +22,7 @@ A professional VS Code extension for visual Markdown comparison. Compare rendere | **MDX / Custom** | Native diffing for MDX, Docusaurus, and Astro Starlight components (Tabs, Steps, Badges, Cards, and Admonitions). | | **Marp** | Render and diff slide decks with full theme support. | | **Math** | High-quality KaTeX rendering for formulas. | -| **Mermaid** | Flowcharts with element-level diff highlighting, plus sequence diagrams and Gantt charts with VS Code theme-aware rendering. | +| **Mermaid** | Flowcharts, sequence diagrams, and Gantt charts with VS Code theme-aware rendering. | | **Obsidian** | Native support for Tags (`#tag`) and Transclusions (`![[link]]`). | | **Alerts** | GitHub-style `[!NOTE]`, `[!WARNING]`, etc. | | **Structure** | Robust diffing for Tables, Nested Lists, Footnotes, and block-level math/code changes. | @@ -66,8 +65,8 @@ The following default shortcuts are available when the visual diff panel is acti | Shortcut (Mac) | Shortcut (Windows/Linux) | Command | Description | | --- | --- | --- | --- | | `F7` / `Shift+F7` | `F7` / `Shift+F7` | Next / Previous Change | Jump to the next or previous diff highlight. | -| `Alt+F7` | `Alt+F7` | Toggle Inline View | Toggle between Side-by-Side and Inline diff layouts. | -| `Alt+F8` | `Alt+F8` | Toggle Fold Unchanged | Fold or unfold unchanged Markdown blocks. | +| `Option+F7` | `Alt+F7` | Toggle Inline View | Toggle between Side-by-Side and Inline diff layouts. | +| `Option+F8` | `Alt+F8` | Toggle Fold Unchanged | Fold or unfold unchanged Markdown blocks. | | `Cmd+Shift+C` | `Ctrl+Shift+C` | Compare with Clipboard | Compare active Markdown document with clipboard content. | ## Technical Details diff --git a/fixtures/expected/comprehensive.html b/fixtures/expected/comprehensive.html index e960560..e4f76b2 100644 --- a/fixtures/expected/comprehensive.html +++ b/fixtures/expected/comprehensive.html @@ -26,7 +26,8 @@

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!`);
     }
     
    @@ -86,7 +87,17 @@ 

    Math (KaTeX)

    M834 80h400000v40h-400000z">

    New equation added:

    i=1ni=n(n+1)2\sum_{i=1}^{n} i = \frac{n(n+1)}{2}

    Mermaid Diagrams

    -
    +graph TD; + A-->B; + B-->C; + C-->D; + +
    Math (KaTeX) E --> H[End] F --> H G --> H -">graph TD +"> +graph TD A[Start] --> B{Decision} B -- Yes --> C[Process One] B -- No --> D[Process Two] @@ -107,32 +119,7 @@

    Math (KaTeX)

    F --> H G --> H -%% Ghost definitions for deleted elements - A--["A--"] - B--["B--"] - C--["C--"] - C -.-> D - -%% Dynamic Diff Styles - style A fill:#e6ffec,stroke:#22863a,stroke-width:2px; - style E fill:#e6ffec,stroke:#22863a,stroke-width:2px; - style F fill:#e6ffec,stroke:#22863a,stroke-width:2px; - style G fill:#e6ffec,stroke:#22863a,stroke-width:2px; - style H fill:#e6ffec,stroke:#22863a,stroke-width:2px; - style A-- fill:#ffeef0,stroke:#d73a49,stroke-width:1px,stroke-dasharray:5 5,opacity:0.75; - style B-- fill:#ffeef0,stroke:#d73a49,stroke-width:1px,stroke-dasharray:5 5,opacity:0.75; - style C-- fill:#ffeef0,stroke:#d73a49,stroke-width:1px,stroke-dasharray:5 5,opacity:0.75; - style B fill:#fffdef,stroke:#b08800,stroke-width:2px; - style C fill:#fffdef,stroke:#b08800,stroke-width:2px; - style D fill:#fffdef,stroke:#b08800,stroke-width:2px; - linkStyle 2 stroke:#22863a,stroke-width:2px; - linkStyle 3 stroke:#22863a,stroke-width:2px; - linkStyle 4 stroke:#22863a,stroke-width:2px; - linkStyle 5 stroke:#22863a,stroke-width:2px; - linkStyle 6 stroke:#22863a,stroke-width:2px; - linkStyle 7 stroke:#22863a,stroke-width:2px; - linkStyle 8 stroke:#22863a,stroke-width:2px; - linkStyle 9 stroke:#d73a49,stroke-width:1px,stroke-dasharray:3 3,opacity:0.75;

    GitHub Alerts

    +

    GitHub Alerts

    Note

    This is a note alert with updated content.

    Warning

    This is a warning alert.

    diff --git a/src/conflictEditorProvider.ts b/src/conflictEditorProvider.ts index cbb4ffb..f990255 100644 --- a/src/conflictEditorProvider.ts +++ b/src/conflictEditorProvider.ts @@ -6,9 +6,13 @@ import * as crypto from "crypto"; import * as vscode from "vscode"; -import { parseConflictBlocks, reconstructDocument, DocBlock } from "./markdown/conflictParser"; -import { MarkdownDiffProvider } from "./markdownDiff"; -import { escapeHtml } from "./markdown/sanitizer"; +import { + parseConflictBlocks, + reconstructDocument, + DocBlock, + getConflictResolverShellHtml, + renderConflictBlocks, +} from "./markdown/conflictParser"; export class ConflictEditorProvider implements vscode.CustomTextEditorProvider { public static readonly viewType = "rich-markdown-diff.conflictResolver"; @@ -113,183 +117,15 @@ export class ConflictEditorProvider implements vscode.CustomTextEditorProvider { * Returns the static shell HTML: styles + script only, no content. * This is set on the webview exactly once and never replaced afterwards. */ - private getShellHtml(nonce: string): string { - return ` - - - - - - - Visual Conflict Resolver - - - -
    - - - - `; + public getShellHtml(nonce: string): string { + return getConflictResolverShellHtml(nonce); } /** * Renders blocks to an HTML string for injection into #content via postMessage. * Does NOT include // wrappers. */ - private renderBlocksContent(blocks: DocBlock[]): string { - const diffProvider = new MarkdownDiffProvider(); - let bodyContent = ""; - - for (const block of blocks) { - if (block.type === "common") { - const { html } = diffProvider.computeDiff(block.text, block.text); - bodyContent += `
    ${html}
    `; - } else { - const safeMineLabel = escapeHtml(block.mineLabel || "Current (Mine)"); - const safeTheirsLabel = escapeHtml(block.theirsLabel || "Incoming (Theirs)"); - const safeBlockId = escapeHtml(block.id); - - // Compute Mine vs Theirs diff to show highlighted changes - const { html: mineVsTheirsHtml } = diffProvider.computeDiff(block.mine, block.theirs); - const { html: mineHtml } = diffProvider.computeDiff(block.mine, block.mine); - const { html: theirsHtml } = diffProvider.computeDiff(block.theirs, block.theirs); - - bodyContent += ` -
    -
    - Conflict (${safeMineLabel} vs ${safeTheirsLabel}) -
    - - - -
    -
    -
    -
    Diff Preview (Mine → Theirs)
    -
    ${mineVsTheirsHtml}
    -
    -
    -
    -
    Mine (${safeMineLabel})
    -
    ${mineHtml}
    -
    -
    -
    Theirs (${safeTheirsLabel})
    -
    ${theirsHtml}
    -
    -
    -
    - `; - } - } - - return bodyContent; + public renderBlocksContent(blocks: DocBlock[]): string { + return renderConflictBlocks(blocks); } } diff --git a/src/exportHtml.ts b/src/exportHtml.ts new file mode 100644 index 0000000..c4e1eb4 --- /dev/null +++ b/src/exportHtml.ts @@ -0,0 +1,222 @@ +import * as fs from "fs"; +import * as path from "path"; + +/** + * Maps common file extensions to their corresponding MIME types. + */ +const MIME_TYPES: Record = { + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".ico": "image/x-icon", + ".avif": "image/avif", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".eot": "application/vnd.ms-fontobject", +}; + +/** + * Extracts a local filesystem path from a VS Code Webview URI or file URI string. + * + * Supported formats: + * - https://file%2B.vscode-resource.vscode-cdn.net/... + * - https://file+.vscode-resource.vscode-cdn.net/... + * - vscode-resource://... + * - vscode-file://vscode-app/... + * - file://... + */ +export function extractLocalFsPath(uriStr: string): string | undefined { + if (!uriStr) { + return undefined; + } + + let decoded = uriStr.trim(); + + // Strip query and hash if present + const queryIndex = decoded.search(/[?#]/); + if (queryIndex !== -1) { + decoded = decoded.slice(0, queryIndex); + } + + const vscodeResourceCdnMatch = decoded.match(/^https:\/\/(?:file%2B|file\+)\.vscode-resource\.vscode-cdn\.net\/(.*)$/i); + if (vscodeResourceCdnMatch) { + let rawPath = decodeURIComponent(vscodeResourceCdnMatch[1]); + // If Windows drive letter without leading slash: e.g. "c:/path/to" or "/c:/path/to" + if (/^\/?[a-zA-Z]:[\\/]/.test(rawPath)) { + rawPath = rawPath.replace(/^\//, ""); + } else if (!rawPath.startsWith("/")) { + rawPath = "/" + rawPath; + } + return rawPath; + } + + const vscodeFileMatch = decoded.match(/^vscode-file:\/\/vscode-app\/(.*)$/i); + if (vscodeFileMatch) { + let rawPath = decodeURIComponent(vscodeFileMatch[1]); + if (/^\/?[a-zA-Z]:[\\/]/.test(rawPath)) { + rawPath = rawPath.replace(/^\//, ""); + } else if (!rawPath.startsWith("/")) { + rawPath = "/" + rawPath; + } + return rawPath; + } + + const vscodeResourceMatch = decoded.match(/^vscode-resource:(?:\/\/file)?\/(.*)$/i); + if (vscodeResourceMatch) { + let rawPath = decodeURIComponent(vscodeResourceMatch[1]); + if (/^\/?[a-zA-Z]:[\\/]/.test(rawPath)) { + rawPath = rawPath.replace(/^\//, ""); + } else if (!rawPath.startsWith("/")) { + rawPath = "/" + rawPath; + } + return rawPath; + } + + const fileMatch = decoded.match(/^file:\/\/(.*)$/i); + if (fileMatch) { + let rawPath = decodeURIComponent(fileMatch[1]); + // file:///c:/... or file:///Users/... + if (/^\/[a-zA-Z]:[\\/]/.test(rawPath)) { + rawPath = rawPath.slice(1); + } + return rawPath; + } + + // Already a local absolute path + if (path.isAbsolute(decoded)) { + return decoded; + } + + return undefined; +} + +/** + * Returns MIME type based on file extension. + */ +export function getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + return MIME_TYPES[ext] || "application/octet-stream"; +} + +/** + * Default file reader using node fs promises. + */ +async function defaultReadFile(fsPath: string): Promise { + return await fs.promises.readFile(fsPath); +} + +export interface PrepareExportHtmlOptions { + readFile?: (fsPath: string) => Promise; + mermaidCdn?: string; + hljsLightCdn?: string; + hljsDarkCdn?: string; + katexFontCdnBase?: string; +} + +/** + * Processes VS Code webview HTML to produce a self-contained, browser-compatible HTML document. + * Inlines local images as Base64 Data URIs and replaces local scripts/styles with CDN equivalents. + */ +export async function prepareExportHtml( + rawHtml: string, + options: PrepareExportHtmlOptions = {}, +): Promise { + const readFile = options.readFile || defaultReadFile; + const mermaidCdn = + options.mermaidCdn || + "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"; + const hljsLightCdn = + options.hljsLightCdn || + "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css"; + const hljsDarkCdn = + options.hljsDarkCdn || + "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css"; + const katexFontCdnBase = + options.katexFontCdnBase || + "https://cdn.jsdelivr.net/npm/katex@0.10.2/dist/fonts"; + + let html = rawHtml; + + // 1. Replace Highlight.js and Mermaid CDN scripts/styles + html = html + .replace( + /href="[^"]*github\.min\.css[^"]*"/i, + `href="${hljsLightCdn}"`, + ) + .replace( + /href="[^"]*github-dark\.min\.css[^"]*"/i, + `href="${hljsDarkCdn}"`, + ) + .replace( + /src="[^"]*mermaid\.min\.js[^"]*"/i, + `src="${mermaidCdn}"`, + ); + + // 2. Adjust Content-Security-Policy for standalone browser execution + // Replace strict webview CSP with a safe standalone CSP allowing CDN and data: URIs + const standaloneCsp = ``; + html = html.replace( + /]*>/i, + standaloneCsp, + ); + + // 3. Find and replace all local image URLs and asset URLs with Base64 Data URIs + // Targets: + // - + // - CSS url(...) inside style tags + const uriRegex = /(?:src=["']([^"']+)["']|url\((['"]?)([^'")]+)\2\))/gi; + const matches: string[] = []; + let m: RegExpExecArray | null; + + while ((m = uriRegex.exec(html)) !== null) { + const candidateUri = m[1] || m[3]; + if (candidateUri && !candidateUri.startsWith("data:")) { + matches.push(candidateUri); + } + } + + // Deduplicate URIs to process + const uniqueUris = Array.from(new Set(matches)); + const uriToDataMap = new Map(); + + for (const uri of uniqueUris) { + // Check if this is a KaTeX font file that can be replaced by KaTeX CDN or inlined + if (uri.includes("/media/katex/fonts/")) { + const fontFilename = path.basename(uri.split("?")[0]); + if (fontFilename) { + // Replace with CDN font URL + uriToDataMap.set(uri, `${katexFontCdnBase}/${fontFilename}`); + continue; + } + } + + const fsPath = extractLocalFsPath(uri); + if (!fsPath) { + continue; + } + + try { + const fileBytes = await readFile(fsPath); + const mime = getMimeType(fsPath); + const base64 = Buffer.from(fileBytes).toString("base64"); + const dataUri = `data:${mime};base64,${base64}`; + uriToDataMap.set(uri, dataUri); + } catch (e) { + console.warn(`[rich-markdown-diff] Failed to inline asset for export: ${fsPath}`, e); + } + } + + // Replace URIs in HTML + for (const [origUri, replacement] of uriToDataMap.entries()) { + // Escape for literal replace + html = html.split(origUri).join(replacement); + } + + return html; +} diff --git a/src/extension.ts b/src/extension.ts index 3bc4f5a..d73f51f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -45,6 +45,7 @@ import { } from "./commandTarget"; import * as path from "path"; import * as l10n from "@vscode/l10n"; +import { prepareExportHtml } from "./exportHtml"; /** * Escapes HTML special characters to prevent XSS in webview content. @@ -1177,25 +1178,12 @@ export function activate(context: vscode.ExtensionContext) { } try { - let htmlContent = activePanel.webview.html; - - const mermaidCdn = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"; - const hljsLightCdn = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css"; - const hljsDarkCdn = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css"; - - htmlContent = htmlContent - .replace( - /href="[^"]*github\.min\.css[^"]*"/i, - `href="${hljsLightCdn}"` - ) - .replace( - /href="[^"]*github-dark\.min\.css[^"]*"/i, - `href="${hljsDarkCdn}"` - ) - .replace( - /src="[^"]*mermaid\.min\.js[^"]*"/i, - `src="${mermaidCdn}"` - ); + const rawHtml = activePanel.webview.html; + const htmlContent = await prepareExportHtml(rawHtml, { + readFile: async (fsPath: string) => { + return await vscode.workspace.fs.readFile(vscode.Uri.file(fsPath)); + }, + }); await vscode.workspace.fs.writeFile( saveUri, diff --git a/src/markdown/conflictParser.ts b/src/markdown/conflictParser.ts index c436dfb..9633b13 100644 --- a/src/markdown/conflictParser.ts +++ b/src/markdown/conflictParser.ts @@ -4,6 +4,9 @@ * Copyright (c) 2026 Rich Markdown Diff Authors */ +import { MarkdownDiffProvider } from "../markdownDiff"; +import { escapeHtml } from "./sanitizer"; + export interface CommonBlock { type: "common"; text: string; @@ -24,6 +27,205 @@ export interface ConflictBlock { export type DocBlock = CommonBlock | ConflictBlock; +/** + * Returns the static shell HTML: styles + script only, no content. + * This is set on the webview exactly once and never replaced afterwards. + */ +export function getConflictResolverShellHtml(nonce: string): string { + return ` + + + + + + + Visual Conflict Resolver + + + +
    + + + + `; +} + +/** + * Renders blocks to an HTML string for injection into #content via postMessage. + * Does NOT include // wrappers. + */ +export function renderConflictBlocks(blocks: DocBlock[]): string { + const diffProvider = new MarkdownDiffProvider(); + let bodyContent = ""; + + for (const block of blocks) { + if (block.type === "common") { + const { html } = diffProvider.computeDiff(block.text, block.text); + bodyContent += `
    ${html}
    `; + } else { + const safeMineLabel = escapeHtml(block.mineLabel || "Current (Mine)"); + const safeTheirsLabel = escapeHtml(block.theirsLabel || "Incoming (Theirs)"); + const safeBlockId = escapeHtml(block.id); + + // Compute Mine vs Theirs diff to show highlighted changes + const { html: mineVsTheirsHtml } = diffProvider.computeDiff(block.mine, block.theirs); + const { html: mineHtml } = diffProvider.computeDiff(block.mine, block.mine); + const { html: theirsHtml } = diffProvider.computeDiff(block.theirs, block.theirs); + + bodyContent += ` +
    +
    + Conflict (${safeMineLabel} vs ${safeTheirsLabel}) +
    + + + +
    +
    +
    +
    Diff Preview (Mine → Theirs)
    +
    ${mineVsTheirsHtml}
    +
    +
    +
    +
    Mine (${safeMineLabel})
    +
    ${mineHtml}
    +
    +
    +
    Theirs (${safeTheirsLabel})
    +
    ${theirsHtml}
    +
    +
    +
    + `; + } + } + + return bodyContent; +} + /** * Parses markdown text containing Git conflict markers (<<<<<<<, |||||||, =======, >>>>>>>) * into a sequence of Common and Conflict blocks. diff --git a/src/markdown/mermaidDiff.ts b/src/markdown/mermaidDiff.ts index 193894e..530ab6c 100644 --- a/src/markdown/mermaidDiff.ts +++ b/src/markdown/mermaidDiff.ts @@ -130,7 +130,7 @@ export function parseMermaidNodes(code: string): Map { continue; } - const nodeRegex = /\b([a-zA-Z0-9_-]+)(?:\(\((?:["']([^"']+)["']|([^\)]+))\)\)|\[\[(?:["']([^"']+)["']|([^\]]+))\]\]|\[(?:["']([^"']+)["']|([^\]]+))\]|\((?:["']([^"']+)["']|([^\)]+))\)|\{(?:["']([^"']+)["']|([^\}]+))\}|>([^\\]]+)\])?/g; + const nodeRegex = /\b([a-zA-Z0-9_]+(?:-[a-zA-Z0-9_]+)*)(?:\(\((?:["']([^"']+)["']|([^\)]+))\)\)|\[\[(?:["']([^"']+)["']|([^\]]+))\]\]|\[(?:["']([^"']+)["']|([^\]]+))\]|\((?:["']([^"']+)["']|([^\)]+))\)|\{(?:["']([^"']+)["']|([^\}]+))\}|>([^\\]]+)\])?/g; let match: RegExpExecArray | null; while ((match = nodeRegex.exec(trimmed)) !== null) { diff --git a/src/markdown/structuralDiff.ts b/src/markdown/structuralDiff.ts index 13338a4..1864c1b 100644 --- a/src/markdown/structuralDiff.ts +++ b/src/markdown/structuralDiff.ts @@ -25,8 +25,6 @@ import * as crypto from "crypto"; import { diffTables } from "./tableDiff"; import { findClosing } from "./domUtils"; -import { computeMermaidDiff } from "./mermaidDiff"; -import { escapeHtml } from "./sanitizer"; /** * Main entrance for computing granular HTML diffs. @@ -144,7 +142,10 @@ export function executeWithFullPipeline( } // 5. Restore checkboxes - restored = restoreCheckboxes(restored, cbTokensNew); + restored = restoreCheckboxes(restored, { + ...cbTokensOld, + ...cbTokensNew, + }); // Apply post-restoration refinements (like list Ghost items) if (!options.skipRefinement) { @@ -548,7 +549,17 @@ export function balanceDiffTags(html: string): string { } export function cleanupUnbalancedDiffTags(html: string): string { - let result = balanceDiffTags(html); + // Protect pre/code blocks from having whitespace/newline diff tags stripped + const preBlocks: string[] = []; + const maskedHtml = html.replace(/]*>[\s\S]*?<\/pre>/gi, (match) => { + // Inside
    , only remove strictly empty diff tags with no content at all
    +    const cleanedPre = match.replace(/<(ins|del)[^>]*><\/\1>/gi, "");
    +    const token = `__PRE_CLEANUP_${preBlocks.length}_TOKEN__`;
    +    preBlocks.push(cleanedPre);
    +    return token;
    +  });
    +
    +  let result = balanceDiffTags(maskedHtml);
     
       // Fix cases like 
    which happen if htmldiff gets confused by fragments result = result.replace(/
    \s*<\/del>\s*<\/ins>/gi, "
    "); @@ -560,6 +571,11 @@ export function cleanupUnbalancedDiffTags(html: string): string { // Remove empty diff tags result = result.replace(/<(ins|del)[^>]*>\s*<\/\1>/gi, ""); + // Restore protected pre blocks + for (let i = 0; i < preBlocks.length; i++) { + result = result.replace(`__PRE_CLEANUP_${i}_TOKEN__`, () => preBlocks[i]); + } + return result; } @@ -716,11 +732,16 @@ export function consolidateWrappedItems(html: string): string { return html.replace( /<(h[1-6]|p|blockquote)([^>]*)>\s*<(ins|del)[^>]*>\s*([\s\S]*?)\s*<\/\3>\s*<\/\1>/gi, (match, tag, attrs, type, content) => { - const diffClass = type === "ins" ? "diffins" : "diffdel"; // Ensure we don't wrap twice if (match.includes("diff-block")) { return match; } + // Safety guard: ensure content does not contain additional unclosed/nested diff tags + // which indicates multiple diff tags inside the block rather than the entire block being wrapped + if (/<(ins|del)\b/i.test(content) || /<\/(ins|del)>/i.test(content)) { + return match; + } + const diffClass = type === "ins" ? "diffins" : "diffdel"; return `<${type} class="${diffClass}"><${tag}${attrs}>${content}`; }, ); @@ -1538,6 +1559,16 @@ export function refineBlockDiffs( }, ); + const tableRegex = + /(]*>\s*(]*>[\s\S]*?<\/table>)\s*<\/del>)\s*(]*>\s*(]*>[\s\S]*?<\/table>)\s*<\/ins>)/gi; + + resultHtml = resultHtml.replace( + tableRegex, + (match, delBlock, oldInner, insBlock, newInner) => { + return diffTables(oldInner, newInner, execute); + }, + ); + const genericBlockRegex = /(]*>\s*<([a-z1-6]+)(?:\s+[^>]*)?>([\s\S]*?)<\/\2>\s*<\/del>)\s*(]*>\s*<([a-z1-6]+)(?:\s+[^>]*)?>([\s\S]*?)<\/\5>\s*<\/ins>)/gi; @@ -1548,9 +1579,9 @@ export function refineBlockDiffs( return match; } - // Special handling for pre blocks to ensure we don't break syntax highlighting - // if it's already highlighted. - if (delTag.toLowerCase() === "pre") { + // Special handling for pre and table blocks to ensure we don't break syntax highlighting + // or bypass structured table diffing. + if (delTag.toLowerCase() === "pre" || delTag.toLowerCase() === "table") { return match; } @@ -1562,28 +1593,13 @@ export function refineBlockDiffs( attributesMatch && attributesMatch[1] ? attributesMatch[1] : ""; // EXCEPTION: Do not re-diff the inside of specialized blocks like Mermaid or GitHub Alerts. - // For Mermaid: Re-diffing injects / tags that break their specific parsers. + // For Mermaid: Preserve separate (original) and (modified) blocks for split/inline view. // For Alerts: It often causes redundant nesting (double vertical bars). if ( /class=["'][^"']*(?:mermaid|markdown-alert|katex)[^"']*["']/i.test( attributes, ) ) { - if (/class=["'][^"']*mermaid[^"']*["']/i.test(attributes)) { - const unescapeHtml = (str: string) => - str - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, "&"); - - const oldCode = unescapeHtml(delInner.replace(/<[^>]+>/g, "").trim()); - const newCode = unescapeHtml(insInner.replace(/<[^>]+>/g, "").trim()); - const diffMermaid = computeMermaidDiff(oldCode, newCode); - const escapedDiff = escapeHtml(diffMermaid); - return `<${insTag}${attributes}>${escapedDiff}`; - } return match; } @@ -1627,16 +1643,6 @@ export function refineBlockDiffs( }, ); - const tableRegex = - /(]*>\s*(]*>[\s\S]*?<\/table>)\s*<\/del>)\s*(]*>\s*(]*>[\s\S]*?<\/table>)\s*<\/ins>)/gi; - - resultHtml = resultHtml.replace( - tableRegex, - (match, delBlock, oldInner, insBlock, newInner) => { - return diffTables(oldInner, newInner, execute); - }, - ); - const imageRegex = /(?:]*)>\s*)?(]*>\s*(]*>)\s*<\/del>)\s*(]*>\s*(]*>)\s*<\/ins>)(?:\s*<\/p>)?/gi; diff --git a/src/markdown/webviewTemplate.ts b/src/markdown/webviewTemplate.ts index d91f4e5..ce78805 100644 --- a/src/markdown/webviewTemplate.ts +++ b/src/markdown/webviewTemplate.ts @@ -1008,6 +1008,10 @@ export function getWebviewContent( .task-list-item { position: relative; } + .task-list-item p { + display: inline; + margin: 0; + } .task-list-item-checkbox { margin: 0 0.2em 0.25em -1.6em; vertical-align: middle; @@ -3679,7 +3683,14 @@ export function getWebviewContent( if (!activeHeading) { // Before first heading - container.innerHTML = '' + t("Top") + ''; + container.innerHTML = ''; + const topItem = document.createElement('span'); + topItem.className = 'breadcrumb-item'; + topItem.textContent = t("Top"); + topItem.onclick = () => { + pane.scrollTop = 0; + }; + container.appendChild(topItem); return; } diff --git a/src/test/unit/checkbox_regression.test.ts b/src/test/unit/checkbox_regression.test.ts index f38046f..ea7f63d 100644 --- a/src/test/unit/checkbox_regression.test.ts +++ b/src/test/unit/checkbox_regression.test.ts @@ -70,4 +70,17 @@ describe("Checkbox Regression Test", () => { assert.strictEqual(restored.includes(''), true, "Should restore token despite mangled casing"); assert.strictEqual(restored.includes('zChEcKbOxZ'), false, "Token string should be gone"); }); + + it('should preserve text and checkbox integrity when comparing tight and loose task lists', () => { + const oldMd = `- [ ] Task A\n- [x] Task B\n- [ ] Task C`; + const newMd = `- [x] Task A (Checked)\n\n- [ ] Task C`; + + const { html: diffHtml } = provider.computeDiff(oldMd, newMd); + + // Verify Task A text is NOT wrapped inside an ins tag along with p + assert.strictEqual(/]*>

    wrapper"); + assert.ok(diffHtml.includes("Task A"), "Task A text must be present in diff HTML"); + assert.ok(diffHtml.includes("Task C"), "Task C text must be present in diff HTML"); + }); }); + diff --git a/src/test/unit/conflictParser.test.ts b/src/test/unit/conflictParser.test.ts index fec9aba..1b89f3d 100644 --- a/src/test/unit/conflictParser.test.ts +++ b/src/test/unit/conflictParser.test.ts @@ -2,6 +2,8 @@ import * as assert from "assert"; import { parseConflictBlocks, reconstructDocument, + renderConflictBlocks, + getConflictResolverShellHtml, } from "../../markdown/conflictParser"; describe("Conflict Parser", () => { @@ -112,4 +114,35 @@ Unclosed mine content`; assert.strictEqual(blocks[1].type, "common"); assert.ok(blocks[1].text.includes("Unclosed mine content")); }); + + it("should render conflict buttons with data attributes and no inline onclick handlers", () => { + const text = `<<<<<<< HEAD +Mine content +======= +Theirs content +>>>>>>> feature`; + const blocks = parseConflictBlocks(text); + + const html = renderConflictBlocks(blocks); + + // Verify buttons have .btn-resolve and data attributes + assert.ok(html.includes('class="btn-resolve"'), "Buttons should have btn-resolve class"); + assert.ok(html.includes('data-block-id="conflict-1"'), "Buttons should have data-block-id attribute"); + assert.ok(html.includes('data-choice="mine"'), "Accept Mine button should have data-choice='mine'"); + assert.ok(html.includes('data-choice="theirs"'), "Accept Theirs button should have data-choice='theirs'"); + assert.ok(html.includes('data-choice="both"'), "Accept Both button should have data-choice='both'"); + + // Crucial: Ensure NO inline onclick attributes are generated (blocked by CSP) + assert.strictEqual(html.includes("onclick="), false, "HTML must not contain inline onclick attributes"); + }); + + it("should generate shell HTML with CSP nonce and event delegation for .btn-resolve", () => { + const nonce = "test-nonce-12345"; + + const shellHtml = getConflictResolverShellHtml(nonce); + + assert.ok(shellHtml.includes(`script-src 'nonce-${nonce}'`), "Shell HTML should include CSP nonce"); + assert.ok(shellHtml.includes(".btn-resolve"), "Shell HTML script should delegate clicks on .btn-resolve"); + assert.ok(shellHtml.includes("resolveConflict"), "Shell HTML should define resolveConflict function"); + }); }); diff --git a/src/test/unit/consolidate_wrapped_items.test.ts b/src/test/unit/consolidate_wrapped_items.test.ts index d85d548..5545e02 100644 --- a/src/test/unit/consolidate_wrapped_items.test.ts +++ b/src/test/unit/consolidate_wrapped_items.test.ts @@ -12,4 +12,10 @@ describe("consolidateWrappedItems bug", () => { assert.strictEqual(result.includes('

      outside of
    • if inside
        "); assert.strictEqual(result, input, "Should leave inside
      • "); }); + it("should NOT move diff tags outside when block contains multiple diff tags", () => { + const input = '

        Task A (Checked)

        '; + const result = consolidateWrappedItems(input); + assert.strictEqual(result, input, "Should leave multiple tags intact without broken wrapping"); + }); }); + diff --git a/src/test/unit/exportHtml.test.ts b/src/test/unit/exportHtml.test.ts new file mode 100644 index 0000000..806dfa7 --- /dev/null +++ b/src/test/unit/exportHtml.test.ts @@ -0,0 +1,156 @@ +import * as assert from "assert"; +import { + extractLocalFsPath, + getMimeType, + prepareExportHtml, +} from "../../exportHtml"; + +describe("Export HTML Module", () => { + describe("extractLocalFsPath", () => { + it("should extract fsPath from vscode-resource.vscode-cdn.net (percent-encoded +)", () => { + const uri = "https://file%2B.vscode-resource.vscode-cdn.net/Users/test/image.png"; + assert.strictEqual(extractLocalFsPath(uri), "/Users/test/image.png"); + }); + + it("should extract fsPath from vscode-resource.vscode-cdn.net (+)", () => { + const uri = "https://file+.vscode-resource.vscode-cdn.net/Users/test/image.svg"; + assert.strictEqual(extractLocalFsPath(uri), "/Users/test/image.svg"); + }); + + it("should extract Windows fsPath correctly", () => { + const uri = "https://file%2B.vscode-resource.vscode-cdn.net/c%3A/Users/test/image.png"; + assert.strictEqual(extractLocalFsPath(uri), "c:/Users/test/image.png"); + }); + + it("should extract fsPath from vscode-file:// URL", () => { + const uri = "vscode-file://vscode-app/Users/test/image.webp"; + assert.strictEqual(extractLocalFsPath(uri), "/Users/test/image.webp"); + }); + + it("should extract fsPath from vscode-resource: URL", () => { + const uri = "vscode-resource:/Users/test/image.gif"; + assert.strictEqual(extractLocalFsPath(uri), "/Users/test/image.gif"); + }); + + it("should extract fsPath from file:// URL", () => { + const uri = "file:///Users/test/image.png"; + assert.strictEqual(extractLocalFsPath(uri), "/Users/test/image.png"); + }); + + it("should ignore query parameters and hashes", () => { + const uri = "https://file%2B.vscode-resource.vscode-cdn.net/Users/test/image.png?v=123#frag"; + assert.strictEqual(extractLocalFsPath(uri), "/Users/test/image.png"); + }); + + it("should return undefined for non-file URLs", () => { + assert.strictEqual(extractLocalFsPath("https://example.com/image.png"), undefined); + assert.strictEqual(extractLocalFsPath("data:image/png;base64,..."), undefined); + }); + }); + + describe("getMimeType", () => { + it("should identify SVG, PNG, JPEG, GIF, WebP", () => { + assert.strictEqual(getMimeType("test.svg"), "image/svg+xml"); + assert.strictEqual(getMimeType("test.png"), "image/png"); + assert.strictEqual(getMimeType("test.jpg"), "image/jpeg"); + assert.strictEqual(getMimeType("test.jpeg"), "image/jpeg"); + assert.strictEqual(getMimeType("test.gif"), "image/gif"); + assert.strictEqual(getMimeType("test.webp"), "image/webp"); + assert.strictEqual(getMimeType("test.woff2"), "font/woff2"); + }); + }); + + describe("prepareExportHtml", () => { + it("should replace local image URIs with Base64 Data URIs", async () => { + const mockHtml = ` + + + + + + +
        old
        +
        new
        + +`; + + const svgContent = ``; + const pngContent = Buffer.from("fake-png-bytes"); + + const mockReadFile = async (filePath: string): Promise => { + if (filePath.endsWith(".svg")) { + return Buffer.from(svgContent, "utf8"); + } + if (filePath.endsWith(".png")) { + return pngContent; + } + throw new Error("File not found"); + }; + + const result = await prepareExportHtml(mockHtml, { readFile: mockReadFile }); + + // Should contain Base64 data URIs + const expectedSvgBase64 = Buffer.from(svgContent, "utf8").toString("base64"); + const expectedPngBase64 = pngContent.toString("base64"); + + assert.ok( + result.includes(`src="data:image/svg+xml;base64,${expectedSvgBase64}"`), + "Should replace SVG src with Base64 data URI", + ); + assert.ok( + result.includes(`src="data:image/png;base64,${expectedPngBase64}"`), + "Should replace PNG src with Base64 data URI", + ); + // CSP should be relaxed for standalone + assert.ok(!result.includes("default-src 'none'"), "Should replace strict webview CSP"); + assert.ok(result.includes("default-src 'self'"), "Should have standalone CSP"); + }); + + it("should replace KaTeX font URIs with CDN URLs", async () => { + const mockHtml = ` +`; + + const result = await prepareExportHtml(mockHtml, { + readFile: async () => { + throw new Error("Should use CDN for katex fonts"); + }, + }); + + assert.ok( + result.includes("https://cdn.jsdelivr.net/npm/katex@0.10.2/dist/fonts/KaTeX_Main-Regular.woff2"), + "Should replace local KaTeX font with KaTeX CDN font URL", + ); + }); + + it("should replace Highlight.js and Mermaid URIs with CDN equivalents", async () => { + const mockHtml = ` + + +`; + + const result = await prepareExportHtml(mockHtml); + + assert.ok(result.includes("https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js")); + assert.ok(result.includes("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css")); + assert.ok(result.includes("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css")); + }); + + it("should gracefully handle missing local image files without crashing", async () => { + const mockHtml = ``; + + const result = await prepareExportHtml(mockHtml, { + readFile: async () => { + throw new Error("File not found on disk"); + }, + }); + + // Should keep original URL if reading fails + assert.ok(result.includes("https://file%2B.vscode-resource.vscode-cdn.net/Users/missing/image.png")); + }); + }); +}); diff --git a/src/test/unit/markdownDiff.test.ts b/src/test/unit/markdownDiff.test.ts index f4b049d..1cbcd65 100644 --- a/src/test/unit/markdownDiff.test.ts +++ b/src/test/unit/markdownDiff.test.ts @@ -104,6 +104,18 @@ describe("MarkdownDiffProvider", () => { assert.ok(diff.includes("graph TD;"), "Should contain mermaid content"); }); + it("should generate separate del (original) and ins (modified) blocks for modified mermaid diagrams", () => { + const oldMd = "```mermaid\ngraph TD;\nA-->B;\nB-->C;\n```"; + const newMd = "```mermaid\ngraph TD\nA[Start] --> B{Decision}\nB --> C[Process One]\nB --> D[Process Two]\n```"; + + const { html: diff } = provider.computeDiff(oldMd, newMd); + assert.ok(diff.includes('class="mermaid"'), "Should contain mermaid class"); + assert.ok(diff.includes(" { const oldMd = "![Icon](images/icon.png)"; const newMd = "![Icon](images/icon.png)"; @@ -337,6 +349,41 @@ describe("MarkdownDiffProvider", () => { ); }); + it("should accurately align columns and cell changes when column count changes", () => { + const table3Cols = [ + "| ID | Name | Role |", + "| :-- | :--- | :--- |", + "| 1 | Alice | Admin |", + "| 2 | Bob | User |", + "| 3 | Charlie | Guest |", + ].join("\n"); + + const table4Cols = [ + "| ID | Name | Role | Status |", + "| :-- | :--- | :--- | :----- |", + "| 1 | Alice | Administrator | Active |", + "| 2 | Bob | User | Inactive |", + "| 3 | Charlie | Guest | Active |", + "| 4 | Dave | User | Active |", + ].join("\n"); + + // 1. 3 cols -> 4 cols (Column inserted) + const { html: diff3to4 } = provider.computeDiff(table3Cols, table4Cols); + assert.ok(diff3to4.includes("diff-col-ins"), "Inserted 4th column should have diff-col-ins class"); + assert.ok( + diff3to4.includes("AdminAdministrator"), + "Role cell should show inline modification between Admin and Administrator", + ); + + // 2. 4 cols -> 3 cols (Column deleted in reverse diff) + const { html: diff4to3 } = provider.computeDiff(table4Cols, table3Cols); + assert.ok(diff4to3.includes("diff-col-del"), "Deleted 4th column should have diff-col-del class"); + assert.ok( + diff4to3.includes("AdministratorAdmin"), + "Role cell in reverse diff should align Administrator -> Admin in the Role column, not drift to Status column", + ); + }); + it("should include explicit table styling in the webview", () => { const webviewContent = provider.getWebviewContent( "
        Feature
        Tables
        ", @@ -1311,6 +1358,21 @@ describe("MarkdownDiffProvider", () => { ); }); + it("should preserve newlines when appending code with blank lines inside code blocks", () => { + const oldMd = '```javascript\nfunction farewell(name) {\n console.log(`Goodbye, ${name}!`);\n}\n```'; + const newMd = '```javascript\nfunction farewell(name) {\n console.log(`Goodbye, ${name}!`);\n}\n\nfunction good_morning(name) {\n console.log(`Hello, ${name}!`);\n}\n```'; + const { html: diff } = provider.computeDiff(oldMd, newMd); + + assert.ok( + /]*>\s*\n\s*<\/ins>/.test(diff) || diff.includes("\n\n") || diff.includes("\n\n"), + "Diff should preserve newlines between functions in code blocks", + ); + assert.ok( + !diff.includes("}function good_morning") && !diff.includes("} { const { html: diff } = provider.computeDiff( "Above\n\n---\n\nBelow", diff --git a/src/test/unit/mermaidDiff.test.ts b/src/test/unit/mermaidDiff.test.ts index c2ae1f4..e6d29c0 100644 --- a/src/test/unit/mermaidDiff.test.ts +++ b/src/test/unit/mermaidDiff.test.ts @@ -178,4 +178,49 @@ describe("Mermaid Semantic Diff", () => { assert.ok(diff.includes("linkStyle 0 stroke:#d73a49"), "Edge 0 should be styled as deleted"); assert.ok(diff.includes("linkStyle 1 stroke:#d73a49"), "Edge 1 should be styled as deleted"); }); + + // --- MERMAID-03 regression tests --- + + it("[MERMAID-03] should not consume arrow hyphens as part of node IDs (A-->B;)", () => { + const code = ` + graph TD; + A-->B; + B-->C; + C-->D; + `; + const nodes = parseMermaidNodes(code); + assert.strictEqual(nodes.size, 4, "Should detect exactly 4 nodes (A, B, C, D)"); + assert.ok(nodes.has("A"), "Node A must be present"); + assert.ok(nodes.has("B"), "Node B must be present"); + assert.ok(nodes.has("C"), "Node C must be present"); + assert.ok(nodes.has("D"), "Node D must be present"); + assert.ok(!nodes.has("A--"), "A-- must NOT be recognized as a node"); + assert.ok(!nodes.has("B--"), "B-- must NOT be recognized as a node"); + assert.ok(!nodes.has("C--"), "C-- must NOT be recognized as a node"); + }); + + it("[MERMAID-03] should correctly compute diff between compact arrow diagrams and modified diagrams", () => { + const oldCode = ` + graph TD; + A-->B; + B-->C; + C-->D; + `; + const newCode = ` + graph TD + A[Start] --> B{Decision} + B -- Yes --> C[Process One] + B -- No --> D[Process Two] + C --> E[Branch A] + C --> F[Branch B] + E --> H[End] + F --> H + G --> H + `; + const diff = computeMermaidDiff(oldCode, newCode); + assert.ok(!diff.includes("A--"), "Should not include bogus A-- node"); + assert.ok(diff.includes("style E fill:#e6ffec"), "Should style added node E as inserted"); + assert.ok(diff.includes("style A fill:#fffdef"), "Should style modified node A as modified"); + assert.ok(diff.includes("C -.-> D"), "Should include ghost edge for removed C-->D"); + }); }); 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 2e53fea..ade4a8f 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