difftool/server.js

356 lines
11 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 = 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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`);
});
}