From 5d4b339b33c5cab1e7e5630c84c67ec042e59b60 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Fri, 31 Jul 2026 20:40:32 +0200 Subject: [PATCH] Interfaz web de Kotej, con la misma UX y branding que la app de macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz --- web/.gitignore | 1 + web/index.html | 16 + web/package-lock.json | 48 +++ web/package.json | 4 +- web/public/apple-touch-icon.png | Bin 0 -> 28634 bytes web/public/favicon.png | Bin 0 -> 4966 bytes web/public/logo.png | Bin 0 -> 56415 bytes web/src/i18n.ts | 142 ++++++++ web/src/main.ts | 176 ++++++++++ web/src/model.ts | 322 ++++++++++++++++++ web/src/picker.ts | 139 ++++++++ web/src/styles.css | 570 ++++++++++++++++++++++++++++++++ web/src/ui/comparison.ts | 545 ++++++++++++++++++++++++++++++ web/src/ui/filediff.ts | 237 +++++++++++++ web/src/ui/menu.ts | 50 +++ web/src/ui/tree.ts | 312 +++++++++++++++++ web/tests/smoke.mjs | 160 +++++++++ 17 files changed, 2721 insertions(+), 1 deletion(-) create mode 100644 web/index.html create mode 100644 web/public/apple-touch-icon.png create mode 100644 web/public/favicon.png create mode 100644 web/public/logo.png create mode 100644 web/src/i18n.ts create mode 100644 web/src/main.ts create mode 100644 web/src/model.ts create mode 100644 web/src/picker.ts create mode 100644 web/src/styles.css create mode 100644 web/src/ui/comparison.ts create mode 100644 web/src/ui/filediff.ts create mode 100644 web/src/ui/menu.ts create mode 100644 web/src/ui/tree.ts create mode 100644 web/tests/smoke.mjs diff --git a/web/.gitignore b/web/.gitignore index b947077..a05ae20 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +tests/smoke.png diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..bc416c6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + + Kotej + + + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json index fe1c5ad..b9e722c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index e6bdb28..360d276 100644 --- a/web/package.json +++ b/web/package.json @@ -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" diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a4d9482da03a07bf097d3543356457460d2b348f GIT binary patch literal 28634 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 literal 0 HcmV?d00001 diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..05bcadef80a4b2fe43b5a97efc910c36fb4a9e26 GIT binary patch literal 56415 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 b*MIyU$DBHpcap)+00000NkvXXu0mjfBoD5p literal 0 HcmV?d00001 diff --git a/web/src/i18n.ts b/web/src/i18n.ts new file mode 100644 index 0000000..cf9a149 --- /dev/null +++ b/web/src/i18n.ts @@ -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 = { + '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]}`; +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..a370831 --- /dev/null +++ b/web/src/main.ts @@ -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 }; diff --git a/web/src/model.ts b/web/src/model.ts new file mode 100644 index 0000000..cc986f8 --- /dev/null +++ b/web/src/model.ts @@ -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(); + 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 { + 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}`); +} diff --git a/web/src/picker.ts b/web/src/picker.ts new file mode 100644 index 0000000..5b2c19f --- /dev/null +++ b/web/src/picker.ts @@ -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: `` (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 { + return pick(false); +} + +export function chooseFolder(): Promise { + return pick(true); +} + +function pick(directory: boolean): Promise { + 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 { + 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 { + 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 { + 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 { + return new Promise((resolve, reject) => entry.file(resolve, reject)); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..66ea77b --- /dev/null +++ b/web/src/styles.css @@ -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; +} diff --git a/web/src/ui/comparison.ts b/web/src/ui/comparison.ts new file mode 100644 index 0000000..1fbcf28 --- /dev/null +++ b/web/src/ui/comparison.ts @@ -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('.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; +} diff --git a/web/src/ui/filediff.ts b/web/src/ui/filediff.ts new file mode 100644 index 0000000..55eb327 --- /dev/null +++ b/web/src/ui/filediff.ts @@ -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; +} diff --git a/web/src/ui/menu.ts b/web/src/ui/menu.ts new file mode 100644 index 0000000..4d75a4e --- /dev/null +++ b/web/src/ui/menu.ts @@ -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 }); + }); +} diff --git a/web/src/ui/tree.ts b/web/src/ui/tree.ts new file mode 100644 index 0000000..e710e7c --- /dev/null +++ b/web/src/ui/tree.ts @@ -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 = { + 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 = { + 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; +} diff --git a/web/tests/smoke.mjs b/web/tests/smoke.mjs new file mode 100644 index 0000000..54a34e8 --- /dev/null +++ b/web/tests/smoke.mjs @@ -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);