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:
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
tests/smoke.png
|
||||
|
||||
@@ -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>
|
||||
Generated
+48
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user