400 lines
14 KiB
JavaScript
400 lines
14 KiB
JavaScript
import express from "express";
|
|
import { execSync, spawn } from "child_process";
|
|
import { readFileSync, writeFileSync } from "fs";
|
|
import { join, dirname } from "path";
|
|
import { tmpdir } from "os";
|
|
import { fileURLToPath } from "url";
|
|
import { parsePatchFiles, DEFAULT_THEMES } from "@pierre/diffs";
|
|
import { preloadFileDiff } from "@pierre/diffs/ssr";
|
|
|
|
const __dirname =
|
|
typeof __filename !== "undefined"
|
|
? dirname(__filename)
|
|
: dirname(fileURLToPath(import.meta.url));
|
|
|
|
// --- arg parsing ---
|
|
|
|
const outIdx = process.argv.indexOf("--out");
|
|
const outFile = outIdx !== -1 ? process.argv[outIdx + 1] : null;
|
|
const isStatic = process.argv.includes("--static");
|
|
|
|
const positional = [];
|
|
let skipNext = false;
|
|
for (const arg of process.argv.slice(2)) {
|
|
if (skipNext) {
|
|
skipNext = false;
|
|
continue;
|
|
}
|
|
if (arg === "--static") continue;
|
|
if (arg === "--out") {
|
|
skipNext = true;
|
|
continue;
|
|
}
|
|
positional.push(arg);
|
|
}
|
|
const [projectDir, mainBranch, featureBranch, portArg] = positional;
|
|
const port = parseInt(portArg, 10) || 3000;
|
|
|
|
if (!projectDir || !mainBranch || !featureBranch) {
|
|
console.error(
|
|
"Usage: difftool.sh [--static] [--out <file>] <git-project-dir> <main branch> <feature branch> [port]",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// --- git ---
|
|
|
|
function run(cmd) {
|
|
try {
|
|
return execSync(cmd, {
|
|
encoding: "utf-8",
|
|
cwd: projectDir,
|
|
}).trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
const branchDiff = run(`git diff ${mainBranch} ${featureBranch} --no-color`);
|
|
const stagedDiff = run("git diff --cached --no-color");
|
|
const unstagedDiff = run("git diff --no-color");
|
|
|
|
const untrackedFiles = run("git ls-files --others --exclude-standard")
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
|
|
let untrackedPatch = "";
|
|
for (const file of untrackedFiles) {
|
|
let content;
|
|
try {
|
|
content = readFileSync(join(projectDir, file), "utf-8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
const lines = content.split("\n");
|
|
const lineCount = content.endsWith("\n")
|
|
? lines.length - 1
|
|
: lines.length;
|
|
untrackedPatch += `diff --git a/${file} b/${file}\n`;
|
|
untrackedPatch += `new file mode 100644\n`;
|
|
untrackedPatch += `--- /dev/null\n`;
|
|
untrackedPatch += `+++ b/${file}\n`;
|
|
untrackedPatch += `@@ -0,0 +1,${lineCount} @@\n`;
|
|
for (let i = 0; i < lineCount; i++) {
|
|
untrackedPatch += `+${lines[i]}\n`;
|
|
}
|
|
}
|
|
|
|
const patch = [branchDiff, stagedDiff, unstagedDiff, untrackedPatch]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
if (!patch.trim()) {
|
|
console.log("No differences found");
|
|
process.exit(0);
|
|
}
|
|
|
|
const parsedEntries = parsePatchFiles(patch);
|
|
const files = parsedEntries.flatMap((e) => e.files ?? []);
|
|
|
|
if (files.length === 0) {
|
|
console.log("No file diffs found in patch");
|
|
process.exit(0);
|
|
}
|
|
|
|
// --- SSR diffs + helpers + mode dispatch ---
|
|
|
|
(async () => {
|
|
const ssrOptions = {
|
|
theme: DEFAULT_THEMES,
|
|
themeType: "system",
|
|
diffStyle: "unified",
|
|
useTokenTransformer: true,
|
|
};
|
|
|
|
const renderedFiles = [];
|
|
for (const fileDiff of files) {
|
|
try {
|
|
const result = await preloadFileDiff({
|
|
fileDiff,
|
|
options: ssrOptions,
|
|
});
|
|
renderedFiles.push({
|
|
name: fileDiff.name || "unknown",
|
|
prerenderedHTML: result.prerenderedHTML,
|
|
});
|
|
} catch (err) {
|
|
console.warn(
|
|
`Failed to SSR-render ${fileDiff.name || "unknown"}: ${err.message}`,
|
|
);
|
|
renderedFiles.push({
|
|
name: fileDiff.name || "unknown",
|
|
prerenderedHTML: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
const finalPatch = patch;
|
|
|
|
function escapeHtml(str) {
|
|
return str
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function fileId(name) {
|
|
return "DF-" + name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
}
|
|
|
|
function pageHtml(opts) {
|
|
const { mode, sidebarHtml, extraScript } = opts;
|
|
|
|
const fileSections = renderedFiles
|
|
.map((f) => {
|
|
const escapedName = escapeHtml(f.name);
|
|
const id = fileId(f.name);
|
|
if (f.prerenderedHTML !== null) {
|
|
return `
|
|
<div class="diff-file" id="${id}">
|
|
<div class="diff-file-header">${escapedName}</div>
|
|
<diffs-container>
|
|
<template shadowrootmode="open">${f.prerenderedHTML}</template>
|
|
</diffs-container>
|
|
</div>`;
|
|
}
|
|
return `
|
|
<div class="diff-file" id="${id}">
|
|
<div class="diff-file-header">${escapedName}</div>
|
|
<div class="diff-fallback"><a href="/patch">View raw patch</a></div>
|
|
</div>`;
|
|
})
|
|
.join("\n");
|
|
|
|
const hasLocal = stagedDiff || unstagedDiff || untrackedPatch;
|
|
const subtitle = hasLocal
|
|
? `${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)} (includes local changes)`
|
|
: `${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)}`;
|
|
const title = `Diff: ${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)}`;
|
|
const rawLink =
|
|
mode === "server"
|
|
? '<a class="raw-link" href="/patch">View raw patch \u2192</a>'
|
|
: "";
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="dark light">
|
|
<title>${title}</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
html, body { min-height: 100%; margin: 0; }
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
background: #f3f5f8;
|
|
color: #0f172a;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
body { background: #05070b; color: #f8fafc; }
|
|
}
|
|
.app { display: flex; flex-direction: column; height: 100vh; }
|
|
.header {
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
flex-shrink: 0;
|
|
}
|
|
@media (prefers-color-scheme: dark) { .header { border-color: #1e293b; } }
|
|
.header h1 { font-size: 1.25rem; margin: 0 0 2px; font-weight: 600; }
|
|
.subtitle { font-size: 0.8125rem; opacity: 0.7; }
|
|
.raw-link { font-size: 0.8125rem; color: #2563eb; text-decoration: none; margin-left: 12px; }
|
|
@media (prefers-color-scheme: dark) { .raw-link { color: #60a5fa; } }
|
|
.body { display: flex; flex: 1; min-height: 0; }
|
|
.sidebar {
|
|
width: 240px;
|
|
flex-shrink: 0;
|
|
border-right: 1px solid #e2e8f0;
|
|
background: #f8fafc;
|
|
overflow-y: auto;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.sidebar { background: #0b0f1a; border-color: #1e293b; }
|
|
}
|
|
.file-list { padding: 8px 0; }
|
|
.file-item {
|
|
display: block;
|
|
padding: 6px 16px;
|
|
font-size: 0.8125rem;
|
|
font-family: "SF Mono", Monaco, Consolas, monospace;
|
|
color: inherit;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.file-item:hover {
|
|
background: rgba(0,0,0,0.06);
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.file-item:hover { background: rgba(255,255,255,0.08); }
|
|
}
|
|
.content { flex: 1; overflow-y: auto; padding: 24px; }
|
|
.diff-file {
|
|
margin-bottom: 16px;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 1px solid #e2e8f0;
|
|
transition: box-shadow 0.3s;
|
|
}
|
|
.diff-file:target {
|
|
box-shadow: 0 0 0 3px #3b82f6;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.diff-file { border-color: #1e293b; }
|
|
}
|
|
.diff-file-header {
|
|
padding: 10px 16px;
|
|
font-family: "SF Mono", Monaco, Consolas, monospace;
|
|
font-size: 0.8125rem;
|
|
font-weight: 500;
|
|
background: #eef2f6;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.diff-file-header { background: #0c1320; border-color: #1e293b; }
|
|
}
|
|
.diff-fallback { padding: 24px; text-align: center; opacity: 0.6; }
|
|
.diff-fallback a { color: #2563eb; }
|
|
@media (prefers-color-scheme: dark) { .diff-fallback a { color: #60a5fa; } }
|
|
.raw-patch { font-size:0.75rem;overflow:auto;border:1px solid #e2e8f0;border-radius:6px;padding:12px;margin:8px 0 0;background:#f8fafc;line-height:1.4; }
|
|
@media (prefers-color-scheme: dark) { .raw-patch { background:#0b0f1a;border-color:#1e293b; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="header">
|
|
<h1>Diff</h1>
|
|
<div>
|
|
<span class="subtitle">${subtitle}</span>
|
|
${rawLink}
|
|
</div>
|
|
</div>
|
|
<div class="body">
|
|
${sidebarHtml}
|
|
<div class="content">${fileSections}</div>
|
|
</div>
|
|
</div>
|
|
${extraScript}
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function buildStaticHtml() {
|
|
const sidebarHtml = `
|
|
<div class="sidebar">
|
|
<div class="file-list">
|
|
${renderedFiles.map((f) => ` <a class="file-item" href="#${fileId(f.name)}">${escapeHtml(f.name)}</a>`).join("\n")}
|
|
</div>
|
|
</div>`;
|
|
|
|
const escapedPatch = escapeHtml(finalPatch);
|
|
|
|
const extraScript = ` <script>
|
|
document.querySelectorAll('.file-item').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const id = el.getAttribute('href').slice(1);
|
|
const target = document.getElementById(id);
|
|
if (target) {
|
|
target.classList.add('diff-highlight');
|
|
setTimeout(() => target.classList.remove('diff-highlight'), 1500);
|
|
}
|
|
});
|
|
});
|
|
document.getElementById('patch-toggle')?.addEventListener('click', () => {
|
|
const pre = document.getElementById('patch-content');
|
|
if (pre) pre.hidden = !pre.hidden;
|
|
});
|
|
</script>`;
|
|
|
|
return pageHtml({
|
|
mode: "static",
|
|
sidebarHtml,
|
|
extraScript,
|
|
}).replace(
|
|
"</body>",
|
|
` <details style="padding:0 24px 16px">
|
|
<summary id="patch-toggle" style="cursor:pointer;font-size:0.8125rem;color:#2563eb">View raw patch</summary>
|
|
<pre id="patch-content" class="raw-patch">${escapedPatch}</pre>
|
|
</details>\n</body>`,
|
|
);
|
|
}
|
|
|
|
// --- mode dispatch ---
|
|
|
|
if (outFile) {
|
|
const html = buildStaticHtml();
|
|
writeFileSync(outFile, html, "utf-8");
|
|
console.log(`Written to ${outFile}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (isStatic) {
|
|
const html = buildStaticHtml();
|
|
const tmpFile = join(tmpdir(), `difftool-${Date.now()}.html`);
|
|
writeFileSync(tmpFile, html, "utf-8");
|
|
|
|
const openCmd =
|
|
process.platform === "darwin"
|
|
? "open"
|
|
: process.platform === "win32"
|
|
? "start"
|
|
: "xdg-open";
|
|
console.log(`Opening ${tmpFile}`);
|
|
try {
|
|
spawn(openCmd, [tmpFile], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
}).unref();
|
|
} catch {
|
|
console.log(`HTML file saved to ${tmpFile}`);
|
|
}
|
|
} else {
|
|
const app = express();
|
|
app.use(express.static(join(__dirname, "public")));
|
|
|
|
app.get("/patch", (req, res) => {
|
|
res.type("text/plain").send(finalPatch);
|
|
});
|
|
|
|
app.get("/", (req, res) => {
|
|
const fileDataJson = JSON.stringify(
|
|
renderedFiles.map((f) => ({ name: f.name })),
|
|
).replace(/<\/script>/gi, "<\\/script>");
|
|
|
|
const sidebarHtml =
|
|
'<div class="sidebar"><div id="file-tree"></div></div>';
|
|
|
|
const extraScript = `
|
|
<script id="diff-files-data" type="application/json">${fileDataJson}</script>
|
|
<script type="module" src="/client.js"></script>`;
|
|
|
|
const html = pageHtml({
|
|
mode: "server",
|
|
sidebarHtml,
|
|
extraScript,
|
|
});
|
|
res.type("html").send(html);
|
|
});
|
|
|
|
app.listen(port, () => {
|
|
console.log(
|
|
`Difftool running at http://localhost:${port}`,
|
|
);
|
|
console.log(
|
|
`Patch endpoint: http://localhost:${port}/patch`,
|
|
);
|
|
});
|
|
}
|
|
})();
|