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 @@
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:
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}${tag}>${type}>`;
},
);
@@ -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}${insTag}>`;
- }
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 = '';
+ 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 = `
+
+
+
+
+
+
+
+
+
+`;
+
+ 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 = "";
const newMd = "";
@@ -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