Interfaz web de Kotej, con la misma UX y branding que la app de macOS

Mismo layout que la ventana de escritorio: los dos lados arriba, barra de
control, árbol y, debajo, las diferencias del fichero seleccionado. Mismas
cadenas (inglés y español, según el idioma del navegador), mismos colores de
estado y el mismo logo.

- pestañas, atajos (⌘T/⌘W/⌘F, siguiente/anterior diferencia) y ventana Acerca de
- árbol con chevron a ambos lados, tamaño por lado e icono del modo aplicado
- filtros, búsqueda, modos de comparación y tolerancias de texto
- diff lado a lado con resaltado de los caracteres que cambian y leyenda por
  tipo de cambio (contenido / comentarios / espacios)
- carpetas y ficheros por selector o arrastrando; soltar dos rellena ambos lados
- comparar texto pegado sin ficheros

El árbol y el diff sólo tienen en el DOM las filas visibles: un EAR se aplana a
decenas de miles y dibujarlas todas hace que el scroll se arrastre.

Prueba en Chrome real (tests/smoke.mjs, 26 comprobaciones) porque compilar no
demuestra que la página funcione. Encontró dos fallos de layout reales: sin
min-height:0 la comparación se montaba sobre las pestañas, y un `display: flex`
propio ganaba al atributo `hidden`, dejando paneles invisibles ocupando sitio.

Closes #7

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
This commit is contained in:
alexandrev-tibco
2026-07-31 20:40:32 +02:00
parent 3754abe694
commit 5d4b339b33
17 changed files with 2721 additions and 1 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules/
dist/
tests/smoke.png
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kotej</title>
<meta name="description" content="Compare folders, files and archives (ZIP, JAR, EAR) in your browser. Nothing is uploaded." />
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="favicon.png" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+48
View File
@@ -11,6 +11,7 @@
"@types/jsdom": "^28.0.3",
"fflate": "^0.8.2",
"jsdom": "^25.0.0",
"playwright": "1.55",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.0"
@@ -1762,6 +1763,53 @@
"dev": true,
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.55.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+3 -1
View File
@@ -7,12 +7,14 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run"
"test": "vitest run",
"smoke": "node tests/smoke.mjs"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
"fflate": "^0.8.2",
"jsdom": "^25.0.0",
"playwright": "1.55",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+142
View File
@@ -0,0 +1,142 @@
/**
* Same strings as the macOS app, keyed by their English text so both stay in
* step. The browser's language plays the role the OS language does there.
*/
const es: Record<string, string> = {
'New comparison': 'Comparación nueva',
'New tab': 'Pestaña nueva',
'Close tab': 'Cerrar pestaña',
'Swap sides': 'Intercambiar lados',
'Compare again': 'Volver a comparar',
Left: 'Izquierda',
Right: 'Derecha',
'Drag or choose…': 'Arrastra o elige…',
Choose: 'Elegir',
'Choose folder': 'Elegir carpeta',
'Choose file': 'Elegir fichero',
Automatic: 'Automática',
'Automatic picks the comparison that suits each file type':
'Automática usa la comparación propia de cada tipo de fichero',
'Byte for byte': 'Byte a byte',
Text: 'Texto',
'Semantic (JSON/XML)': 'Semántica (JSON/XML)',
'Empty comparison': 'Comparación vacía',
'Drag two folders, files or archives (ZIP, JAR, EAR) to start comparing.':
'Arrastra dos carpetas, ficheros o archivos (ZIP, JAR, EAR) para empezar a comparar.',
'Nothing leaves your browser: the files are read on this device.':
'Nada sale de tu navegador: los ficheros se leen en este dispositivo.',
Status: 'Estado',
same: 'igual',
equivalent: 'equivalente',
different: 'distinto',
'only left': 'solo izq.',
'only right': 'solo der.',
pending: 'pendiente',
Same: 'Iguales',
Different: 'Distintos',
'Only left': 'Solo izquierda',
'Only right': 'Solo derecha',
Unresolved: 'Sin resolver',
'No differences': 'Sin diferencias',
'Comparing…': 'Comparando…',
'Select a file to see its differences': 'Selecciona un fichero para ver las diferencias',
'Nothing to show': 'Sin contenido que mostrar',
'Archive: expand it in the tree to see its contents.':
'Archivo comprimido: despliégalo en el árbol para ver su contenido.',
'Folder: select a file inside it.': 'Carpeta: selecciona un fichero dentro.',
'Only exists on the right.': 'Solo existe en la derecha.',
'Only exists on the left.': 'Solo existe en la izquierda.',
"Couldn't read the contents.": 'No se pudo leer el contenido.',
'Identical binary': 'Binario idéntico',
'Different binary': 'Binario distinto',
bytes: 'bytes',
Everything: 'Todo',
'Only differences': 'Solo diferencias',
'Only changed files': 'Solo ficheros cambiados',
'Only on the left': 'Solo en la izquierda',
'Only on the right': 'Solo en la derecha',
'Only on one side': 'Solo en un lado',
'Which rows the tree shows': 'Qué filas muestra el árbol',
'Nothing matches this filter': 'Nada coincide con este filtro',
'Wrap lines': 'Ajustar líneas',
'Filter by name': 'Filtrar por nombre',
'Comparison options': 'Opciones de comparación',
'Next difference': 'Diferencia siguiente',
'Previous difference': 'Diferencia anterior',
Size: 'Tamaño',
'Copy path': 'Copiar ruta',
'Text comparison': 'Comparación de texto',
'Ignore trailing whitespace': 'Ignorar espacios al final',
'Ignore all whitespace': 'Ignorar todos los espacios',
'Ignore case': 'Ignorar mayúsculas',
'Ignore blank lines': 'Ignorar líneas en blanco',
'Treat CRLF and LF as equal': 'Tratar CRLF y LF como iguales',
'Applies to text files; JSON and XML also compare structurally.':
'Se aplica a ficheros de texto; JSON y XML además se comparan estructuralmente.',
content: 'contenido',
comments: 'comentarios',
whitespace: 'espacios',
added: 'añadidas',
removed: 'borradas',
lines: 'líneas',
'Mark left for comparison': 'Marcar izquierda para comparar',
'Mark right for comparison': 'Marcar derecha para comparar',
'Marked for comparison:': 'Marcado para comparar:',
Forget: 'Olvidar',
'Compared byte for byte': 'Comparado byte a byte',
'Compared as text': 'Comparado como texto',
'Compared structurally (JSON/XML)': 'Comparado estructuralmente (JSON/XML)',
'Set as base': 'Usar como base',
'Set as base in a new tab': 'Usar como base en pestaña nueva',
'Download left': 'Descargar izquierda',
'Download right': 'Descargar derecha',
'Compare pasted text instead': 'Comparar texto pegado',
'New text comparison': 'Comparación de texto nueva',
'Paste here…': 'Pega aquí…',
'Paste something on each side to compare.': 'Pega algo en cada lado para comparar.',
Clear: 'Limpiar',
Binary: 'Binario',
'Expand all': 'Desplegar todo',
'Collapse all': 'Plegar todo',
'Resolve': 'Resolver',
'Too big to compare automatically': 'Demasiado grande para comparar automáticamente',
'About Kotej': 'Acerca de Kotej',
Close: 'Cerrar',
'Open source': 'Código abierto',
'Also available as a native macOS app.': 'También disponible como app nativa de macOS.',
'Your files never leave this device: everything is compared in the browser.':
'Tus ficheros nunca salen de este dispositivo: todo se compara en el navegador.',
};
const spanish = (navigator.languages ?? [navigator.language ?? 'en'])
.some((tag) => tag.toLowerCase().startsWith('es'));
/** Translates a key; unknown keys fall through as their English text. */
export function t(key: string): string {
return spanish ? (es[key] ?? key) : key;
}
export const locale = spanish ? 'es' : 'en';
export function formatBytes(size: number): string {
if (size < 1024) return `${size} B`;
const units = ['KB', 'MB', 'GB'];
let value = size / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
return `${value.toLocaleString(locale, { maximumFractionDigits: 1 })} ${units[unit]}`;
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Tabs, keyboard shortcuts and the About panel — everything above a single
* comparison. Mirrors the macOS window, which is one tab bar over one
* comparison view at a time.
*/
import './styles.css';
import { ComparisonView } from './ui/comparison';
import type { Node } from './engine/tree';
import { t } from './i18n';
import { icon } from './ui/tree';
const root = document.getElementById('app')!;
const tabBar = document.createElement('div');
tabBar.className = 'tabbar';
const stack = document.createElement('div');
stack.className = 'stack';
root.append(tabBar, stack);
const tabs: ComparisonView[] = [];
let current = 0;
function newTab(): ComparisonView {
const view = new ComparisonView(openInNewTab, renderTabs);
tabs.push(view);
stack.append(view.element);
select(tabs.length - 1);
return view;
}
function openInNewTab(left: Node, right: Node) {
void newTab().model.setBase(left, right);
}
function select(index: number) {
current = Math.max(0, Math.min(index, tabs.length - 1));
tabs.forEach((tab, i) => { tab.element.hidden = i !== current; });
renderTabs();
}
function closeTab(index: number) {
// Closing the only tab leaves an empty one: a window with no comparison at
// all has nothing to show and no way back.
if (tabs.length === 1) {
tabs[0].element.remove();
tabs.length = 0;
newTab();
return;
}
tabs[index].element.remove();
tabs.splice(index, 1);
select(current > index ? current - 1 : current);
}
function renderTabs() {
const children: HTMLElement[] = [];
const brand = document.createElement('div');
brand.className = 'brand';
const logo = document.createElement('img');
logo.src = 'logo.png';
logo.alt = 'Kotej';
brand.append(logo);
children.push(brand);
tabs.forEach((tab, index) => {
const item = document.createElement('div');
item.className = `tab${index === current ? ' active' : ''}`;
const label = document.createElement('span');
label.textContent = tab.title;
label.title = tab.title;
item.append(label);
const close = document.createElement('button');
close.type = 'button';
close.className = 'tab-close';
close.title = t('Close tab');
close.append(icon('close'));
close.addEventListener('click', (event) => { event.stopPropagation(); closeTab(index); });
item.append(close);
item.addEventListener('click', () => select(index));
children.push(item);
});
const add = document.createElement('button');
add.type = 'button';
add.className = 'tab-add';
add.title = t('New tab');
add.append(icon('plus'));
add.addEventListener('click', () => newTab());
children.push(add);
const spacer = document.createElement('div');
spacer.className = 'spacer';
children.push(spacer);
const about = document.createElement('button');
about.type = 'button';
about.className = 'icon-button';
about.title = t('About Kotej');
about.append(icon('info'));
about.addEventListener('click', showAbout);
children.push(about);
tabBar.replaceChildren(...children);
}
function showAbout() {
const overlay = document.createElement('div');
overlay.className = 'overlay';
const panel = document.createElement('div');
panel.className = 'about';
const logo = document.createElement('img');
logo.src = 'logo.png';
logo.alt = '';
const title = document.createElement('h2');
title.textContent = 'Kotej';
const privacy = document.createElement('p');
privacy.textContent = t('Your files never leave this device: everything is compared in the browser.');
const native = document.createElement('p');
native.className = 'note';
native.textContent = t('Also available as a native macOS app.');
const source = document.createElement('a');
source.href = 'https://gitea.alexandre-vazquez.cloud/alexandrev/kotej';
source.textContent = t('Open source');
source.target = '_blank';
source.rel = 'noreferrer';
const close = document.createElement('button');
close.type = 'button';
close.textContent = t('Close');
close.addEventListener('click', () => overlay.remove());
panel.append(logo, title, privacy, native, source, close);
overlay.append(panel);
overlay.addEventListener('click', (event) => {
if (event.target === overlay) overlay.remove();
});
document.body.append(overlay);
}
// MARK: Shortcuts — the same ones as the desktop app.
window.addEventListener('keydown', (event) => {
const meta = event.metaKey || event.ctrlKey;
const typing = event.target instanceof HTMLInputElement
|| event.target instanceof HTMLTextAreaElement;
if (meta && event.key === 't') { event.preventDefault(); newTab(); return; }
if (meta && event.key === 'w') { event.preventDefault(); closeTab(current); return; }
if (meta && event.key === 'f') { event.preventDefault(); tabs[current].focusSearch(); return; }
if (typing) return;
if ((meta && event.key === 'ArrowDown') || event.key === 'F3' || event.key === 'n') {
event.preventDefault();
tabs[current].model.selectNextDifference();
} else if ((meta && event.key === 'ArrowUp')
|| (event.key === 'F3' && event.shiftKey) || event.key === 'p') {
event.preventDefault();
tabs[current].model.selectPreviousDifference();
}
});
// The browser's own drop behaviour is to navigate away to the file, which would
// throw the comparison away.
window.addEventListener('dragover', (event) => event.preventDefault());
window.addEventListener('drop', (event) => event.preventDefault());
document.title = 'Kotej';
newTab();
// A handle for the end-to-end test, which drives real comparisons through the
// model. There's no server and no secret here, so exposing it costs nothing.
(window as unknown as { __kotej: unknown }).__kotej = { tabs };
+322
View File
@@ -0,0 +1,322 @@
/**
* State for one comparison tab: what's on each side, the resulting tree, and
* everything the UI can adjust (filter, mode, text tolerances, expansion).
*
* Mirrors ComparisonModel from the macOS app, minus what only makes sense there
* (writing files back, revealing in Finder).
*/
import {
compareTrees, scanFiles, scanFile, resolve as resolveNode, totals,
isDifference, type DiffNode, type Node, type DiffStatus, type Totals,
} from './engine/tree';
import { defaultTextOptions, type ComparisonMode, type TextOptions } from './engine/content';
export type Side = 'left' | 'right';
export type RowFilter =
| 'all' | 'differences' | 'changed' | 'orphansLeft' | 'orphansRight' | 'orphans';
export const ROW_FILTERS: Array<{ id: RowFilter; label: string }> = [
{ id: 'all', label: 'Everything' },
{ id: 'differences', label: 'Only differences' },
{ id: 'changed', label: 'Only changed files' },
{ id: 'orphansLeft', label: 'Only on the left' },
{ id: 'orphansRight', label: 'Only on the right' },
{ id: 'orphans', label: 'Only on one side' },
];
export function filterMatches(filter: RowFilter, status: DiffStatus): boolean {
switch (filter) {
case 'all': return true;
case 'differences': return isDifference(status);
// Pending counts here: it's undecided, not proven equal.
case 'changed': return status === 'different' || status === 'pending';
case 'orphansLeft': return status === 'onlyLeft';
case 'orphansRight': return status === 'onlyRight';
case 'orphans': return status === 'onlyLeft' || status === 'onlyRight';
}
}
/** One file or folder the user picked, however they picked it. */
export interface SideInput {
name: string;
/** A folder's files carry their relative path; a lone file is just itself. */
files?: Array<{ file: File; relativePath: string }>;
file?: File;
}
export interface FlatRow {
node: DiffNode;
depth: number;
hasChildren: boolean;
expanded: boolean;
}
export class ComparisonModel {
left: SideInput | null = null;
right: SideInput | null = null;
leftTree: Node | null = null;
rightTree: Node | null = null;
root: DiffNode | null = null;
filter: RowFilter = 'all';
searchText = '';
mode: ComparisonMode | null = null;
textOptions: TextOptions = { ...defaultTextOptions };
expanded = new Set<string>();
selection: string | null = null;
isScanning = false;
errorMessage: string | null = null;
/** Left half of a manual pairing: the node marked while picking a new base. */
pendingPick: { side: Side; node: Node; name: string } | null = null;
/** Compare two pasted snippets, with no files at all. */
showsTextCompare = false;
leftText = '';
rightText = '';
/** Stamped on each scan so a slow, superseded one can't overwrite a newer. */
private scanGeneration = 0;
onChange: () => void = () => {};
get isReady(): boolean { return this.left !== null && this.right !== null; }
get totals(): Totals {
return this.root ? totals(this.root)
: { identical: 0, different: 0, onlyLeft: 0, onlyRight: 0, pending: 0 };
}
setSide(side: Side, input: SideInput) {
if (side === 'left') this.left = input; else this.right = input;
this.selection = null;
this.compare();
}
/**
* Sets both sides at once. Doing this as two separate calls would kick off a
* comparison against the old other side, and a slow one could land last.
*/
setBoth(left: SideInput, right: SideInput) {
this.left = left;
this.right = right;
this.selection = null;
this.compare();
}
swapSides() {
const left = this.left;
this.left = this.right;
this.right = left;
this.selection = null;
this.compare();
}
clear() {
this.left = this.right = null;
this.leftTree = this.rightTree = null;
this.root = null;
this.selection = null;
this.expanded.clear();
this.onChange();
}
applyTextOptions(options: TextOptions) {
this.textOptions = options;
if (this.isReady) this.compare();
}
async compare() {
if (!this.left || !this.right) { this.onChange(); return; }
const generation = ++this.scanGeneration;
this.isScanning = true;
this.errorMessage = null;
this.showsTextCompare = false;
this.onChange();
try {
const [leftTree, rightTree] = await Promise.all([
buildTree(this.left), buildTree(this.right),
]);
const root = await compareTrees(leftTree, rightTree, {
mode: this.mode ?? undefined, textOptions: this.textOptions,
});
if (generation !== this.scanGeneration) return; // superseded
this.leftTree = leftTree;
this.rightTree = rightTree;
this.root = root;
// Top level open by default, as on the desktop: a collapsed root tells
// you nothing about what you just compared.
this.expanded = new Set(root.children.filter((c) => c.isDirectory).map((c) => c.path));
this.expanded.add(root.path);
} catch (error) {
if (generation !== this.scanGeneration) return;
this.errorMessage = error instanceof Error ? error.message : String(error);
this.root = null;
} finally {
if (generation === this.scanGeneration) {
this.isScanning = false;
this.onChange();
}
}
}
/** Compares two nodes already on screen, as a new base for this tab. */
async setBase(left: Node, right: Node) {
const generation = ++this.scanGeneration;
this.isScanning = true;
this.pendingPick = null;
this.selection = null;
this.onChange();
try {
const root = await compareTrees(left, right, {
mode: this.mode ?? undefined, textOptions: this.textOptions,
});
if (generation !== this.scanGeneration) return;
this.left = { name: left.name };
this.right = { name: right.name };
this.leftTree = left;
this.rightTree = right;
this.root = root;
this.expanded = new Set(root.children.filter((c) => c.isDirectory).map((c) => c.path));
} finally {
if (generation === this.scanGeneration) {
this.isScanning = false;
this.onChange();
}
}
}
/** Reads a file left pending because it was too big to compare eagerly. */
async resolvePending(node: DiffNode) {
if (!node.left || !node.right) return;
const outcome = await resolveNode(node.left, node.right, {
mode: this.mode ?? undefined, textOptions: this.textOptions,
});
node.status = outcome.status;
node.appliedMode = outcome.mode;
this.onChange();
}
toggleExpanded(path: string) {
if (this.expanded.has(path)) this.expanded.delete(path); else this.expanded.add(path);
this.onChange();
}
expandAll() {
const walk = (node: DiffNode) => {
if (node.isDirectory) { this.expanded.add(node.path); node.children.forEach(walk); }
};
if (this.root) walk(this.root);
this.onChange();
}
collapseAll() {
this.expanded.clear();
this.onChange();
}
// MARK: Rows
/**
* The tree flattened to what's actually on screen. A folder survives a filter
* when something inside it does — hiding the parent would hide the match.
*/
get visibleRows(): FlatRow[] {
if (!this.root) return [];
const needle = this.searchText.trim().toLowerCase();
const rows: FlatRow[] = [];
const keeps = (node: DiffNode): boolean => {
const nameMatches = !needle || node.name.toLowerCase().includes(needle);
if (node.isDirectory) {
return node.children.some(keeps) || (nameMatches && this.filter === 'all');
}
return nameMatches && filterMatches(this.filter, node.status);
};
const walk = (node: DiffNode, depth: number) => {
const children = node.children.filter(keeps);
const expanded = this.expanded.has(node.path);
rows.push({ node, depth, hasChildren: children.length > 0, expanded });
if (expanded) children.forEach((child) => walk(child, depth + 1));
};
// The root itself isn't a row: it's already named in the header, and a row
// for "the whole thing" would only push everything a level to the right.
this.root.children.filter(keeps).forEach((child) => walk(child, 0));
return rows;
}
find(path: string): DiffNode | null {
const search = (node: DiffNode): DiffNode | null => {
if (node.path === path) return node;
for (const child of node.children) {
const found = search(child);
if (found) return found;
}
return null;
};
return this.root ? search(this.root) : null;
}
get selectedNode(): DiffNode | null {
return this.selection ? this.find(this.selection) : null;
}
select(path: string) {
this.selection = path;
this.onChange();
}
/** Every differing file, in tree order: what next/previous walks. */
get differences(): DiffNode[] {
const out: DiffNode[] = [];
const walk = (node: DiffNode) => {
if (node.isDirectory) { node.children.forEach(walk); return; }
if (isDifference(node.status)) out.push(node);
};
if (this.root) walk(this.root);
return out;
}
get currentDifferenceIndex(): number | null {
const index = this.differences.findIndex((node) => node.path === this.selection);
return index >= 0 ? index : null;
}
selectNextDifference() { this.step(1); }
selectPreviousDifference() { this.step(-1); }
private step(delta: number) {
const list = this.differences;
if (!list.length) return;
const current = this.currentDifferenceIndex;
const next = current === null
? (delta > 0 ? 0 : list.length - 1)
: (current + delta + list.length) % list.length;
const target = list[next];
this.revealAncestors(target.path);
this.selection = target.path;
this.onChange();
}
/** Opens every folder above a row so jumping to it can't land out of view. */
private revealAncestors(path: string) {
const open = (node: DiffNode): boolean => {
if (node.path === path) return true;
if (node.children.some(open)) { this.expanded.add(node.path); return true; }
return false;
};
if (this.root) open(this.root);
}
}
async function buildTree(input: SideInput): Promise<Node> {
if (input.files) return scanFiles(input.files, input.name);
if (input.file) return scanFile(input.file);
throw new Error(`nothing to read for ${input.name}`);
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Getting files and folders into the page. Everything stays local: these are
* plain `File` handles, nothing is uploaded.
*
* Two routes, because no single one works everywhere: `<input>` (with
* `webkitdirectory` for folders) covers every browser, and drag-and-drop uses
* the entries API so a dropped folder arrives as a folder rather than as
* nothing at all.
*/
import type { SideInput } from './model';
export function chooseFile(): Promise<SideInput | null> {
return pick(false);
}
export function chooseFolder(): Promise<SideInput | null> {
return pick(true);
}
function pick(directory: boolean): Promise<SideInput | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
if (directory) input.webkitdirectory = true;
input.style.display = 'none';
document.body.append(input);
let settled = false;
const finish = (value: SideInput | null) => {
if (settled) return;
settled = true;
input.remove();
resolve(value);
};
input.addEventListener('change', () => {
const files = Array.from(input.files ?? []);
if (!files.length) { finish(null); return; }
finish(directory ? folderInput(files) : { name: files[0].name, file: files[0] });
});
// Cancelling the dialog fires nothing in older browsers; without this the
// promise would never settle and the button would stay dead.
input.addEventListener('cancel', () => finish(null));
input.click();
});
}
/**
* Turns a directory pick into a side. `webkitRelativePath` includes the chosen
* folder itself, which we strip so both sides are rooted at the same level and
* pair up by name.
*/
function folderInput(files: File[]): SideInput {
const first = files[0].webkitRelativePath || files[0].name;
const rootName = first.split('/')[0];
return {
name: rootName,
files: files.map((file) => ({
file,
relativePath: (file.webkitRelativePath || file.name).slice(rootName.length + 1)
|| file.name,
})),
};
}
// MARK: Drag and drop
interface FileSystemEntryLike {
isFile: boolean;
isDirectory: boolean;
name: string;
fullPath: string;
file(callback: (file: File) => void, error: (e: unknown) => void): void;
createReader(): { readEntries(cb: (entries: FileSystemEntryLike[]) => void,
error: (e: unknown) => void): void };
}
/** Reads a drop into one side per dropped item, keeping the order they came in. */
export async function readDrop(event: DragEvent): Promise<SideInput[]> {
const items = Array.from(event.dataTransfer?.items ?? [])
.filter((item) => item.kind === 'file');
if (items.length) {
// The DOM's FileSystemEntry type omits `file` and `createReader`, which live
// on the FileSystemFileEntry / FileSystemDirectoryEntry subtypes; this
// interface is the union we actually branch on.
const entries = items.map((item) =>
item.webkitGetAsEntry() as FileSystemEntryLike | null);
const sides = await Promise.all(entries.map(async (entry, index) => {
if (entry) return entryInput(entry);
const file = items[index].getAsFile();
return file ? { name: file.name, file } : null;
}));
return sides.filter((side): side is SideInput => side !== null);
}
// No entries API: plain files are still better than refusing the drop.
return Array.from(event.dataTransfer?.files ?? []).map((file) => ({ name: file.name, file }));
}
async function entryInput(entry: FileSystemEntryLike): Promise<SideInput> {
if (entry.isFile) {
return { name: entry.name, file: await fileOf(entry) };
}
const files: Array<{ file: File; relativePath: string }> = [];
await walk(entry, '', files);
return { name: entry.name, files };
}
async function walk(directory: FileSystemEntryLike, prefix: string,
out: Array<{ file: File; relativePath: string }>) {
for (const entry of await readEntries(directory)) {
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isFile) {
out.push({ file: await fileOf(entry), relativePath: path });
} else {
await walk(entry, path, out);
}
}
}
/** readEntries returns a batch at a time; keep asking until it comes back empty. */
function readEntries(directory: FileSystemEntryLike): Promise<FileSystemEntryLike[]> {
return new Promise((resolve, reject) => {
const reader = directory.createReader();
const all: FileSystemEntryLike[] = [];
const next = () => reader.readEntries((batch) => {
if (!batch.length) { resolve(all); return; }
all.push(...batch);
next();
}, reject);
next();
});
}
function fileOf(entry: FileSystemEntryLike): Promise<File> {
return new Promise((resolve, reject) => entry.file(resolve, reject));
}
+570
View File
@@ -0,0 +1,570 @@
/*
* Deliberately close to the macOS app: system font, the same greys, the same
* status colours. Someone who uses both shouldn't have to relearn anything.
*/
:root {
--bg: #ffffff;
--bar: #f4f4f5;
--border: #d8d8dc;
--text: #1d1d1f;
--secondary: #6e6e73;
--tertiary: #a1a1a6;
--accent: #0a72e8;
--selection: #d8e8fb;
--alt-row: #fafafa;
--same: #8e8e93;
--different: #e08b12;
--only-left: #2f7fe0;
--only-right: #8b5cd6;
--pending: #a1a1a6;
--added: #2ba24c;
--removed: #d64541;
--comment: #6155cf;
--whitespace: #1c9aa6;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1c1c1e;
--bar: #262629;
--border: #3a3a3d;
--text: #f2f2f7;
--secondary: #9a9aa0;
--tertiary: #6e6e73;
--accent: #4d9dff;
--selection: #1e3a5c;
--alt-row: #202023;
}
}
* { box-sizing: border-box; }
/* Any `display` we set would otherwise beat the hidden attribute, leaving
invisible panes that still take up their space. */
[hidden] { display: none !important; }
html, body, #app {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--bg);
color: var(--text);
font-size: 13px;
}
#app { display: flex; flex-direction: column; }
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
button:disabled { opacity: 0.35; cursor: default; }
select {
font: inherit;
color: inherit;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 6px;
max-width: 200px;
}
.icon { width: 15px; height: 15px; fill: none; stroke: currentColor;
stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 6px;
color: var(--secondary);
}
.icon-button:hover:not(:disabled) { background: rgb(127 127 127 / 0.15); color: var(--text); }
.spacer { flex: 1; }
.link { color: var(--accent); padding: 2px 4px; }
.link:hover { text-decoration: underline; }
.placeholder {
display: flex;
flex: 1;
gap: 8px;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--secondary);
padding: 20px;
text-align: center;
}
.placeholder-icon { width: 30px; height: 30px; color: var(--tertiary); }
.spinner {
width: 18px; height: 18px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* MARK: Tabs */
.tabbar {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 10px;
background: var(--bar);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.brand { display: flex; align-items: center; padding-inline-end: 6px; }
.brand img { width: 18px; height: 18px; }
.tab {
display: flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 4px 8px;
border-radius: 6px;
color: var(--secondary);
cursor: default;
user-select: none;
}
.tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tab:hover { background: rgb(127 127 127 / 0.12); }
.tab.active { background: var(--bg); color: var(--text); box-shadow: 0 0 0 1px var(--border); }
.tab-close { display: flex; opacity: 0; padding: 1px; border-radius: 4px; }
.tab:hover .tab-close, .tab.active .tab-close { opacity: 0.55; }
.tab-close:hover { opacity: 1; background: rgb(127 127 127 / 0.2); }
.tab-add { display: flex; padding: 4px; border-radius: 6px; color: var(--secondary); }
.tab-add:hover { background: rgb(127 127 127 / 0.15); }
/* min-height: 0 matters: without it a tall comparison grows past the window
instead of scrolling, and rides up over the tab bar. */
.stack { flex: 1; min-height: 0; display: flex; overflow: hidden; }
.comparison {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.comparison[hidden] { display: none; }
.comparison.dropping { outline: 2px solid var(--accent); outline-offset: -2px; }
/* MARK: Sides */
.sides {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.side-field {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
padding: 5px 8px;
border: 1px dashed var(--border);
border-radius: 8px;
background: var(--bar);
}
.side-field.filled { border-style: solid; }
.side-field.targeted { border-color: var(--accent); background: rgb(10 114 232 / 0.12); }
.side-icon { color: var(--tertiary); flex: 0 0 auto; }
.side-field.filled .side-icon { color: var(--accent); }
.side-labels { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.side-caption { font-size: 10px; color: var(--secondary); }
.side-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Truncate from the head, since the tail of a path is the telling part —
but keep the text itself left-aligned. */
direction: rtl;
text-align: left;
}
.side-field:not(.filled) .side-name { color: var(--secondary); }
/* MARK: Control bar */
.controls {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bar);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.search {
display: flex;
align-items: center;
gap: 5px;
width: 220px;
padding: 2px 7px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.search-icon { width: 13px; height: 13px; color: var(--secondary); }
.search input {
font: inherit;
color: inherit;
background: none;
border: none;
outline: none;
width: 100%;
padding: 2px 0;
}
.position { font-family: var(--mono); font-size: 11px; color: var(--secondary); }
/* MARK: Banners */
.banner {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
font-size: 12px;
}
.banner.error { background: rgb(224 139 18 / 0.14); color: var(--different); }
.banner.pin { background: rgb(10 114 232 / 0.1); }
/* MARK: Split */
.body { flex: 1; min-height: 0; display: flex; }
.split { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.split-top { min-height: 60px; display: flex; overflow: hidden; }
.split-bottom { flex: 1; min-height: 60px; display: flex; overflow: hidden; }
.split-divider {
flex: 0 0 6px;
cursor: row-resize;
background: var(--bar);
border-block: 1px solid var(--border);
}
.split-divider:hover { background: var(--accent); }
/* MARK: Tree */
.tree { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tree-header,
.tree-row {
display: grid;
/* Each side gets exactly half the width, so neither one dominates. */
grid-template-columns: 1fr 1fr 26px 96px;
align-items: center;
}
.tree-header {
padding: 5px 12px;
font-size: 11px;
font-weight: 600;
color: var(--secondary);
background: var(--bar);
border-bottom: 1px solid var(--border);
gap: 0;
}
.tree-header-name, .tree-header-size, .tree-header-status {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tree-header-name { padding-inline-start: 4px; }
.tree-header-size { display: none; }
.tree-header-status { text-align: end; }
.tree-scroller { flex: 1; overflow: auto; }
.tree-spacer { position: relative; }
.tree-rows { position: absolute; inset-inline: 0; top: 0; }
.tree-row {
height: 24px;
padding: 0 12px;
cursor: default;
user-select: none;
}
.tree-row.alternate { background: var(--alt-row); }
.tree-row:hover { background: rgb(127 127 127 / 0.08); }
.tree-row.selected { background: var(--selection); }
.tree-side { display: flex; align-items: center; min-width: 0; gap: 4px; }
.tree-side.right { border-inline-start: 1px solid var(--border); padding-inline-start: 6px; }
.tree-name { display: flex; align-items: center; gap: 3px; min-width: 0; flex: 1; }
.tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-side.missing { opacity: 0.3; }
.tree-size {
flex: 0 0 auto;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--secondary);
padding-inline: 6px;
}
.row-icon { width: 13px; height: 13px; color: var(--secondary); flex: 0 0 auto; }
.chevron {
display: flex;
padding: 0;
color: var(--secondary);
transition: transform 0.12s ease;
flex: 0 0 auto;
}
.chevron .icon { width: 11px; height: 11px; }
.chevron.open { transform: rotate(90deg); }
.chevron-gap { width: 11px; flex: 0 0 auto; }
.tree-mode {
font-family: var(--mono);
font-size: 9px;
text-align: center;
color: var(--secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 0;
}
.tree-mode:empty { border: none; }
.tree-status {
font-size: 11px;
text-align: end;
color: var(--secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge-different { color: var(--different); font-weight: 600; }
.badge-onlyLeft { color: var(--only-left); }
.badge-onlyRight { color: var(--only-right); }
.badge-pending { color: var(--pending); font-style: italic; }
/* MARK: File diff */
.filediff { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.filediff-header {
display: flex;
align-items: center;
gap: 12px;
padding: 5px 12px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
min-height: 28px;
}
.filediff-title {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
flex: 1;
font-weight: 500;
}
.filediff-title span {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left;
}
.filediff-scroller { flex: 1; overflow: auto; }
.filediff-spacer { position: relative; }
/* Only the virtualised list inside .filediff is positioned; the same class is
reused for pasted text, which is a plain flow of lines. */
.filediff.nowrap .filediff-lines { position: absolute; inset-inline: 0; top: 0; }
.diff-line {
display: grid;
grid-template-columns: 1fr 1fr;
font-family: var(--mono);
font-size: 11px;
line-height: 18px;
}
.diff-cell { display: flex; gap: 8px; min-width: 0; padding-inline: 6px; }
.diff-cell.right { border-inline-start: 1px solid var(--border); }
.diff-number {
flex: 0 0 42px;
text-align: end;
color: var(--tertiary);
user-select: none;
}
.diff-text { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }
.filediff.nowrap .diff-text { white-space: pre; overflow: hidden; text-overflow: ellipsis; }
.diff-text mark { background: rgb(224 139 18 / 0.45); color: inherit; font-weight: 600; }
.kind-changed .diff-cell { background: rgb(224 139 18 / 0.12); }
.kind-changed.change-comment .diff-cell { background: rgb(97 85 207 / 0.12); }
.kind-changed.change-comment .diff-text mark { background: rgb(97 85 207 / 0.4); }
.kind-changed.change-whitespace .diff-cell { background: rgb(28 154 166 / 0.12); }
.kind-changed.change-whitespace .diff-text mark { background: rgb(28 154 166 / 0.4); }
.kind-removed .diff-cell.left { background: rgb(214 69 65 / 0.16); }
.kind-removed .diff-cell.right { background: rgb(127 127 127 / 0.06); }
.kind-added .diff-cell.right { background: rgb(43 162 76 / 0.16); }
.kind-added .diff-cell.left { background: rgb(127 127 127 / 0.06); }
.legend { display: flex; gap: 10px; font-size: 10px; color: var(--secondary); }
.legend-item { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
.legend-item i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
.legend-content i { background: var(--different); }
.legend-comment i { background: var(--comment); }
.legend-space i { background: var(--whitespace); }
.legend-added i { background: var(--added); }
.legend-removed i { background: var(--removed); }
.checkbox { display: flex; align-items: center; gap: 5px; font-size: 11px; white-space: nowrap; }
/* MARK: Empty state */
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
text-align: center;
}
.empty-logo { width: 72px; height: 72px; opacity: 0.9; }
.empty h2 { margin: 0; font-size: 17px; }
.empty p { margin: 0; color: var(--secondary); max-width: 460px; }
.note { font-size: 11px; color: var(--tertiary); }
.wells { display: flex; gap: 14px; width: min(560px, 100%); margin-top: 8px; }
.well {
flex: 1;
min-height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 1.5px dashed var(--border);
border-radius: 12px;
background: var(--bar);
cursor: pointer;
padding: 10px;
}
.well span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.well:hover, .well.targeted { border-color: var(--accent); background: rgb(10 114 232 / 0.1); }
.well-icon { width: 22px; height: 22px; color: var(--tertiary); }
.well.filled .well-icon { color: var(--added); }
/* MARK: Pasted text */
.textcompare { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.textcompare-panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; height: 38%; }
.textcompare-panes textarea {
font-family: var(--mono);
font-size: 11px;
border: none;
outline: none;
resize: none;
padding: 8px;
background: var(--bg);
color: var(--text);
border-inline-end: 1px solid var(--border);
}
.textcompare-bar {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
padding: 5px 12px;
background: var(--bar);
border-block: 1px solid var(--border);
}
.textcompare-result { flex: 1; overflow: auto; }
/* MARK: Summary */
.summary {
display: flex;
align-items: center;
gap: 14px;
padding: 5px 14px;
font-size: 11px;
color: var(--secondary);
background: var(--bar);
border-top: 1px solid var(--border);
flex: 0 0 auto;
}
.summary:empty { display: none; }
.counter { display: flex; align-items: center; gap: 4px; }
.counter i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.counter-same i { background: var(--same); }
.counter-different i { background: var(--different); }
.counter-only-left i { background: var(--only-left); }
.counter-only-right i { background: var(--only-right); }
.counter-pending i { background: var(--pending); }
.clean { color: var(--added); font-weight: 500; }
/* MARK: Menus and overlays */
.context-menu, .popover {
position: fixed;
z-index: 100;
min-width: 180px;
padding: 5px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 28px rgb(0 0 0 / 0.22);
}
.context-menu button {
display: block;
width: 100%;
text-align: start;
padding: 5px 9px;
border-radius: 5px;
white-space: nowrap;
}
.context-menu button:hover:not(:disabled) { background: var(--accent); color: #fff; }
.context-menu hr { border: none; border-top: 1px solid var(--border); margin: 4px 2px; }
.popover { display: flex; flex-direction: column; gap: 8px; padding: 12px; max-width: 300px; }
.popover h3 { margin: 0; font-size: 12px; }
.popover .note { margin: 0; }
.overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 0.35);
}
.about {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 26px 32px;
background: var(--bg);
border-radius: 14px;
text-align: center;
max-width: 380px;
}
.about img { width: 76px; height: 76px; }
.about h2 { margin: 0; font-size: 20px; }
.about p { margin: 0; color: var(--secondary); }
.about a { color: var(--accent); }
.about button {
margin-top: 6px;
padding: 5px 18px;
border: 1px solid var(--border);
border-radius: 7px;
}
+545
View File
@@ -0,0 +1,545 @@
/**
* One comparison tab, laid out like the macOS window: the two sides on top, a
* control bar, the tree, and the selected file's differences underneath.
*/
import { ComparisonModel, ROW_FILTERS, type RowFilter, type Side } from '../model';
import type { Node } from '../engine/tree';
import type { ComparisonMode, TextOptions } from '../engine/content';
import { diffRows } from '../engine/diff';
import { chooseFile, chooseFolder, readDrop } from '../picker';
import { t } from '../i18n';
import { TreeView, icon } from './tree';
import { FileDiffView } from './filediff';
const MODES: Array<{ id: ComparisonMode | ''; label: string }> = [
{ id: '', label: 'Automatic' },
{ id: 'binary', label: 'Byte for byte' },
{ id: 'text', label: 'Text' },
{ id: 'semantic', label: 'Semantic (JSON/XML)' },
];
const TEXT_TOGGLES: Array<{ key: keyof TextOptions; label: string }> = [
{ key: 'ignoreTrailingWhitespace', label: 'Ignore trailing whitespace' },
{ key: 'ignoreAllWhitespace', label: 'Ignore all whitespace' },
{ key: 'ignoreCase', label: 'Ignore case' },
{ key: 'ignoreBlankLines', label: 'Ignore blank lines' },
{ key: 'normaliseLineEndings', label: 'Treat CRLF and LF as equal' },
];
export class ComparisonView {
readonly element = document.createElement('div');
readonly model = new ComparisonModel();
private readonly sides = document.createElement('div');
private readonly controls = document.createElement('div');
private readonly banners = document.createElement('div');
private readonly body = document.createElement('div');
private readonly summary = document.createElement('div');
private readonly tree: TreeView;
private readonly fileDiff: FileDiffView;
/** How much of the height the tree takes; dragging the divider keeps it. */
private splitFraction = Number(localStorage.getItem('kotej.splitFraction') ?? '0.45');
constructor(onOpenInNewTab: (left: Node, right: Node) => void,
private readonly onTitleChange: () => void) {
this.element.className = 'comparison';
this.sides.className = 'sides';
this.controls.className = 'controls';
this.banners.className = 'banners';
this.body.className = 'body';
this.summary.className = 'summary';
this.tree = new TreeView(this.model, { onOpenInNewTab });
this.fileDiff = new FileDiffView(this.model);
this.element.append(this.sides, this.controls, this.banners, this.body, this.summary);
this.model.onChange = () => this.render();
// Dropping anywhere in the tab works: two items fill both sides at once,
// which saves aiming at the wells.
this.element.addEventListener('dragover', (event) => {
event.preventDefault();
this.element.classList.add('dropping');
});
this.element.addEventListener('dragleave', (event) => {
if (event.target === this.element) this.element.classList.remove('dropping');
});
this.element.addEventListener('drop', (event) => {
event.preventDefault();
this.element.classList.remove('dropping');
void this.accept(event);
});
this.render();
}
get title(): string {
if (this.model.left && this.model.right) {
return `${this.model.left.name}${this.model.right.name}`;
}
if (this.model.showsTextCompare) return t('New text comparison');
return t('New comparison');
}
focusSearch() {
this.controls.querySelector<HTMLInputElement>('.search input')?.focus();
}
async accept(event: DragEvent, preferring?: Side) {
const dropped = await readDrop(event);
if (!dropped.length) return;
if (dropped.length >= 2) { this.model.setBoth(dropped[0], dropped[1]); return; }
const side: Side = preferring ?? (this.model.left ? 'right' : 'left');
this.model.setSide(side, dropped[0]);
}
render() {
this.renderSides();
this.renderControls();
this.renderBanners();
this.renderBody();
this.renderSummary();
this.onTitleChange();
}
// MARK: Sides
private renderSides() {
const swap = iconButton('swap', t('Swap sides'), () => this.model.swapSides());
swap.disabled = !this.model.isReady;
const again = iconButton('refresh', t('Compare again'), () => void this.model.compare());
again.disabled = !this.model.isReady;
this.sides.replaceChildren(
this.sideField('left'), swap, this.sideField('right'), again);
}
private sideField(side: Side): HTMLElement {
const input = side === 'left' ? this.model.left : this.model.right;
const field = document.createElement('div');
field.className = `side-field${input ? ' filled' : ''}`;
const glyph = icon(input ? (input.files ? 'folder' : 'file') : 'blank', 'side-icon');
const labels = document.createElement('div');
labels.className = 'side-labels';
const caption = document.createElement('span');
caption.className = 'side-caption';
caption.textContent = t(side === 'left' ? 'Left' : 'Right');
const name = document.createElement('span');
name.className = 'side-name';
name.textContent = input?.name ?? t('Drag or choose…');
labels.append(caption, name);
const pickFolder = iconButton('folder', t('Choose folder'),
async () => { const picked = await chooseFolder(); if (picked) this.model.setSide(side, picked); });
const pickFile = iconButton('file', t('Choose file'),
async () => { const picked = await chooseFile(); if (picked) this.model.setSide(side, picked); });
field.append(glyph, labels, pickFolder, pickFile);
field.addEventListener('dragover', (event) => {
event.preventDefault();
event.stopPropagation();
field.classList.add('targeted');
});
field.addEventListener('dragleave', () => field.classList.remove('targeted'));
field.addEventListener('drop', (event) => {
event.preventDefault();
event.stopPropagation();
field.classList.remove('targeted');
void this.accept(event, side);
});
return field;
}
// MARK: Controls
private renderControls() {
const search = document.createElement('div');
search.className = 'search';
search.append(icon('search', 'search-icon'));
const box = document.createElement('input');
box.type = 'search';
box.placeholder = t('Filter by name');
box.value = this.model.searchText;
box.addEventListener('input', () => {
this.model.searchText = box.value;
this.renderBody();
});
search.append(box);
const filter = select(ROW_FILTERS.map((option) => ({ value: option.id, label: t(option.label) })),
this.model.filter, (value) => {
this.model.filter = value as RowFilter;
this.renderBody();
});
filter.title = t('Which rows the tree shows');
const mode = select(MODES.map((option) => ({ value: option.id, label: t(option.label) })),
this.model.mode ?? '', (value) => {
this.model.mode = (value || null) as ComparisonMode | null;
if (this.model.isReady) void this.model.compare();
});
mode.title = t('Automatic picks the comparison that suits each file type');
const options = iconButton('sliders', t('Comparison options'),
(event) => this.showTextOptions(event));
const spacer = document.createElement('div');
spacer.className = 'spacer';
const position = document.createElement('span');
position.className = 'position';
const count = this.model.differences.length;
if (count) {
const index = this.model.currentDifferenceIndex;
position.textContent = `${index === null ? '—' : index + 1}/${count}`;
}
const previous = iconButton('up', t('Previous difference'),
() => this.model.selectPreviousDifference());
const next = iconButton('down', t('Next difference'),
() => this.model.selectNextDifference());
previous.disabled = next.disabled = count === 0;
this.controls.replaceChildren(search, filter, mode, options, spacer, position, previous, next);
}
/** Tolerances the engine already supported; they just needed a way in. */
private showTextOptions(event: MouseEvent) {
const popover = document.createElement('div');
popover.className = 'popover';
const heading = document.createElement('h3');
heading.textContent = t('Text comparison');
popover.append(heading);
for (const { key, label } of TEXT_TOGGLES) {
const row = document.createElement('label');
row.className = 'checkbox';
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = this.model.textOptions[key];
box.addEventListener('change', () => {
this.model.applyTextOptions({ ...this.model.textOptions, [key]: box.checked });
});
row.append(box, document.createTextNode(t(label)));
popover.append(row);
}
const note = document.createElement('p');
note.className = 'note';
note.textContent = t('Applies to text files; JSON and XML also compare structurally.');
popover.append(note);
document.body.append(popover);
const anchor = (event.currentTarget as HTMLElement).getBoundingClientRect();
popover.style.top = `${anchor.bottom + 6}px`;
popover.style.left =
`${Math.min(anchor.left, window.innerWidth - popover.offsetWidth - 8)}px`;
setTimeout(() => window.addEventListener('pointerdown', function dismiss(e) {
if (popover.contains(e.target as globalThis.Node)) {
window.addEventListener('pointerdown', dismiss, { once: true });
return;
}
popover.remove();
}, { once: true }));
}
// MARK: Banners
private renderBanners() {
const children: HTMLElement[] = [];
if (this.model.errorMessage) {
const banner = document.createElement('div');
banner.className = 'banner error';
banner.textContent = this.model.errorMessage;
children.push(banner);
}
// Keeps the marked side visible; otherwise it's easy to forget one is armed.
if (this.model.pendingPick) {
const banner = document.createElement('div');
banner.className = 'banner pin';
banner.append(document.createTextNode(
`${t('Marked for comparison:')} ${this.model.pendingPick.name}`));
const forget = document.createElement('button');
forget.type = 'button';
forget.className = 'link';
forget.textContent = t('Forget');
forget.addEventListener('click', () => {
this.model.pendingPick = null;
this.renderBanners();
});
banner.append(forget);
children.push(banner);
}
this.banners.replaceChildren(...children);
}
// MARK: Body
private renderBody() {
if (this.model.isScanning) {
const busy = document.createElement('div');
busy.className = 'placeholder';
busy.append(spinner(), document.createTextNode(t('Comparing…')));
this.body.replaceChildren(busy);
return;
}
if (this.model.root) {
this.body.replaceChildren(this.splitPane());
this.tree.render();
this.fileDiff.render();
return;
}
this.body.replaceChildren(this.model.showsTextCompare ? this.textCompare() : this.emptyState());
}
private splitPane(): HTMLElement {
const pane = document.createElement('div');
pane.className = 'split';
const top = document.createElement('div');
top.className = 'split-top';
top.style.flexBasis = `${this.splitFraction * 100}%`;
top.append(this.tree.element);
const divider = document.createElement('div');
divider.className = 'split-divider';
divider.addEventListener('pointerdown', (event) => {
event.preventDefault();
divider.setPointerCapture(event.pointerId);
const move = (move: PointerEvent) => {
const box = pane.getBoundingClientRect();
const fraction = Math.min(0.85, Math.max(0.15, (move.clientY - box.top) / box.height));
this.splitFraction = fraction;
top.style.flexBasis = `${fraction * 100}%`;
};
const up = () => {
divider.removeEventListener('pointermove', move);
localStorage.setItem('kotej.splitFraction', String(this.splitFraction));
};
divider.addEventListener('pointermove', move);
divider.addEventListener('pointerup', up, { once: true });
});
const bottom = document.createElement('div');
bottom.className = 'split-bottom';
bottom.append(this.fileDiff.element);
pane.append(top, divider, bottom);
return pane;
}
/** The first-run / cleared state: two big drop wells. */
private emptyState(): HTMLElement {
const empty = document.createElement('div');
empty.className = 'empty';
const logo = document.createElement('img');
logo.src = 'logo.png';
logo.alt = '';
logo.className = 'empty-logo';
const title = document.createElement('h2');
title.textContent = t('Empty comparison');
const blurb = document.createElement('p');
blurb.textContent =
t('Drag two folders, files or archives (ZIP, JAR, EAR) to start comparing.');
const privacy = document.createElement('p');
privacy.className = 'note';
privacy.textContent = t('Nothing leaves your browser: the files are read on this device.');
const paste = document.createElement('button');
paste.type = 'button';
paste.className = 'link';
paste.textContent = t('Compare pasted text instead');
paste.addEventListener('click', () => {
this.model.showsTextCompare = true;
this.renderBody();
});
const wells = document.createElement('div');
wells.className = 'wells';
wells.append(this.well('left'), this.well('right'));
empty.append(logo, title, blurb, privacy, paste, wells);
return empty;
}
private well(side: Side): HTMLElement {
const input = side === 'left' ? this.model.left : this.model.right;
const well = document.createElement('div');
well.className = `well${input ? ' filled' : ''}`;
const label = document.createElement('span');
label.textContent = input?.name ?? t(side === 'left' ? 'Left' : 'Right');
well.append(icon(input ? 'check' : 'plus', 'well-icon'), label);
well.addEventListener('click', async () => {
const picked = await chooseFolder();
if (picked) this.model.setSide(side, picked);
});
well.addEventListener('dragover', (event) => {
event.preventDefault();
event.stopPropagation();
well.classList.add('targeted');
});
well.addEventListener('dragleave', () => well.classList.remove('targeted'));
well.addEventListener('drop', (event) => {
event.preventDefault();
event.stopPropagation();
well.classList.remove('targeted');
void this.accept(event, side);
});
return well;
}
/** Two panes of pasted text: comparing a snippet shouldn't need a file. */
private textCompare(): HTMLElement {
const view = document.createElement('div');
view.className = 'textcompare';
const panes = document.createElement('div');
panes.className = 'textcompare-panes';
const result = document.createElement('div');
result.className = 'textcompare-result';
const update = () => {
if (!this.model.leftText.trim() || !this.model.rightText.trim()) {
result.replaceChildren(placeholder(t('Paste something on each side to compare.')));
return;
}
const rows = diffRows(this.model.leftText, this.model.rightText);
const lines = document.createElement('div');
lines.className = 'filediff-lines';
for (const row of rows) {
const line = document.createElement('div');
line.className = `diff-line kind-${row.kind}`;
if (row.kind === 'changed') line.classList.add(`change-${row.changeKind}`);
line.append(textCell(row.leftNumber, row.left, 'left'),
textCell(row.rightNumber, row.right, 'right'));
lines.append(line);
}
result.replaceChildren(lines);
};
for (const side of ['left', 'right'] as Side[]) {
const area = document.createElement('textarea');
area.placeholder = t('Paste here…');
area.spellcheck = false;
area.value = side === 'left' ? this.model.leftText : this.model.rightText;
area.addEventListener('input', () => {
if (side === 'left') this.model.leftText = area.value;
else this.model.rightText = area.value;
update();
});
panes.append(area);
}
const bar = document.createElement('div');
bar.className = 'textcompare-bar';
const clear = document.createElement('button');
clear.type = 'button';
clear.textContent = t('Clear');
clear.addEventListener('click', () => {
this.model.leftText = this.model.rightText = '';
this.renderBody();
});
const back = document.createElement('button');
back.type = 'button';
back.className = 'link';
back.textContent = t('Empty comparison');
back.addEventListener('click', () => {
this.model.showsTextCompare = false;
this.renderBody();
});
bar.append(back, clear);
view.append(panes, bar, result);
update();
return view;
}
private renderSummary() {
if (!this.model.root) { this.summary.replaceChildren(); return; }
const totals = this.model.totals;
const counters: Array<[string, number, string]> = [
['Same', totals.identical, 'same'],
['Different', totals.different, 'different'],
['Only left', totals.onlyLeft, 'only-left'],
['Only right', totals.onlyRight, 'only-right'],
];
if (totals.pending > 0) counters.push(['Unresolved', totals.pending, 'pending']);
const children = counters.map(([label, value, key]) => {
const item = document.createElement('span');
item.className = `counter counter-${key}`;
const dot = document.createElement('i');
item.append(dot, document.createTextNode(`${t(label)}: ${value}`));
return item;
});
const differences = totals.different + totals.onlyLeft + totals.onlyRight;
if (differences === 0) {
const spacer = document.createElement('div');
spacer.className = 'spacer';
const clean = document.createElement('span');
clean.className = 'clean';
clean.textContent = `${t('No differences')}`;
children.push(spacer, clean);
}
this.summary.replaceChildren(...children);
}
}
// MARK: Small builders
function textCell(number: number | null, value: string, side: string): HTMLElement {
const cell = document.createElement('div');
cell.className = `diff-cell ${side}`;
const gutter = document.createElement('span');
gutter.className = 'diff-number';
gutter.textContent = number === null ? '' : String(number);
const body = document.createElement('span');
body.className = 'diff-text';
body.textContent = value || ' ';
cell.append(gutter, body);
return cell;
}
function placeholder(message: string): HTMLElement {
const element = document.createElement('div');
element.className = 'placeholder';
element.textContent = message;
return element;
}
function spinner(): HTMLElement {
const element = document.createElement('div');
element.className = 'spinner';
return element;
}
function select(options: Array<{ value: string; label: string }>, selected: string,
onChange: (value: string) => void): HTMLSelectElement {
const element = document.createElement('select');
for (const option of options) {
const item = document.createElement('option');
item.value = option.value;
item.textContent = option.label;
item.selected = option.value === selected;
element.append(item);
}
element.addEventListener('change', () => onChange(element.value));
return element;
}
function iconButton(name: string, title: string,
action: (event: MouseEvent) => void): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'icon-button';
button.title = title;
button.setAttribute('aria-label', title);
button.append(icon(name));
button.addEventListener('click', (event) => { event.stopPropagation(); action(event); });
return button;
}
+237
View File
@@ -0,0 +1,237 @@
/**
* Side-by-side contents of the selected row. Text (and JSON/XML) is aligned line
* by line; binaries just report their verdict, since a hex view would be noise.
*/
import type { ComparisonModel } from '../model';
import type { DiffNode } from '../engine/tree';
import { nodeData } from '../engine/tree';
import { detectKind } from '../engine/content';
import { diffRows, commentSyntaxFor, type DiffRow } from '../engine/diff';
import { t, formatBytes } from '../i18n';
import { icon } from './tree';
const ROW_HEIGHT = 18;
const OVERSCAN = 20;
export class FileDiffView {
readonly element = document.createElement('div');
private readonly header = document.createElement('div');
private readonly scroller = document.createElement('div');
private readonly spacer = document.createElement('div');
private readonly linesLayer = document.createElement('div');
private readonly notice = document.createElement('div');
private rows: DiffRow[] = [];
private shownPath: string | null = null;
/** Long lines wrap by default; turning it off reads better for code. */
private wrap = localStorage.getItem('kotej.wrapLines') !== 'false';
/** Guards against a slow load landing after the user moved on. */
private loadGeneration = 0;
constructor(private readonly model: ComparisonModel) {
this.element.className = 'filediff';
this.header.className = 'filediff-header';
this.scroller.className = 'filediff-scroller';
this.spacer.className = 'filediff-spacer';
this.linesLayer.className = 'filediff-lines';
this.notice.className = 'placeholder';
this.spacer.append(this.linesLayer);
this.scroller.append(this.spacer);
this.element.append(this.header, this.scroller, this.notice);
this.scroller.addEventListener('scroll', () => this.drawWindow());
}
render() {
const node = this.model.selectedNode;
if (!node) {
this.shownPath = null;
this.rows = [];
this.showNotice(t('Select a file to see its differences'));
this.header.replaceChildren();
return;
}
// Re-rendering for an unrelated change (a filter, say) shouldn't reload and
// lose the scroll position.
if (node.path === this.shownPath) { this.renderHeader(node); return; }
this.shownPath = node.path;
void this.load(node);
}
private async load(node: DiffNode) {
const generation = ++this.loadGeneration;
this.rows = [];
this.renderHeader(node);
if (node.isDirectory) {
this.showNotice(t(node.isArchive
? 'Archive: expand it in the tree to see its contents.'
: 'Folder: select a file inside it.'));
return;
}
if (!node.left || !node.right) {
this.showNotice(t(node.left ? 'Only exists on the right.' : 'Only exists on the left.'));
return;
}
this.showNotice(t('Comparing…'));
let leftData: Uint8Array;
let rightData: Uint8Array;
try {
[leftData, rightData] = await Promise.all([nodeData(node.left), nodeData(node.right)]);
} catch {
if (generation === this.loadGeneration) this.showNotice(t("Couldn't read the contents."));
return;
}
if (generation !== this.loadGeneration) return;
if (detectKind(node.name, leftData) === 'binary') {
const same = leftData.length === rightData.length
&& leftData.every((byte, index) => byte === rightData[index]);
this.showNotice(same
? `${t('Identical binary')} (${formatBytes(leftData.length)}).`
: `${t('Different binary')} (${formatBytes(leftData.length)} · ${formatBytes(rightData.length)}).`);
return;
}
const decoder = new TextDecoder();
this.rows = diffRows(decoder.decode(leftData), decoder.decode(rightData),
commentSyntaxFor(node.name));
if (generation !== this.loadGeneration) return;
this.renderHeader(node);
this.notice.hidden = true;
this.scroller.hidden = false;
this.spacer.style.height = `${this.rows.length * ROW_HEIGHT}px`;
this.scroller.scrollTop = 0;
this.drawWindow();
this.scrollToFirstChange();
}
private showNotice(message: string) {
this.notice.textContent = message;
this.notice.hidden = false;
this.scroller.hidden = true;
}
/** Lands on the first difference rather than at the top of a long file. */
private scrollToFirstChange() {
const index = this.rows.findIndex((row) => row.kind !== 'equal');
if (index < 0) return;
this.scroller.scrollTop = Math.max(0, index * ROW_HEIGHT - this.scroller.clientHeight / 3);
this.drawWindow();
}
private drawWindow() {
if (this.wrap) {
// Wrapped lines aren't a fixed height, so the window trick doesn't hold;
// draw the lot and let the browser deal with it.
this.spacer.style.height = 'auto';
this.linesLayer.style.transform = '';
this.linesLayer.replaceChildren(...this.rows.map((row) => this.lineElement(row)));
return;
}
const top = this.scroller.scrollTop;
const height = this.scroller.clientHeight || 300;
const first = Math.max(0, Math.floor(top / ROW_HEIGHT) - OVERSCAN);
const last = Math.min(this.rows.length, Math.ceil((top + height) / ROW_HEIGHT) + OVERSCAN);
this.spacer.style.height = `${this.rows.length * ROW_HEIGHT}px`;
this.linesLayer.style.transform = `translateY(${first * ROW_HEIGHT}px)`;
this.linesLayer.replaceChildren(
...this.rows.slice(first, last).map((row) => this.lineElement(row)));
}
private lineElement(row: DiffRow): HTMLElement {
const line = document.createElement('div');
line.className = `diff-line kind-${row.kind}`;
if (row.kind === 'changed') line.classList.add(`change-${row.changeKind}`);
line.append(cell(row.leftNumber, row.left, row.leftHighlight, 'left'),
cell(row.rightNumber, row.right, row.rightHighlight, 'right'));
return line;
}
private renderHeader(node: DiffNode) {
const title = document.createElement('div');
title.className = 'filediff-title';
title.append(icon(node.isDirectory ? 'folder' : 'file', 'row-icon'));
const path = document.createElement('span');
path.textContent = node.path || node.name;
path.title = node.path;
title.append(path);
const children: HTMLElement[] = [title];
if (this.rows.length) {
children.push(legend(this.rows), this.wrapToggle());
}
this.header.replaceChildren(...children);
}
private wrapToggle(): HTMLElement {
const label = document.createElement('label');
label.className = 'checkbox';
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = this.wrap;
box.addEventListener('change', () => {
this.wrap = box.checked;
localStorage.setItem('kotej.wrapLines', String(this.wrap));
this.element.classList.toggle('nowrap', !this.wrap);
this.drawWindow();
});
label.append(box, document.createTextNode(t('Wrap lines')));
this.element.classList.toggle('nowrap', !this.wrap);
return label;
}
}
function cell(number: number | null, value: string, highlight: [number, number] | null,
side: 'left' | 'right'): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = `diff-cell ${side}`;
const gutter = document.createElement('span');
gutter.className = 'diff-number';
gutter.textContent = number === null ? '' : String(number);
const body = document.createElement('span');
body.className = 'diff-text';
// Paints just the characters that differ, so a one-character change doesn't
// look the same as a rewritten line.
if (highlight && highlight[0] < highlight[1] && value) {
const [from, to] = highlight;
const mark = document.createElement('mark');
mark.textContent = value.slice(from, to);
body.append(document.createTextNode(value.slice(0, from)), mark,
document.createTextNode(value.slice(to)));
} else {
body.textContent = value || ' ';
}
wrapper.append(gutter, body);
return wrapper;
}
/** Tells you at a glance what kind of differences this file has. */
function legend(rows: DiffRow[]): HTMLElement {
const changed = rows.filter((row) => row.kind === 'changed');
const counts: Array<[string, string, number]> = [
['content', 'content', changed.filter((r) => r.changeKind === 'content').length],
['comment', 'comments', changed.filter((r) => r.changeKind === 'comment').length],
['space', 'whitespace', changed.filter((r) => r.changeKind === 'whitespace').length],
['added', 'added', rows.filter((r) => r.kind === 'added').length],
['removed', 'removed', rows.filter((r) => r.kind === 'removed').length],
];
const element = document.createElement('div');
element.className = 'legend';
for (const [key, label, count] of counts) {
if (!count) continue;
const item = document.createElement('span');
item.className = `legend-item legend-${key}`;
const swatch = document.createElement('i');
item.append(swatch, document.createTextNode(`${t(label)} ${count}`));
element.append(item);
}
return element;
}
+50
View File
@@ -0,0 +1,50 @@
/** A context menu, since the browser's own can't be extended. */
export interface MenuItem {
label?: string;
action?: () => void;
disabled?: boolean;
separator?: boolean;
}
let open: HTMLElement | null = null;
export function closeMenu() {
open?.remove();
open = null;
}
export function showMenu(event: MouseEvent, items: MenuItem[]) {
closeMenu();
const menu = document.createElement('div');
menu.className = 'context-menu';
for (const item of items) {
if (item.separator) {
menu.append(document.createElement('hr'));
continue;
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = item.label ?? '';
button.disabled = item.disabled ?? false;
button.addEventListener('click', () => { closeMenu(); item.action?.(); });
menu.append(button);
}
document.body.append(menu);
open = menu;
// Placed after appending so the real size is known: near the right or bottom
// edge the menu has to flip rather than hang off screen.
const { width, height } = menu.getBoundingClientRect();
const x = Math.min(event.clientX, window.innerWidth - width - 8);
const y = Math.min(event.clientY, window.innerHeight - height - 8);
menu.style.left = `${Math.max(8, x)}px`;
menu.style.top = `${Math.max(8, y)}px`;
setTimeout(() => {
window.addEventListener('pointerdown', closeMenu, { once: true });
window.addEventListener('blur', closeMenu, { once: true });
});
}
+312
View File
@@ -0,0 +1,312 @@
/**
* The side-by-side tree. Archives appear as folders, and either side can be
* expanded — the disclosure chevron is on both, since which side you're reading
* shouldn't decide where you have to click.
*
* Only the rows in view are in the DOM: a large EAR flattens to tens of
* thousands of rows, and drawing them all makes scrolling crawl.
*/
import type { ComparisonModel, FlatRow } from '../model';
import type { DiffNode, Node } from '../engine/tree';
import type { ComparisonMode } from '../engine/content';
import { t, formatBytes } from '../i18n';
import { showMenu, type MenuItem } from './menu';
const ROW_HEIGHT = 24;
/** Rows drawn beyond each edge, so a flick doesn't show blank space. */
const OVERSCAN = 10;
export interface TreeCallbacks {
onOpenInNewTab: (left: Node, right: Node) => void;
}
export class TreeView {
readonly element = document.createElement('div');
private readonly header = document.createElement('div');
private readonly scroller = document.createElement('div');
private readonly spacer = document.createElement('div');
private readonly rowsLayer = document.createElement('div');
private readonly empty = document.createElement('div');
private rows: FlatRow[] = [];
constructor(private readonly model: ComparisonModel,
private readonly callbacks: TreeCallbacks) {
this.element.className = 'tree';
this.header.className = 'tree-header';
this.scroller.className = 'tree-scroller';
this.spacer.className = 'tree-spacer';
this.rowsLayer.className = 'tree-rows';
this.empty.className = 'placeholder';
this.empty.hidden = true;
this.spacer.append(this.rowsLayer);
this.scroller.append(this.spacer);
this.element.append(this.header, this.scroller, this.empty);
this.scroller.addEventListener('scroll', () => this.drawWindow());
}
render() {
this.rows = this.model.visibleRows;
this.header.replaceChildren(...headerCells(this.model));
const nothing = this.rows.length === 0;
this.scroller.hidden = nothing;
this.empty.hidden = !nothing;
if (nothing) {
this.empty.replaceChildren(icon('filter', 'placeholder-icon'),
text('p', t('Nothing matches this filter')));
return;
}
this.spacer.style.height = `${this.rows.length * ROW_HEIGHT}px`;
this.drawWindow();
this.scrollSelectionIntoView();
}
/** Draws only the slice of rows the scroller is showing. */
private drawWindow() {
const top = this.scroller.scrollTop;
const height = this.scroller.clientHeight || 400;
const first = Math.max(0, Math.floor(top / ROW_HEIGHT) - OVERSCAN);
const last = Math.min(this.rows.length,
Math.ceil((top + height) / ROW_HEIGHT) + OVERSCAN);
this.rowsLayer.style.transform = `translateY(${first * ROW_HEIGHT}px)`;
this.rowsLayer.replaceChildren(
...this.rows.slice(first, last).map((row, index) => this.rowElement(row, first + index)));
}
private scrollSelectionIntoView() {
const index = this.rows.findIndex((row) => row.node.path === this.model.selection);
if (index < 0) return;
const top = index * ROW_HEIGHT;
const viewTop = this.scroller.scrollTop;
const viewBottom = viewTop + this.scroller.clientHeight - ROW_HEIGHT;
if (top < viewTop || top > viewBottom) {
this.scroller.scrollTop = top - this.scroller.clientHeight / 2;
}
}
private rowElement(row: FlatRow, index: number): HTMLElement {
const { node } = row;
const element = document.createElement('div');
element.className = `tree-row status-${node.status}`;
element.style.top = `${index * ROW_HEIGHT}px`;
if (node.path === this.model.selection) element.classList.add('selected');
if (index % 2 === 1) element.classList.add('alternate');
element.append(
side(node, 'left', row, () => this.model.toggleExpanded(node.path)),
side(node, 'right', row, () => this.model.toggleExpanded(node.path)),
modeCell(node.appliedMode),
statusCell(node.status),
);
element.addEventListener('click', () => {
this.model.select(node.path);
// Big files are left undecided during the scan; asking for one is a fair
// signal that it's worth reading now.
if (node.status === 'pending') void this.model.resolvePending(node);
});
element.addEventListener('dblclick', () => {
if (row.hasChildren) this.model.toggleExpanded(node.path);
});
element.addEventListener('contextmenu', (event) => {
event.preventDefault();
this.model.select(node.path);
showMenu(event, this.menuFor(node));
});
return element;
}
private menuFor(node: DiffNode): MenuItem[] {
const items: MenuItem[] = [];
const { pendingPick } = this.model;
// Descend: both sides of this row become the new base. Works for folders
// and archives, not just files.
if (node.left && node.right) {
const left = node.left, right = node.right;
items.push({ label: t('Set as base'), action: () => void this.model.setBase(left, right) });
items.push({ label: t('Set as base in a new tab'),
action: () => this.callbacks.onOpenInNewTab(left, right) });
items.push({ separator: true });
}
// Pair up two things whose names don't match: mark one side, then pick the
// other. The only way to compare a renamed folder against its original.
if (node.left) {
const left = node.left;
items.push({ label: t('Mark left for comparison'),
action: () => { this.model.pendingPick = { side: 'left', node: left, name: left.name };
this.model.onChange(); } });
}
if (node.right) {
const right = node.right;
items.push({ label: t('Mark right for comparison'),
action: () => { this.model.pendingPick = { side: 'right', node: right, name: right.name };
this.model.onChange(); } });
}
if (pendingPick) {
items.push({ separator: true });
if (node.left) {
const left = node.left;
items.push({ label: `${t('Set as base')}: “${pendingPick.name}” ↔ ${left.name}`,
action: () => void this.model.setBase(
pendingPick.side === 'left' ? pendingPick.node : left,
pendingPick.side === 'left' ? left : pendingPick.node) });
}
if (node.right) {
const right = node.right;
items.push({ label: `${t('Set as base')}: “${pendingPick.name}” ↔ ${right.name}`,
action: () => void this.model.setBase(
pendingPick.side === 'left' ? pendingPick.node : right,
pendingPick.side === 'left' ? right : pendingPick.node) });
}
items.push({ label: `${t('Forget')}${pendingPick.name}`,
action: () => { this.model.pendingPick = null; this.model.onChange(); } });
}
items.push({ separator: true });
items.push({ label: t('Copy path'), action: () => void navigator.clipboard.writeText(node.path) });
items.push({ separator: true });
items.push({ label: t('Expand all'), action: () => this.model.expandAll() });
items.push({ label: t('Collapse all'), action: () => this.model.collapseAll() });
return items;
}
}
function headerCells(model: ComparisonModel): HTMLElement[] {
const name = (label: string) => {
const cell = document.createElement('div');
cell.className = 'tree-header-name';
cell.textContent = label;
return cell;
};
const size = () => {
const cell = document.createElement('div');
cell.className = 'tree-header-size';
cell.textContent = t('Size');
return cell;
};
const mode = document.createElement('div');
mode.className = 'tree-header-mode';
const status = document.createElement('div');
status.className = 'tree-header-status';
status.textContent = t('Status');
return [
name(model.left?.name ?? t('Left')), size(),
name(model.right?.name ?? t('Right')), size(),
mode, status,
];
}
/** One side of a row: chevron, icon, name, and its own size. */
function side(node: DiffNode, which: 'left' | 'right', row: FlatRow,
toggle: () => void): HTMLElement {
const present = which === 'left' ? node.left : node.right;
const wrapper = document.createElement('div');
wrapper.className = `tree-side ${which}`;
if (!present) wrapper.classList.add('missing');
const name = document.createElement('div');
name.className = 'tree-name';
name.style.paddingInlineStart = `${row.depth * 14}px`;
if (row.hasChildren && present) {
const chevron = document.createElement('button');
chevron.className = `chevron${row.expanded ? ' open' : ''}`;
chevron.type = 'button';
chevron.append(icon('chevron'));
chevron.addEventListener('click', (event) => { event.stopPropagation(); toggle(); });
name.append(chevron);
} else {
const gap = document.createElement('span');
gap.className = 'chevron-gap';
name.append(gap);
}
name.append(icon(nodeIcon(node, present), 'row-icon'));
const label = document.createElement('span');
label.className = 'tree-label';
label.textContent = present ? node.name : '';
label.title = node.path;
name.append(label);
const size = document.createElement('div');
size.className = 'tree-size';
size.textContent = present && !node.isDirectory ? formatBytes(present.size) : '';
wrapper.append(name, size);
return wrapper;
}
function nodeIcon(node: DiffNode, present: Node | null): string {
if (!present) return 'blank';
if (present.isArchive) return 'archive';
return node.isDirectory ? 'folder' : 'file';
}
/** Which comparison settled the row, so "Automatic" says what it chose. */
function modeCell(mode: ComparisonMode | undefined): HTMLElement {
const cell = document.createElement('div');
cell.className = 'tree-mode';
if (!mode) return cell;
const glyph = { binary: '01', text: 'T', semantic: '{ }' }[mode];
const title = { binary: 'Compared byte for byte', text: 'Compared as text',
semantic: 'Compared structurally (JSON/XML)' }[mode];
cell.textContent = glyph;
cell.title = t(title);
cell.classList.add(`mode-${mode}`);
return cell;
}
function statusCell(status: DiffNode['status']): HTMLElement {
const cell = document.createElement('div');
cell.className = `tree-status badge-${status}`;
const labels: Record<string, string> = {
identical: 'same', equivalent: 'equivalent', different: 'different',
onlyLeft: 'only left', onlyRight: 'only right', pending: 'pending',
};
cell.textContent = t(labels[status] ?? status);
return cell;
}
// MARK: Icons
const PATHS: Record<string, string> = {
chevron: 'M6 4l4 4-4 4',
folder: 'M2 4.5A1.5 1.5 0 013.5 3h3l1.2 1.5h4.8A1.5 1.5 0 0114 6v6a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12z',
file: 'M4 2h5l3 3v9a1 1 0 01-1 1H4a1 1 0 01-1-1V3a1 1 0 011-1zm5 0v3h3',
archive: 'M2 5.2L8 2.5l6 2.7v5.6L8 13.5l-6-2.7zM2 5.2L8 8m0 0l6-2.8M8 8v5.5',
filter: 'M2.5 4h11M4.5 8h7M6.5 12h3',
swap: 'M3 6h10L10.5 3.5M13 10H3l2.5 2.5',
refresh: 'M13 8a5 5 0 11-1.6-3.7M13 2.5V5h-2.5',
search: 'M7.2 2.5a4.7 4.7 0 104.7 4.7 4.7 4.7 0 00-4.7-4.7zm3.5 8.2L14 14',
sliders: 'M2 4.5h12M2 8h12M2 11.5h12M5.5 3v3M10 6.5v3M7 10v3',
up: 'M4 10l4-4 4 4',
down: 'M4 6l4 4 4-4',
plus: 'M8 3.5v9M3.5 8h9',
check: 'M3.5 8.5l3 3 6-6.5',
blank: '',
close: 'M4 4l8 8M12 4l-8 8',
info: 'M8 2.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM8 7v4M8 5.2v.1',
};
export function icon(name: string, className = ''): SVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('class', `icon ${className}`.trim());
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', PATHS[name] ?? '');
svg.append(path);
return svg;
}
export function text(tag: string, value: string): HTMLElement {
const element = document.createElement(tag);
element.textContent = value;
return element;
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Drives the built app in a real browser. The engine tests prove the verdicts;
* this proves the page actually wires them to something you can see — which no
* amount of type checking can tell you.
*
* Run against a served build: node tests/smoke.mjs http://localhost:4173
*/
import { chromium } from 'playwright';
import { zipSync } from 'fflate';
const base = process.argv[2] ?? 'http://localhost:4173';
const failures = [];
function check(label, condition, detail = '') {
if (condition) {
console.log(` ok ${label}`);
} else {
console.log(` FAIL ${label}${detail ? `${detail}` : ''}`);
failures.push(label);
}
}
// The Chrome already on the machine, rather than downloading another copy.
const browser = await chromium.launch({ channel: 'chrome' });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
const errors = [];
page.on('pageerror', (error) => errors.push(String(error)));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto(base);
await page.waitForSelector('.empty');
console.log('empty state');
check('shows the empty comparison', await page.locator('.empty h2').isVisible());
check('offers two drop wells', (await page.locator('.well').count()) === 2);
check('has one tab', (await page.locator('.tab').count()) === 1);
// Feed both sides directly through the model, which is what a folder pick does
// once the browser has handed over the File objects.
console.log('\ncomparison');
await page.evaluate(() => {
const make = (path, body) => ({
file: new File([new TextEncoder().encode(body)], path.split('/').pop()),
relativePath: path,
});
const view = window.__kotej.tabs[0];
view.model.setBoth(
{ name: 'left', files: [
make('same.txt', 'igual\n'),
make('changed.txt', 'uno\ndos\ntres\n'),
make('only-left.txt', 'solo A\n'),
make('config.json', '{"a":1,"b":2}'),
make('sub/deep.txt', 'profundo\n'),
] },
{ name: 'right', files: [
make('same.txt', 'igual\n'),
make('changed.txt', 'uno\nDOS\ntres\n'),
make('only-right.txt', 'solo B\n'),
make('config.json', '{\n "b": 2,\n "a": 1\n}'),
make('sub/deep.txt', 'profundo\n'),
] },
);
});
await page.waitForSelector('.tree-row');
const rowFor = (name) => page.locator('.tree-row', { has: page.locator(`.tree-label:text-is("${name}")`) }).first();
check('lists the top-level rows', (await page.locator('.tree-row').count()) >= 5);
check('marks the identical file', await rowFor('same.txt').locator('.badge-identical').count() === 1);
check('marks the changed file', await rowFor('changed.txt').locator('.badge-different').count() === 1);
check('marks the left-only file', await rowFor('only-left.txt').locator('.badge-onlyLeft').count() === 1);
check('marks the right-only file', await rowFor('only-right.txt').locator('.badge-onlyRight').count() === 1);
check('treats reordered JSON as equivalent',
await rowFor('config.json').locator('.badge-equivalent').count() === 1);
check('shows which mode was applied',
(await rowFor('config.json').locator('.tree-mode').textContent())?.trim() === '{ }');
check('shows a size per side',
(await rowFor('same.txt').locator('.tree-size').allTextContents()).filter(Boolean).length === 2);
const summary = await page.locator('.summary').textContent();
check('summarises the totals', /1/.test(summary ?? ''), summary ?? '');
console.log('\nfile differences');
await rowFor('changed.txt').click();
await page.waitForSelector('.diff-line.kind-changed');
check('aligns the changed line as one row',
await page.locator('.diff-line.kind-changed').count() === 1);
check('highlights only the differing characters',
await page.locator('.diff-line.kind-changed mark').first().textContent() === 'dos');
check('keeps unchanged lines equal', await page.locator('.diff-line.kind-equal').count() === 3);
console.log('\nnavigation');
await page.locator('.controls .icon-button[aria-label]').last().click();
check('next difference selects a row', await page.locator('.tree-row.selected').count() === 1);
check('shows the position', /\d+\/\d+/.test(await page.locator('.position').textContent() ?? ''));
console.log('\nfilters');
await page.selectOption('.controls select', { index: 4 }); // only on the right
await page.waitForTimeout(60);
const filtered = await page.locator('.tree-row .tree-label').allTextContents();
check('filter leaves only right-side orphans',
filtered.filter(Boolean).includes('only-right.txt')
&& !filtered.filter(Boolean).includes('same.txt'), filtered.join(', '));
await page.selectOption('.controls select', { index: 0 });
console.log('\nsearch');
await page.fill('.search input', 'config');
await page.waitForTimeout(60);
const searched = (await page.locator('.tree-row .tree-label').allTextContents()).filter(Boolean);
check('search narrows the tree', searched.every((name) => name.includes('config') || name === 'sub'),
searched.join(', '));
await page.fill('.search input', '');
console.log('\narchives');
// Built here rather than in the page, so the app's bundle doesn't have to carry
// a ZIP *writer* it never needs. Inflating it is the browser's own job.
const zipBytes = Array.from(zipSync({
'a/b.txt': new TextEncoder().encode('dentro del zip\n'),
}));
await page.evaluate((bytes) => {
const blob = new Blob([new Uint8Array(bytes)]);
const view = window.__kotej.tabs[0];
view.model.setBoth({ name: 'one.zip', file: new File([blob], 'one.zip') },
{ name: 'two.zip', file: new File([blob], 'two.zip') });
}, zipBytes);
await page.waitForSelector('.tree-row');
const archiveRows = (await page.locator('.tree-row .tree-label').allTextContents()).filter(Boolean);
check('browses an archive as a folder', archiveRows.includes('a'), archiveRows.join(', '));
console.log('\npasted text');
await page.evaluate(() => {
const view = window.__kotej.tabs[0];
view.model.clear();
view.model.showsTextCompare = true;
view.model.onChange();
});
await page.waitForSelector('.textcompare textarea');
await page.locator('.textcompare textarea').first().fill('alfa\nbeta');
await page.locator('.textcompare textarea').last().fill('alfa\nGAMMA');
await page.waitForTimeout(80);
check('compares pasted text without files',
await page.locator('.textcompare-result .diff-line.kind-changed').count() === 1);
console.log('\ntabs');
await page.locator('.tab-add').click();
check('opens a second tab', (await page.locator('.tab').count()) === 2);
await page.locator('.tab.active .tab-close').click();
check('closes it again', (await page.locator('.tab').count()) === 1);
console.log('\nno console errors');
check('the page raised nothing', errors.length === 0, errors.join(' | '));
await page.screenshot({ path: 'tests/smoke.png', fullPage: false });
await browser.close();
console.log(failures.length ? `\n${failures.length} failed` : '\nall passed');
process.exit(failures.length ? 1 : 0);