From b2fd6574ded5f95d3cd666cb95e52a7ac74e1a23 Mon Sep 17 00:00:00 2001 From: phine-apps <91043289+phine-apps@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:52:58 +0900 Subject: [PATCH] chore(release): prepare v1.5.0 release with Visual Conflict Resolver, HTML export, and bug fixes --- CHANGELOG.md | 3 +- README.md | 7 +- fixtures/expected/comprehensive.html | 45 ++-- src/conflictEditorProvider.ts | 186 +-------------- src/exportHtml.ts | 222 ++++++++++++++++++ src/extension.ts | 26 +- src/markdown/conflictParser.ts | 202 ++++++++++++++++ src/markdown/mermaidDiff.ts | 2 +- src/markdown/structuralDiff.ts | 74 +++--- src/markdown/webviewTemplate.ts | 13 +- src/test/unit/checkbox_regression.test.ts | 13 + src/test/unit/conflictParser.test.ts | 33 +++ .../unit/consolidate_wrapped_items.test.ts | 6 + src/test/unit/exportHtml.test.ts | 156 ++++++++++++ src/test/unit/markdownDiff.test.ts | 62 +++++ src/test/unit/mermaidDiff.test.ts | 45 ++++ ...nsive-v1-v2-split-light-chromium-linux.png | Bin 580678 -> 568772 bytes 17 files changed, 830 insertions(+), 265 deletions(-) create mode 100644 src/exportHtml.ts create mode 100644 src/test/unit/exportHtml.test.ts 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('