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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A professional VS Code extension for visual Markdown comparison. Compare rendere
- **Interactive Image Diff**: Compare visual changes in images using "Swipe" and "Onion Skin" comparison modes.
- **Quick Edit**: Modify image paths and frontmatter metadata directly within the preview panels.
- **Clipboard Compare**: Instantly compare any active Markdown file against your clipboard contents.
- 📤 **HTML Export**: Save the entire diff view as a standalone HTML file. It preserves custom styles and client-side interactions, making it perfect for offline sharing and peer reviews in standard web browsers.

## Supported Extensions

Expand Down Expand Up @@ -60,6 +61,20 @@ If you enjoy **Rich Markdown Diff**, you might also like:

- 🔗 **[Markdown Link Assistant](https://marketplace.visualstudio.com/items?itemName=phine-apps.markdown-link-assistant)**: The ultimate link manager for Visual Studio Code. Effortlessly transform raw URLs into beautiful, structured link previews and Notion-like cards with instant previews and AI summaries.

## Configuration

You can customize the extension behavior via the following VS Code settings:

| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `rich-markdown-diff.defaultViewMode` | `string` | `"side-by-side"` | Default layout view mode when opening a diff panel (`"side-by-side"` or `"inline"`). |
| `rich-markdown-diff.defaultFoldUnchanged` | `boolean` | `false` | Whether to automatically fold unchanged Markdown blocks when opening a diff. |
| `rich-markdown-diff.insertedColor` | `string` | `""` | Custom background color for inserted diff elements (e.g. `#e6ffec` or `rgba(46,160,67,0.15)`). |
| `rich-markdown-diff.deletedColor` | `string` | `""` | Custom background color for deleted diff elements (e.g. `#ffeef0` or `rgba(248,81,73,0.15)`). |
| `rich-markdown-diff.showGutterMarkers` | `boolean` | `true` | Whether to show vertical diff markers in the gutter area. |
| `rich-markdown-diff.showGitBlame` | `boolean` | `true` | Whether to load Git Blame metadata for the compared files. |
| `rich-markdown-diff.lineHoverDelay` | `number` | `500` | The delay in milliseconds before showing Git Blame and Quick Edit indicators on hover. |

## License

Licensed under the [MIT License](LICENSE).
52 changes: 52 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@
"category": "%rich-markdown-diff.category%",
"icon": "$(arrow-up)",
"enablement": "rich-markdown-diff.isDiffActive"
},
{
"command": "rich-markdown-diff.exportHtml",
"title": "%rich-markdown-diff.exportHtml.title%",
"category": "%rich-markdown-diff.category%",
"icon": "$(export)",
"enablement": "rich-markdown-diff.isDiffActive"
}
],
"keybindings": [
Expand All @@ -117,6 +124,18 @@
"key": "shift+alt+f5",
"when": "rich-markdown-diff.isDiffActive"
},
{
"command": "rich-markdown-diff.toggleInlineView",
"key": "alt+f7",
"mac": "alt+f7",
"when": "rich-markdown-diff.isDiffActive"
},
{
"command": "rich-markdown-diff.toggleFoldUnchanged",
"key": "alt+f8",
"mac": "alt+f8",
"when": "rich-markdown-diff.isDiffActive"
},
{
"command": "rich-markdown-diff.diffClipboard",
"key": "ctrl+shift+c",
Expand All @@ -142,6 +161,10 @@
"command": "rich-markdown-diff.prevChange",
"when": "rich-markdown-diff.isDiffActive"
},
{
"command": "rich-markdown-diff.exportHtml",
"when": "rich-markdown-diff.isDiffActive"
},
{
"command": "rich-markdown-diff.compareWithSelected",
"when": "rich-markdown-diff.hasSelectedForCompare"
Expand Down Expand Up @@ -180,6 +203,11 @@
"command": "rich-markdown-diff.toggleFoldUnchanged",
"group": "navigation@4",
"when": "rich-markdown-diff.isDiffActive"
},
{
"command": "rich-markdown-diff.exportHtml",
"group": "navigation@5",
"when": "rich-markdown-diff.isDiffActive"
}
],
"explorer/context": [
Expand Down Expand Up @@ -248,6 +276,30 @@
"type": "number",
"default": 500,
"description": "%rich-markdown-diff.lineHoverDelay.description%"
},
"rich-markdown-diff.insertedColor": {
"type": "string",
"default": "",
"description": "%rich-markdown-diff.insertedColor.description%"
},
"rich-markdown-diff.deletedColor": {
"type": "string",
"default": "",
"description": "%rich-markdown-diff.deletedColor.description%"
},
"rich-markdown-diff.defaultViewMode": {
"type": "string",
"enum": [
"side-by-side",
"inline"
],
"default": "side-by-side",
"description": "%rich-markdown-diff.defaultViewMode.description%"
},
"rich-markdown-diff.defaultFoldUnchanged": {
"type": "boolean",
"default": false,
"description": "%rich-markdown-diff.defaultFoldUnchanged.description%"
}
}
},
Expand Down
7 changes: 6 additions & 1 deletion package.nls.ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,10 @@
"rich-markdown-diff.customEditor.displayName": "Markdown Diff プレビュー",
"rich-markdown-diff.showGutterMarkers.description": "ガター領域に垂直な差分マーカーを表示するかどうか。",
"rich-markdown-diff.showGitBlame.description": "比較対象ファイルの Git Blame メタデータを読み込むかどうか。",
"rich-markdown-diff.lineHoverDelay.description": "ホバー時に Git Blame や Quick Edit のインジケーターを表示するまでの遅延時間(ミリ秒)。"
"rich-markdown-diff.lineHoverDelay.description": "ホバー時に Git Blame や Quick Edit のインジケーターを表示するまでの遅延時間(ミリ秒)。",
"rich-markdown-diff.exportHtml.title": "差分を HTML としてエクスポート",
"rich-markdown-diff.insertedColor.description": "追加された差分要素のカスタム背景色(例: #e6ffec または rgba(46,160,67,0.15))。",
"rich-markdown-diff.deletedColor.description": "削除された差分要素のカスタム背景色(例: #ffeef0 または rgba(248,81,73,0.15))。",
"rich-markdown-diff.defaultViewMode.description": "差分パネルを開いた際のデフォルトレイアウト表示モード(side-by-side または inline)。",
"rich-markdown-diff.defaultFoldUnchanged.description": "差分パネルを開いた際にデフォルトで変更のないブロックを折りたたむかどうか。"
}
7 changes: 6 additions & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,10 @@
"rich-markdown-diff.customEditor.displayName": "Markdown Diff Preview",
"rich-markdown-diff.showGutterMarkers.description": "Whether to show vertical diff markers in the gutter area.",
"rich-markdown-diff.showGitBlame.description": "Whether to load Git Blame metadata for the compared files.",
"rich-markdown-diff.lineHoverDelay.description": "The delay in milliseconds before showing Git Blame and Quick Edit indicators on hover."
"rich-markdown-diff.lineHoverDelay.description": "The delay in milliseconds before showing Git Blame and Quick Edit indicators on hover.",
"rich-markdown-diff.exportHtml.title": "Export Diff to HTML",
"rich-markdown-diff.insertedColor.description": "Custom background color for inserted diff elements (e.g. #e6ffec or rgba(46,160,67,0.15)).",
"rich-markdown-diff.deletedColor.description": "Custom background color for deleted diff elements (e.g. #ffeef0 or rgba(248,81,73,0.15)).",
"rich-markdown-diff.defaultViewMode.description": "Default layout view mode when opening a diff panel (side-by-side or inline).",
"rich-markdown-diff.defaultFoldUnchanged.description": "Whether to fold unchanged blocks by default when opening a diff panel."
}
109 changes: 104 additions & 5 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,10 @@ async function renderDiffPanel(
const showGutterMarkers = config.get<boolean>("showGutterMarkers", true);
const showGitBlame = config.get<boolean>("showGitBlame", true);
const lineHoverDelay = config.get<number>("lineHoverDelay", 500);
const insertedColor = config.get<string>("insertedColor", "");
const deletedColor = config.get<string>("deletedColor", "");
const defaultViewMode = config.get<string>("defaultViewMode", "side-by-side");
const defaultFoldUnchanged = config.get<boolean>("defaultFoldUnchanged", false);

const {
html: diffHtml,
Expand Down Expand Up @@ -676,6 +680,10 @@ async function renderDiffPanel(
showGutterMarkers,
showGitBlame,
lineHoverDelay,
insertedColor,
deletedColor,
defaultViewMode,
defaultFoldUnchanged,
);
panel.webview.html = html;

Expand Down Expand Up @@ -792,7 +800,11 @@ async function bindDiffPanel(
if (
e.affectsConfiguration("rich-markdown-diff.showGutterMarkers") ||
e.affectsConfiguration("rich-markdown-diff.showGitBlame") ||
e.affectsConfiguration("rich-markdown-diff.lineHoverDelay")
e.affectsConfiguration("rich-markdown-diff.lineHoverDelay") ||
e.affectsConfiguration("rich-markdown-diff.insertedColor") ||
e.affectsConfiguration("rich-markdown-diff.deletedColor") ||
e.affectsConfiguration("rich-markdown-diff.defaultViewMode") ||
e.affectsConfiguration("rich-markdown-diff.defaultFoldUnchanged")
) {
lastContentKey = undefined; // Force re-render
scheduleUpdate("document");
Expand Down Expand Up @@ -870,12 +882,23 @@ async function bindDiffPanel(

if (message.command === "applyEdit" && currentState) {
const uri = currentState.modifiedUri ?? currentState.fallbackSourceUri;
if (uri) {
const start = message.lineStart;
const end = message.lineEnd;
const newContent = message.newContent;

if (
uri &&
uri.scheme !== "git" &&
uri.scheme !== "gitlens" &&
typeof start === "number" &&
typeof end === "number" &&
typeof newContent === "string" &&
start >= 0 &&
end >= start
) {
try {
const document = await vscode.workspace.openTextDocument(uri);
const edit = new vscode.WorkspaceEdit();
const start = message.lineStart;
const end = message.lineEnd;

// Construct range. end is exclusive line in markdown-it,
// but in VS Code Range, (start, 0) to (end, 0) means lines [start, end-1].
Expand All @@ -886,7 +909,7 @@ async function bindDiffPanel(
);

// Ensure newContent ends with newline if we replaced full lines
let text = message.newContent;
let text = newContent;
if (end < document.lineCount && !text.endsWith("\n")) {
text += "\n";
}
Expand Down Expand Up @@ -1116,6 +1139,82 @@ export function activate(context: vscode.ExtensionContext) {
return false;
},
),
vscode.commands.registerCommand("rich-markdown-diff.exportHtml", async () => {
if (!activePanel) {
vscode.window.showWarningMessage(
l10n.t("No active diff panel found to export."),
);
return;
}

let cleanTitle = activePanel.title
.replace(/[\/\\:\*\?"<>\|]/g, "_")
.trim();
cleanTitle = cleanTitle.replace(/\.[a-zA-Z0-9]+$/i, "");
if (!cleanTitle.endsWith("-diff.html")) {
cleanTitle += "-diff.html";
}

const defaultUri = vscode.workspace.workspaceFolders?.[0]
? vscode.Uri.joinPath(
vscode.workspace.workspaceFolders[0].uri,
cleanTitle,
)
: undefined;

const saveUri = await vscode.window.showSaveDialog({
defaultUri,
filters: {
"HTML Files": ["html"],
},
saveLabel: l10n.t("Export HTML"),
});

if (!saveUri) {
return;
}

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}"`
);

await vscode.workspace.fs.writeFile(
saveUri,
Buffer.from(htmlContent, "utf8"),
);

const openOption = l10n.t("Open File");
const selected = await vscode.window.showInformationMessage(
l10n.t("Exported Markdown diff to {0}", path.basename(saveUri.fsPath)),
openOption,
);

if (selected === openOption) {
await vscode.env.openExternal(saveUri);
}
} catch (error) {
vscode.window.showErrorMessage(
l10n.t("Failed to export HTML: {0}", String(error)),
);
}
}),
);

context.subscriptions.push(
Expand Down
4 changes: 2 additions & 2 deletions src/gitBlameResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function resolveBlameInfo(
// ...
// filename <name>
// \t<line_content>
const child = child_process.spawn("git", ["blame", "--porcelain", fileName], { cwd });
const child = child_process.spawn("git", ["blame", "--porcelain", "--", fileName], { cwd });

child.stderr?.resume();

Expand Down Expand Up @@ -118,7 +118,7 @@ export async function resolveBlameInfo(

// Check if it's the start of a new line block in the porcelain output
const headerMatch = line.match(
/^([0-9a-f]{40})\s+(\d+)\s+(\d+)(?:\s+(\d+))?$/,
/^([0-9a-f]{40,64})\s+(\d+)\s+(\d+)(?:\s+(\d+))?$/,
);
if (headerMatch) {
currentHash = headerMatch[1];
Expand Down
7 changes: 5 additions & 2 deletions src/markdown/mdxPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,21 @@ export default function mdxPlugin(md: MarkdownIt) {
let nextLine = startLine + 1;
let depth = 1;
const closeTag = `</${tagName}>`;
const escapedTagName = tagName.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
const openTagRegex = new RegExp(`^<${escapedTagName}(?:[\\s/>]|$)`, "i");
const closeTagRegex = new RegExp(`^</${escapedTagName}(?:[\\s>]|$)`, "i");

while (nextLine < endLine) {
const nextPos = state.bMarks[nextLine] + state.tShift[nextLine];
const nextMax = state.eMarks[nextLine];
const nextLineText = state.src.slice(nextPos, nextMax).trim();

if (nextLineText === closeTag) {
if (closeTagRegex.test(nextLineText)) {
depth--;
if (depth === 0) {
break;
}
} else if (nextLineText.startsWith(`<${tagName}`)) {
} else if (openTagRegex.test(nextLineText)) {
depth++;
}
nextLine++;
Expand Down
4 changes: 2 additions & 2 deletions src/markdown/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,8 @@ export function injectLineNumbers(md: MarkdownIt) {
// try to inject them into the first tag of the output.
if (adjustedStart !== undefined && html && !/data-line="/i.test(html)) {
html = html.replace(
/(\/?>)/,
` data-line="${adjustedStart}" data-line-end="${adjustedEnd}"$1`,
/^(<[a-zA-Z0-9-]+(?:\s+[^"'>=\s]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>\s]+))?)*\s*)(\/?>)/i,
`$1 data-line="${adjustedStart}" data-line-end="${adjustedEnd}"$2`,
);
}
return html;
Expand Down
Loading