initial version, can start a server that serves the diff, gen an html file in tmp and open it or generate it in a specific location
This commit is contained in:
commit
872d29f3e1
6 changed files with 2409 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
public/client.js
|
||||||
39
difftool.sh
Executable file
39
difftool.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STATIC=""
|
||||||
|
OUTFILE=""
|
||||||
|
ARGS=()
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--static)
|
||||||
|
STATIC="--static"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--out)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: --out requires a file path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
OUTFILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ARGS+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#ARGS[@]} -lt 3 ]; then
|
||||||
|
echo "Usage: difftool.sh [--static] [--out <file>] <git-project-dir> <main branch> <feature branch> [port]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
node_args=("$script_dir/server.js")
|
||||||
|
[ -n "$STATIC" ] && node_args+=("$STATIC")
|
||||||
|
[ -n "$OUTFILE" ] && node_args+=("--out" "$OUTFILE")
|
||||||
|
node_args+=("${ARGS[@]}")
|
||||||
|
exec node "${node_args[@]}"
|
||||||
1969
package-lock.json
generated
Normal file
1969
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
Normal file
18
package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "difftool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@pierre/diffs": "^1.2.7",
|
||||||
|
"@pierre/trees": "^1.0.0-beta.4",
|
||||||
|
"express": "^5.1.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "esbuild src/client.js --bundle --outfile=public/client.js --format=esm",
|
||||||
|
"postinstall": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.28.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
356
server.js
Normal file
356
server.js
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
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 = 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 ---
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
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, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML template ---
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- static HTML generation (shared by --static and --out) ---
|
||||||
|
|
||||||
|
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(patch);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// -- out mode: write HTML to specified path, exit --
|
||||||
|
const html = buildStaticHtml();
|
||||||
|
writeFileSync(outFile, html, 'utf-8');
|
||||||
|
console.log(`Written to ${outFile}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStatic) {
|
||||||
|
// -- static mode: generate self-contained HTML file, open in browser, exit --
|
||||||
|
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 {
|
||||||
|
// -- server mode: start Express --
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.static(join(__dirname, 'public')));
|
||||||
|
|
||||||
|
app.get('/patch', (req, res) => {
|
||||||
|
res.type('text/plain').send(patch);
|
||||||
|
});
|
||||||
|
|
||||||
|
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`);
|
||||||
|
});
|
||||||
|
}
|
||||||
25
src/client.js
Normal file
25
src/client.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { FileTree } from '@pierre/trees';
|
||||||
|
|
||||||
|
const dataEl = document.getElementById('diff-files-data');
|
||||||
|
const files = JSON.parse(dataEl.textContent);
|
||||||
|
const paths = files.map(f => f.name);
|
||||||
|
|
||||||
|
const mount = document.getElementById('file-tree');
|
||||||
|
mount.style.height = (window.innerHeight - mount.getBoundingClientRect().top) + 'px';
|
||||||
|
|
||||||
|
const tree = new FileTree({
|
||||||
|
paths,
|
||||||
|
initialExpansion: 'open',
|
||||||
|
onSelectionChange: (selectedPaths) => {
|
||||||
|
if (selectedPaths.length !== 1) return;
|
||||||
|
const id = 'DF-' + selectedPaths[0].replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
el.classList.add('diff-highlight');
|
||||||
|
setTimeout(() => el.classList.remove('diff-highlight'), 1500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tree.render({ fileTreeContainer: mount });
|
||||||
Loading…
Add table
Add a link
Reference in a new issue