19 Commits

Author SHA1 Message Date
alexandrev-tibco 83d61df13b Exportar la comparación en un formato pensado para pegar a un LLM
Pedido: poder llevar la comparativa a una sesión abierta con un modelo para que
la use. Eso, y no un informe para imprimir, decide el formato: los ficheros
iguales se resumen en una linea en vez de listarse, los binarios solo se
mencionan, y las diferencias van en diff unificado, que los modelos leen de
forma nativa. Lo que se omite se dice en voz alta — un informe que se deja la
mitad en silencio es peor que ninguno, porque se lee como completo.

Las cabeceras `--- a/ +++ b/` cuestan dos lineas y convierten cada bloque en un
parche de verdad, asi que el modelo puede devolverlo por `git apply` en vez de
reescribir el cambio a mano. Que lo sea de verdad esta comprobado ejecutando
git sobre la salida en 10 escenarios (sin salto final, insercion al principio,
borrado al final...); dos fallos aparecieron asi: interlineaba `-` y `+` en vez
de agrupar, y contaba la linea fantasma que deja el ultimo `\n`.

Implementado en los dos motores (Swift y TypeScript) con la misma semantica, y
verificado que producen el mismo texto **byte a byte** para un mismo caso.
Tambien hay salida JSON, para alimentar una herramienta en vez de una charla.

UI en las dos apps: copiar al portapapeles primero (que es el gesto real), y
guardar/descargar despues. En web, si el portapapeles se niega, se descarga y
se avisa: un fallo silencioso ahi acaba en pegar contenido viejo sin saberlo.

De paso, un bug que esto destapó: el menu contextual de la web se cerraba en
`pointerdown`, quitando los botones antes de que su click llegara — ninguna
accion del menu funcionaba (tampoco "Set as base" ni "Expand all"). El smoke
test nunca habia pulsado una; ahora si.

Closes #9

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
2026-08-02 09:19:59 +02:00
alexandrev-tibco 43ab3a6c04 Publicar la web en kotej.alexandre-vazquez.cloud
Arquitectura de siempre: estatico en el LXC 142 (nginx, puerto 8083) con NPM
delante para el TLS del wildcard, y CNAME kotej -> dyn en Namecheap. No hay
backend porque no hace falta: los ficheros no salen del navegador.

El CNAME se anadio releyendo la zona entera y reenviandola con el registro
nuevo — setHosts reemplaza toda la zona, y perder el MX o el DKIM por no
reenviarlos romperia el correo. Verificado que los 39 registros siguen ahi.

deploy.sh deja el despliegue repetible; borra el destino antes de extraer para
que los assets viejos no se acumulen.

Closes #8

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
2026-07-31 20:59:08 +02:00
alexandrev-tibco 5d4b339b33 Interfaz web de Kotej, con la misma UX y branding que la app de macOS
Mismo layout que la ventana de escritorio: los dos lados arriba, barra de
control, árbol y, debajo, las diferencias del fichero seleccionado. Mismas
cadenas (inglés y español, según el idioma del navegador), mismos colores de
estado y el mismo logo.

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

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

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

Closes #7

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
2026-07-31 20:40:32 +02:00
alexandrev-tibco 3754abe694 Portar el motor de comparación a TypeScript para la versión web
Mismo motor que la app de escritorio, pero client-side: los ficheros nunca
salen del navegador. Listar un EAR sólo lee la cola del fichero y las entradas
se inflan bajo demanda con DecompressionStream, que es lo que hace usable un
archivo grande en una pestaña.

- zip.ts: lector del directorio central, ZIP64, detección por firma
- content.ts: binario/texto/semántico; JSON por estructura y XML canónico con
  hermanos repetidos tratados como conjunto
- diff.ts: alineación LCS con recorte de prefijo/sufijo y clasificación de la
  diferencia (espacios / comentario / contenido)
- tree.ts: árboles de carpetas y archivos, emparejado por nombre y CRC32 del
  directorio central para resolver iguales sin leer bytes

34 tests portados de los del motor Swift: existen para probar que ambas
implementaciones dan el mismo veredicto.

Closes #5
Closes #6

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
2026-07-31 20:24:09 +02:00
alexandrev-tibco 8e4a3e5f62 Fix Set as base leaving one side on its old contents
Setting a new base called setSide twice, so the first call kicked off a
comparison against the *old* other side. Scans run concurrently, so that stale
result could land last and win: the left had zoomed into the archive while the
right still showed the parent, which is exactly what it looked like on screen.

Both sides are now set in one go before comparing, and every scan is stamped so a
slower, older run can no longer overwrite a newer result.

Also renamed the pairing entries to mention 'set as base', since marking one side
and picking the other achieves the same thing for items whose names differ and
that wasn't obvious from the wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-31 14:46:59 +02:00
alexandrev-tibco 5331bb5283 Drop two files at once to fill both sides
A drop only ever used its first item, so dragging two files in did nothing useful
and each side had to be filled separately.

Two items now land left and right in the order they were dropped, and the whole
empty area accepts them so there's no need to aim at a well. A single item goes
to the side it was dropped on, or to the first free side otherwise — so dropping
one file preloads the left, and the next one fills the right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-31 14:44:34 +02:00
alexandrev-tibco 18a0898741 Compare pasted text without any files
Checking two blobs of JSON meant saving them to disk first, which is friction for
the most common quick comparison there is. A tab can now be a pair of editors:
paste on each side and the diff appears underneath.

It goes through the same engine as files, so JSON and XML are still compared
structurally — the kind is detected from the pasted content itself, since there's
no filename to go on. Reachable from the empty state or cmd-shift-T.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-31 13:32:30 +02:00
alexandrev-tibco 82525da917 XML: repeated sibling elements compare as a set
A list of NameValuePair entries says the same thing whichever order it's written
in, but the comparison demanded positional equality and flagged a pure reorder as
a difference — which makes it useless on configuration files.

Elements are now reduced to a canonical string: attributes sorted, whitespace
normalised, and siblings grouped by name with *repeated* groups sorted, so their
order stops mattering. Differently named siblings keep their order, since a
sequence of distinct elements can carry meaning.

Comparing canonical strings also avoids matching children pairwise, which would
be quadratic on exactly the long repeated lists this is for — covered by a test
with 400 reversed entries.

Reordering no longer hides real changes: a changed value, a dropped entry and a
collapsed duplicate are all still caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-31 13:30:25 +02:00
alexandrev-tibco 139f650d64 Set as base, folder pairing, copy between sides, single window
- Set as base: both sides of a row become the new comparison, which is how you
  descend into a folder pair. Works on folders and archives, not just files.
- Marking for comparison no longer refuses folders, so a folder inside a JAR can
  be paired with one on disk; archive subtrees are extracted whole to do it.
- Copy left → right and right → left, from the toolbar, the context menu or
  cmd-option-arrow, re-comparing afterwards. Only offered where the destination
  is a real folder: an entry inside an archive has no path to write to, and the
  button is disabled rather than failing silently.
- The scene is now a single Window instead of a WindowGroup: a comparison opened
  from Finder joins the window already open as a new tab instead of spawning a
  second window. Verified with two consecutive kotej:// opens — one window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-31 13:27:59 +02:00
alexandrev-tibco 33f48edc11 Sandbox the Finder extension so macOS actually loads it
The context menu never appeared because the extension wasn't registered at all:
pluginkit didn't list it, and it says nothing about why. macOS only loads Finder
Sync extensions that are sandboxed, and the target had App Sandbox off, so the
appex was silently ignored.

It now ships with its own entitlements — sandbox plus read-only access to the
items picked in Finder, which is all it needs to read the selection and open a
kotej:// URL. The containing app stays unsandboxed, since comparing arbitrary
folders needs unrestricted file access.

Verified: pluginkit lists it as enabled and the extension is loaded by Finder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-31 13:14:15 +02:00
alexandrev-tibco e3cf1df73e Nested archives by signature, per-side sizes, applied-mode icon, manual pairing
Archives are detected by their signature, not just their extension, so a ZIP
renamed to anything still opens as a folder — and nesting keeps going, verified
on EAR -> JAR -> renamed ZIP. Entries with clearly non-container extensions are
skipped without inflating, since checking the signature inside an archive means
decompressing, and an EAR holds thousands of .class files.

The comparison now records which mode settled each file, so Automatic shows an
icon per row (byte / text / structural) instead of leaving you to guess when it
went semantic.

Sizes are shown on both sides and highlighted when they disagree, which is often
the fastest hint of what changed.

Two files whose names don't match can now be compared: mark one side, pick the
other, and it becomes the new base in this tab or a new one. Entries living
inside an archive are extracted to a temporary file first, since they have no
path of their own — that's what lets a file inside a JAR be paired with a loose
one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-30 15:53:33 +02:00
alexandrev-tibco 4c0f55c32d Say what the difference actually is, and expand from either side
A changed line only said 'changed'. Now the engine classifies it and the view
colours it accordingly: whitespace-only (teal), comment-only (indigo) or real
content (orange), with a legend counting each kind present in the file. Comment
detection uses the syntax inferred from the extension and only claims a comment
change when the code either side of it is identical; an unknown file type claims
nothing, since hiding a real change is worse than showing a cosmetic one.

Within a changed line the differing characters are now highlighted rather than
tinting the whole row, so a 21 -> 18 reads as a one-character edit instead of
looking like a rewritten line.

The tree no longer uses List's outline, which only ever puts a disclosure
triangle on the leading column: rows are flattened with their own expansion
state, so both sides carry a chevron and the tree can be driven from whichever
side you're reading. Expand all / collapse all added to the context menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-28 10:08:54 +02:00
alexandrev-tibco e0bff88225 Make the panes fill the window: replace VSplitView with an explicit split
The tree kept rendering as a ~628pt column centred in a wide window. That number
was exactly the sum of the row's column minimums, which gave it away: VSplitView
proposes its children their *ideal* width, so the List settled at the narrowest
size its content allowed instead of filling.

SplitPane hands each pane geo.size.width explicitly, removing the ambiguity, and
its divider is draggable with the position remembered across launches. With the
width no longer ambiguous the two name columns share it evenly, so each side gets
half the window; their floor dropped to 120pt since a large minimum is what
becomes the ideal width in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-27 17:44:40 +02:00
alexandrev-tibco a4efef9664 Professional pass on the UI: fix the collapsed tree, add search, navigation and options
The previous change made both name columns fully flexible with no floor, so the
list collapsed to its minimum width and every name was truncated to "c com" /
"e...e". Columns now have an explicit minimum and the tree fills the window.

Added what a comparison tool is expected to have and this didn't:
- Search box to filter rows by name, on top of the status filter.
- Next/previous difference with a position counter, plus cmd-G / cmd-shift-G,
  walking only what's actually visible.
- Comparison options popover exposing the text tolerances (whitespace, case,
  blank lines, CRLF) that the engine already supported but had no way in.
- File sizes, a Size column, status shown as a coloured pill instead of plain
  text, alternating row backgrounds, and a context menu with "Reveal in Finder"
  (only for real files — archive entries have no path) and "Copy path".
- Compare-again button next to the sides.

Note on verification: ImageRenderer can't rasterise List/VSplitView, so the
headless snapshot only confirmed the bars fill the width and the difference
counter works; the tree layout needs a look on screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-27 17:42:03 +02:00
alexandrev-tibco 7d7238ba33 Use the full width in the diff, and filter the tree by kind of difference
The file diff sat in a narrow centred column with empty space around it: cells
had a fixed minWidth inside a two-axis ScrollView, and the empty/notice states
were vertically centred. Now each side takes exactly half the width, content is
anchored top-leading, and scrolling is vertical only, with long lines wrapping
(a "Wrap lines" toggle turns that off for code, remembered across launches).

The tree's right column was pinned at 220pt regardless of window size; both name
columns now share the space evenly and only the status column is fixed.

The "only differences" checkbox became a filter with the modes worth having when
reviewing a real comparison: everything, only differences, only changed files,
only on the left, only on the right, only on one side. Folders survive filtering
only when something inside them does, so no empty branches are left behind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-27 17:33:35 +02:00
alexandrev-tibco f85c746af0 Rename to Kotej and add the app icon
"Cotejo" means nothing outside Spanish and is hard to pronounce elsewhere, so the
project is now Kotej: same root, but it reads and sounds the same in English and
Spanish. Renamed throughout — engine, targets, bundle ids and the URL scheme,
which is now kotej://compare.

Icon generated with Codex: two facing panels split by a seam, content lines on
each side and one amber line marking a difference — the tool's job at a glance,
still legible at 16pt.

34 tests still green; kotej:// verified end to end against the demo JARs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-27 17:24:05 +02:00
alexandrev-tibco c8d0af0783 Tabs, and follow the system language (English + Spanish)
- Tabs: TabsModel owns one ComparisonModel per tab, so each keeps its own scan,
  selection and options. A comparison arriving from Finder reuses the current tab
  only while it's still empty, otherwise it opens another — an in-progress
  comparison is never overwritten. Tab strip with close buttons, plus cmd-T and
  cmd-W.
- Localization: UI strings are now English keys with en/es .strings for both the
  app and the Finder extension, so it follows the OS language. Care was needed
  where SwiftUI wouldn't localise: Text(someString) renders verbatim, so titles
  became LocalizedStringKey and file names are explicitly Text(verbatim:) — data
  must not be translated. Comparison mode labels are localised in the app because
  the engine stays UI-free and English.

Verified: both .lproj ship inside the app and the appex (40 keys each, "Empty
comparison" -> "Comparación vacía"), and the app launches under es and en.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-27 15:05:37 +02:00
alexandrev-tibco 9941a71ddd SwiftUI app and Finder extension
- TextDiff: line alignment for the file view. Common prefix/suffix are matched
  first, so one change in a 20k-line file costs almost nothing; one-sided runs
  are paired into "changed" rows instead of a delete plus an insert, and very
  large differing sections fall back to positional pairing rather than hanging.
- App: empty comparison with drop wells, drag & drop or pick per side, mode
  picker (automatic per file type, or force binary/text/semantic), side-by-side
  tree with archives as folders, "only differences" filter, per-file diff and a
  totals bar. Scanning runs off the main actor.
- Finder extension: "Comparar los 2 elementos", or the two-step "Seleccionar
  para comparar" then "Comparar con …". It holds no logic — it just opens
  cotejo://compare, which the app handles.
- Engine target raised to macOS 14 for @Observable; Totals/status made public.

Verified on a real pair of JARs: reordered JSON keys and reformatted XML come out
"equivalent", genuine edits "different", and added/removed files as only-left or
only-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-27 14:52:28 +02:00
alexandrev-tibco a8eda5c0a2 Comparison engine: ZIP-aware trees, layered diffing, semantic JSON/XML
First slice of Cotejo, a Beyond Compare-style tool for macOS. The engine has no
UI dependencies so the same code can back a CLI later.

- ZipReader: own central-directory parser (no third-party zip), inflating via the
  system Compression framework and handling ZIP64. Because the directory already
  carries每 entry's CRC32 and size, two archives can be compared entry by entry
  without decompressing anything; bytes are only inflated when an entry is opened.
- TreeScanner: folders and archives scan into one tree type, so JAR/EAR/WAR are
  browsed as directories — nested archives included (EAR -> JAR -> classes).
- DirectoryComparer: layered so large trees stay fast. Cheap checks (size, CRC)
  can prove files identical but never that they differ, since JSON/XML with other
  bytes may still be equivalent; whatever they can't settle is read, or left
  pending under a size limit and resolved on demand.
- ContentComparer: byte-for-byte, text with configurable tolerances, and semantic
  equality for JSON (key order irrelevant, array order significant, true != 1)
  and XML (attribute order and whitespace irrelevant, element order significant).

25 tests, including an EAR containing a JAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-27 12:28:51 +02:00