Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ All notable changes to **Rich Markdown Diff** will be documented in this file.
- **Quick Edit**: Fixed a bug where the Quick Edit overlay loaded incorrect source text when editing documents that contain frontmatter metadata.
- **Obsidian Tags**: Expanded the tag parser to support Japanese, Chinese, and other multi-byte characters.
- **Marp Support**: Fixed slide transition animations and restored accurate dark/light theme styling in the webview.
- **Stability & Rendering**: Resolved layout and event-handling bugs, including table scroll wrapping, code block placeholder collisions, and duplicate event listeners.
- **Stability & Rendering**:
- Fixed horizontal scroll clipping of line highlights on code blocks and tables.
- Resolved visual alignment and active highlight issues for embedded code blocks and task list checkboxes.
- Fixed potential memory leaks in clipboard comparisons and child process hangs in Git Blame.
- **Compare Commands**: Fixed a bug where comparing a file with itself opened a blank diff panel.


## [1.3.1] - 2026-05-24

Expand Down
7 changes: 3 additions & 4 deletions fixtures/expected/comprehensive.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ <h3 data-line="28" data-line-end="29">Task List</h3>
<li class="task-list-item" data-line="31" data-line-end="32"><input class="task-list-item-checkbox" checked disabled type="checkbox" /> Task 2</li>
<ins class="diffins diff-block"><li data-all-inserted="true" class="task-list-item" data-line="32" data-line-end="34"><input class="task-list-item-checkbox" checked disabled type="checkbox" /> Task 3</li></ins></ul>
<h2 data-line="34" data-line-end="35">Code Blocks</h2>
<pre><code data-line="36" data-line-end="46" class="language-javascript"><span class="hljs-keyword">function</span> <span class="hljs-title function_">greet</span>(<span class="hljs-params">name</span>) {<span class="hljs-comment"><ins class="diffins">// Updated comment in v2</ins></span>
<div class="code-block-wrapper"><pre><code data-line="36" data-line-end="46" class="language-javascript"><span class="hljs-keyword">function</span> <span class="hljs-title function_">greet</span>(<span class="hljs-params">name</span>) {<span class="hljs-comment"><ins class="diffins">// Updated comment in v2</ins></span>
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">`Hello, <span class="hljs-subst">${name}</span>!<ins class="diffins">&nbsp;Welcome!`</ins></span><ins class="diffins">);
}

</ins><span class="hljs-keyword"><ins class="diffins">function</ins></span><ins class="diffins">&nbsp;</ins><span class="hljs-title function_"><ins class="diffins">farewell</ins></span><ins class="diffins">(</ins><span class="hljs-params"><ins class="diffins">name</ins></span><ins class="diffins">) {
</ins><span class="hljs-variable language_"><ins class="diffins">console</ins></span><ins class="diffins">.</ins><span class="hljs-title function_"><ins class="diffins">log</ins></span><ins class="diffins">(</ins><span class="hljs-string"><ins class="diffins">`Goodbye, </ins><span class="hljs-subst"><ins class="diffins">${name}</ins></span><ins class="diffins">!</ins>`</span>);
}
</code></pre><del class="diffdel diff-block"><h2 >Tables</h2></del><del class="diffmod diff-block"><table data-line="39" data-line-end="44">
</code></pre></div><del class="diffdel diff-block"><h2 >Tables</h2></del><del class="diffmod diff-block"><table data-line="39" data-line-end="44">
<thead>
<tr data-line="39" data-line-end="40">
<th data-line="39" data-line-end="40">Feature</th>
Expand All @@ -53,8 +53,7 @@ <h2 data-line="34" data-line-end="35">Code Blocks</h2>
<td data-line="43" data-line-end="44">✅</td>
</tr>
</tbody>
</table></del>
<h2 data-line="47" data-line-end="48">Links and Images</h2>
</table></del><h2 data-line="47" data-line-end="48">Links and Images</h2>
<p data-line="49" data-line-end="51"><a href="https://github.com">Visit GitHub</a><a href="https://marketplace.visualstudio.com"><ins class="diffins">Visit VS Code Marketplace</ins></a></p>
<h2 data-line="52" data-line-end="53">Blockquotes</h2>
<blockquote data-line="54" data-line-end="57">
Expand Down
21 changes: 20 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,14 @@ async function createAndBindDiffPanel(
getDiffPanelOptions(context),
);

context.subscriptions.push(panel);
panel.onDidDispose(() => {
const idx = context.subscriptions.indexOf(panel);
if (idx > -1) {
context.subscriptions.splice(idx, 1);
}
});

await bindDiffPanel(panel, context, resolveState);
return panel;
}
Expand Down Expand Up @@ -1170,10 +1178,12 @@ export function activate(context: vscode.ExtensionContext) {
// Helper to track active panel for shortcuts
attachPanelTracking(panel);

context.subscriptions.push(panel);

panel.webview.html = webviewContent;

// Handle Double Click
panel.webview.onDidReceiveMessage((message) => {
const messageDisposable = panel.webview.onDidReceiveMessage((message) => {
if (message.command === "openSource") {
const side = message.side;
const line = message.line;
Expand All @@ -1198,6 +1208,14 @@ export function activate(context: vscode.ExtensionContext) {
}
}
});

panel.onDidDispose(() => {
messageDisposable.dispose();
const idx = context.subscriptions.indexOf(panel);
if (idx > -1) {
context.subscriptions.splice(idx, 1);
}
});
},
);

Expand Down Expand Up @@ -1373,6 +1391,7 @@ export function activate(context: vscode.ExtensionContext) {
vscode.window.showInformationMessage(
l10n.t("You are comparing the same file."),
);
return;
}

void showTwoFilesDiff(selectedForCompareUri, targetUri, context);
Expand Down
2 changes: 2 additions & 0 deletions src/gitBlameResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export async function resolveBlameInfo(
// \t<line_content>
const child = child_process.spawn("git", ["blame", "--porcelain", fileName], { cwd });

child.stderr?.resume();

const rl = readline.createInterface({
input: child.stdout,
crlfDelay: Infinity,
Expand Down
6 changes: 5 additions & 1 deletion src/markdown/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ function configureRules(md: MarkdownIt) {
return `<div class="mermaid"${attrs} data-original-content="${escapedContent}">\n${escapedContent}\n</div>`;
}

return defaultFence(tokens, idx, options, env, self);
// Wrap code blocks in a container div so that the ::after gutter marker
// (positioned at left: -16px) is not clipped by <pre>'s overflow-x: auto,
// which creates a scroll container that clips absolutely-positioned children.
const rendered = defaultFence(tokens, idx, options, env, self);
return `<div class="code-block-wrapper">${rendered}</div>`;
};

// Image Resolver Support
Expand Down
177 changes: 112 additions & 65 deletions src/markdown/structuralDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,20 @@ export function replaceBalancedTags(
}
}

if (options.tokenizeCodeBlocks !== false && html.startsWith('<div class="code-block-wrapper">', i)) {
const start = i;
const end = findClosing(html, i, "div");
if (end > -1) {
const content = html.substring(start, end);
const token = createToken(content, "CODEBLOCK", tokens);
result += token;
i = end;
continue;
}
}

// Fallback: also tokenize bare <pre> elements (e.g. from Marp or other renderers
// that do not use the code-block-wrapper div).
if (options.tokenizeCodeBlocks !== false && html.startsWith("<pre", i)) {
const start = i;
const end = findClosing(html, i, "pre");
Expand Down Expand Up @@ -1053,8 +1067,8 @@ export function replaceBalancedTags(
}
}

// Tables (already covered by listRegex, but keeping for compatibility if listRegex doesn't match for some reason)
if (html.startsWith("<table", i)) {
// Tables (fallback when list/table container tokenization is disabled)
if (options.tokenizeListContainers === false && html.startsWith("<table", i)) {
const start = i;
const end = findClosing(html, i, "table");
if (end > -1) {
Expand Down Expand Up @@ -1400,57 +1414,89 @@ export function refineBlockDiffs(

// NOTE: We cannot use a simple adjacency regex (del<pre></pre>del ins<pre></pre>ins) because
// other diff elements (e.g. a deleted section) may sit between the del-pre and ins-pre blocks.
// Instead, collect all del-wrapped and ins-wrapped <pre> blocks globally, pair them by index,
// Instead, collect all del-wrapped and ins-wrapped code blocks globally, pair them by index,
// re-diff each pair, and substitute back via placeholder tokens.
//
// Code blocks may appear as:
// A) <del><div class="code-block-wrapper"><pre ...>...</pre></div></del> (standard renderer)
// B) <del><pre ...>...</pre></del> (bare, e.g. Marp)
{
const delPreRegex =
/<del([^>]*)>\s*<pre([^>]*)>([\s\S]*?)<\/pre>\s*<\/del>/gi;
const insPreRegex =
/<ins([^>]*)>\s*<pre([^>]*)>([\s\S]*?)<\/pre>\s*<\/ins>/gi;

interface PreBlock {
full: string;
wrapperAttrs: string; // div attrs (empty string for bare <pre>)
preAttrs: string;
inner: string;
isWrapped: boolean; // true → code-block-wrapper present
}
const delBlocks: PreBlock[] = [];
const insBlocks: PreBlock[] = [];
const delWrappedBlocks: PreBlock[] = [];
const insWrappedBlocks: PreBlock[] = [];
const delBareBlocks: PreBlock[] = [];
const insBareBlocks: PreBlock[] = [];

// Pattern A: del/ins wrapping the code-block-wrapper div
const delWrappedRegex =
/<del([^>]*)>\s*<div class="code-block-wrapper"([^>]*)>\s*<pre([^>]*)>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/del>/gi;
const insWrappedRegex =
/<ins([^>]*)>\s*<div class="code-block-wrapper"([^>]*)>\s*<pre([^>]*)>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/ins>/gi;

let m: RegExpExecArray | null;
while ((m = delWrappedRegex.exec(resultHtml)) !== null) {
delWrappedBlocks.push({ full: m[0], wrapperAttrs: m[2], preAttrs: m[3], inner: m[4], isWrapped: true });
}
while ((m = insWrappedRegex.exec(resultHtml)) !== null) {
insWrappedBlocks.push({ full: m[0], wrapperAttrs: m[2], preAttrs: m[3], inner: m[4], isWrapped: true });
}

// Pattern B: del/ins wrapping a bare <pre> (fallback for Marp etc.)
const delPreRegex =
/<del([^>]*)>\s*<pre([^>]*)>([\s\S]*?)<\/pre>\s*<\/del>/gi;
const insPreRegex =
/<ins([^>]*)>\s*<pre([^>]*)>([\s\S]*?)<\/pre>\s*<\/ins>/gi;

while ((m = delPreRegex.exec(resultHtml)) !== null) {
delBlocks.push({
full: m[0],
preAttrs: m[2],
inner: m[3],
});
delBareBlocks.push({ full: m[0], wrapperAttrs: "", preAttrs: m[2], inner: m[3], isWrapped: false });
}
while ((m = insPreRegex.exec(resultHtml)) !== null) {
insBlocks.push({
full: m[0],
preAttrs: m[2],
inner: m[3],
insBareBlocks.push({ full: m[0], wrapperAttrs: "", preAttrs: m[2], inner: m[3], isWrapped: false });
}

const diffedPairs: Array<{
delFull: string;
insFull: string;
diffed: string;
}> = [];

const pairWrappedCount = Math.min(delWrappedBlocks.length, insWrappedBlocks.length);
for (let i = 0; i < pairWrappedCount; i++) {
const innerDiff = diffCodeBlocks(
delWrappedBlocks[i].inner,
insWrappedBlocks[i].inner,
);
const diffedPre = `<pre${insWrappedBlocks[i].preAttrs}>${innerDiff}</pre>`;
const diffed = `<div class="code-block-wrapper"${insWrappedBlocks[i].wrapperAttrs}>${diffedPre}</div>`;
diffedPairs.push({
delFull: delWrappedBlocks[i].full,
insFull: insWrappedBlocks[i].full,
diffed,
});
}

const pairBareCount = Math.min(delBareBlocks.length, insBareBlocks.length);
for (let i = 0; i < pairBareCount; i++) {
const innerDiff = diffCodeBlocks(
delBareBlocks[i].inner,
insBareBlocks[i].inner,
);
const diffedPre = `<pre${insBareBlocks[i].preAttrs}>${innerDiff}</pre>`;
diffedPairs.push({
delFull: delBareBlocks[i].full,
insFull: insBareBlocks[i].full,
diffed: diffedPre,
});
}

const pairCount = Math.min(delBlocks.length, insBlocks.length);
const pairCount = diffedPairs.length;
if (pairCount > 0) {
const diffedPairs: Array<{
delFull: string;
insFull: string;
diffed: string;
}> = [];
for (let i = 0; i < pairCount; i++) {
// Diff ONLY the inner content of the pre block
const innerDiff = diffCodeBlocks(
delBlocks[i].inner,
insBlocks[i].inner,
);
diffedPairs.push({
delFull: delBlocks[i].full,
insFull: insBlocks[i].full,
diffed: `<pre${insBlocks[i].preAttrs}>${innerDiff}</pre>`,
});
}

const uniqueRunId = Math.random().toString(36).slice(2, 10);
for (let i = 0; i < diffedPairs.length; i++) {
Expand Down Expand Up @@ -1771,10 +1817,15 @@ export function normalizeMathBlockDiffs(html: string): string {
}

export function cleanupCheckboxArtifacts(html: string): string {
return html.replace(
/(<input[^>]+class="task-list-item-checkbox"[^>]*>)(\s*)(?=(?:<p\b|<div\b|<ins[^>]*>\s*\[))/gi,
let result = html.replace(
/(<input[^>]+class="task-list-item-checkbox"[^>]*>)(\s*)(?=(?:<p\b|<div\b|<del[^>]*>\s*\[))/gi,
'<del class="diffdel">$1</del>$2',
);
result = result.replace(
/(<input[^>]+class="task-list-item-checkbox"[^>]*>)(\s*)(?=(?:<p\b|<div\b|<ins[^>]*>\s*\[))/gi,
'<ins class="diffins">$1</ins>$2',
);
return result;
}

export function stripDataLineAttributes(html: string): string {
Expand Down Expand Up @@ -2213,42 +2264,38 @@ export function verifyDiffIntegrity(
return true; // Nothing to check
}

const diffWordsSet = new Set(getWords(diffText));
const diffWordsMap = new Map<string, number>();
for (const word of getWords(diffText)) {
diffWordsMap.set(word, (diffWordsMap.get(word) || 0) + 1);
}

// Sample a subset of words to check to avoid performance issues on huge documents
// but ensure we check enough to detect truncation.
const sampleSize = Math.min(newWords.length, 200);
const step = Math.max(1, Math.floor(newWords.length / sampleSize));
const newWordsMap = new Map<string, number>();
for (const word of newWords) {
newWordsMap.set(word, (newWordsMap.get(word) || 0) + 1);
}

let missingCount = 0;
let checkedCount = 0;
for (let i = 0; i < newWords.length; i += step) {
const word = newWords[i];
checkedCount++;
if (!diffWordsSet.has(word)) {
missingCount++;
let totalChecked = 0;
const missingWords: string[] = [];

for (const [word, newCount] of newWordsMap.entries()) {
const diffCount = diffWordsMap.get(word) || 0;
if (diffCount < newCount) {
missingCount += (newCount - diffCount);
if (missingWords.length < 10) {
missingWords.push(word);
}
}
totalChecked += newCount;
}

// Allow a very small margin of error (e.g. 1.0%) for edge cases where htmldiff
// might legitimately combine or slightly transform words (e.g. case changes, punctuation).
const failureThreshold = 0.01; // 1.0% margin of error
const missingRatio = checkedCount > 0 ? missingCount / checkedCount : 0;
const failureThreshold = 0.02; // 2.0% margin of error
const missingRatio = totalChecked > 0 ? missingCount / totalChecked : 0;
const isBroken = missingRatio > failureThreshold;

if (isBroken) {
const missingWords = [];
for (let i = 0; i < newWords.length; i += step) {
const word = newWords[i];
if (!diffWordsSet.has(word)) {
missingWords.push(word);
if (missingWords.length >= 10) {
break;
}
}
}
console.warn(
`Integrity check failed: missing ${missingCount}/${checkedCount} words (${(missingRatio * 100).toFixed(1)}%). Missing: ${missingWords.join(", ")}`,
`Integrity check failed: missing ${missingCount}/${totalChecked} words (${(missingRatio * 100).toFixed(1)}%). Missing: ${missingWords.join(", ")}`,
);
return false;
}
Expand Down
Loading