diff --git a/README.md b/README.md index 15ed08e..b4ea3b4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,42 @@ Provides various entry points to run: Used for regression testing in https://github.com/opendocument-app/OpenDocument.core. +## Reviewing differences in the browser + +`compare-html-server` serves two pages: + +- the **overview** lists every comparable file with its status (`same`, + `different`, `pending`), a search box, status filters with counts, and an + `update ref` button per file plus `Update all in view` +- the **compare page** (click a path) shows reference (A) and monitored (B) + side by side with synchronised scrolling, a diff strip in between, and the + controls to work through a review + +On the compare page: + +- **Update reference** copies the monitored file over the reference one; the + file is then marked `✓ accepted` for the rest of the session (the mark + disappears again if the monitored file changes afterwards) +- **← / →** step to the previous and next document; tick *diffs only* to skip + files that already match +- the **diff strip** maps the whole page into the column: red bars mark the + regions that differ, the blue box marks the visible part of the document, and + a click jumps both panes to that position + +| key | action | +| --- | --- | +| `←` / `k`, `→` / `j` | previous / next document | +| `1` / `2` / `3` | reference only / side by side / monitored only | +| `d` | toggle the diff strip | +| `u` | update the reference | +| `r` | reload both panes | +| `Esc` | back to the overview | +| `?` | shortcut help | + +Comparison results are computed live, so the pages update themselves while the +monitored directory is being regenerated. Accepted marks are kept in memory +only and are gone after a restart. + ## Install via PyPI ```bash diff --git a/pyproject.toml b/pyproject.toml index 5c81be1..1e1a360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ download = "https://pypi.org/project/pyodr/#files" tracker = "https://github.com/opendocument-app/compare-html/issues" "release notes" = "https://github.com/opendocument-app/compare-html/releases" +[tool.setuptools.package-data] +htmlcmp = ["templates/*.html", "static/*.css", "static/*.js"] + [tool.pytest.ini_options] pythonpath = ["src"] diff --git a/src/htmlcmp/compare_output_server.py b/src/htmlcmp/compare_output_server.py index e641807..fd3b72a 100755 --- a/src/htmlcmp/compare_output_server.py +++ b/src/htmlcmp/compare_output_server.py @@ -3,18 +3,22 @@ import io import sys +import time import shutil import argparse import logging import threading import functools +import collections from pathlib import Path from concurrent.futures import ThreadPoolExecutor -from flask import Flask, send_from_directory, send_file +from flask import Flask, Response, render_template, send_from_directory, url_for import watchdog.observers import watchdog.events +from PIL import Image + from htmlcmp.common import ( comparable_file, compare_files, @@ -166,17 +170,24 @@ def compare(self, path: Path) -> None: if not isinstance(path, Path): raise TypeError("Path must be of type Path") - if path not in self._future: - raise RuntimeError("Path not submitted for comparison") browser = getattr(Config.thread_local, "browser", None) - result = compare_files( - Config.path_a / path, - Config.path_b / path, - browser=browser, - ) + try: + result = compare_files( + Config.path_a / path, + Config.path_b / path, + browser=browser, + ) + except Exception: + # A file may have vanished or failed to render between submission + # and comparison; report it as different rather than leaving the + # path stuck on "pending" forever. + logger.exception(f"Comparison failed for path: {path}") + result = False self._result[path] = "same" if result else "different" - self._future.pop(path) + # The worker can start before ``submit`` stored the future, so the + # entry is not guaranteed to be there yet. + self._future.pop(path, None) def result(self, path: Path) -> str | None: logger.debug(f"Getting comparison result for path: {path}") @@ -257,7 +268,179 @@ def forget(self, path: Path) -> None: self._result.pop(path, None) -app = Flask("compare") +class Accepted: + """Remembers which files had their reference updated during this session. + + That is what "accepted" means on the pages: someone looked at the diff and + promoted the monitored file to be the new reference. The mark is dropped + again as soon as the monitored file changes after the update, so it never + claims more than it knows. State is in-memory only. + """ + + def __init__(self): + self._lock = threading.Lock() + self._marks: dict[str, float] = {} + + def mark(self, path: str) -> None: + logger.debug(f"Marking as accepted: {path}") + + with self._lock: + self._marks[path] = time.time() + + def check(self, path: str) -> bool: + with self._lock: + timestamp = self._marks.get(path) + + if timestamp is None: + return False + + try: + stale = (Config.path_b / path).stat().st_mtime > timestamp + except OSError: + stale = True + + if stale: + logger.debug(f"Dropping stale acceptance: {path}") + with self._lock: + self._marks.pop(path, None) + return False + + return True + + +def highlight(diff: Image.Image) -> Image.Image: + """Turn a raw difference image into something readable at a glance. + + Pixel-wise differences are mostly very dark, which is unusable as an image, + so every differing pixel is painted in full-strength red on a near-black + background. + """ + mask = diff.convert("L").point(lambda value: 255 if value > 0 else 0) + visual = Image.new("RGB", diff.size, (12, 14, 18)) + visual.paste((255, 76, 76), mask=mask) + return visual + + +def diff_regions(diff: Image.Image, bands: int = 256) -> list[list[float]]: + """Locate the vertical bands of the page that contain differences. + + Returned as [start, end] fractions of the page height. The compare page + draws these as overlay bars: a few differing pixels vanish when the diff + image itself is scaled into a narrow strip, whereas a band always stays + visible. Costs one scan over the image. + """ + width, height = diff.size + band = max(1, height // bands) + + regions = [] + for top in range(0, height, band): + bottom = min(height, top + band) + if diff.crop((0, top, width, bottom)).getbbox() is None: + continue + if regions and regions[-1][1] == top: + regions[-1][1] = bottom + else: + regions.append([top, bottom]) + + return [[top / height, bottom / height] for top, bottom in regions] + + +class DiffCache: + """Caches rendered diff images, keyed by the mtimes of both inputs. + + Rendering drives a real browser and is by far the most expensive thing the + server does. The compare page wants both the image and its statistics, and + asks again on every reload, so without a cache a single review step would + pay for several full-page renders. + """ + + MAX_ENTRIES = 32 + + def __init__(self): + self._lock = threading.Lock() + self._entries = collections.OrderedDict() + + def get(self, path: str) -> tuple[bytes, dict]: + a = Config.path_a / path + b = Config.path_b / path + key = (path, a.stat().st_mtime_ns, b.stat().st_mtime_ns) + + with self._lock: + hit = self._entries.get(key) + if hit is not None: + self._entries.move_to_end(key) + logger.debug(f"Diff cache hit: {path}") + return hit + + logger.debug(f"Rendering diff: {path}") + with Config.browser_lock: + diff, _ = html_render_diff(a, b, Config.browser) + + width, height = diff.size + bbox = diff.getbbox() + stats = { + "available": True, + "identical": bbox is None, + "width": width, + "height": height, + } + if bbox is not None: + left, top, right, bottom = bbox + stats["first_diff"] = top / height + stats["area"] = ((right - left) * (bottom - top)) / (width * height) + stats["regions"] = diff_regions(diff) + + buffer = io.BytesIO() + highlight(diff).save(buffer, "PNG", optimize=True) + result = (buffer.getvalue(), stats) + + with self._lock: + self._entries[key] = result + while len(self._entries) > self.MAX_ENTRIES: + self._entries.popitem(last=False) + + return result + + +app = Flask(__name__) + +accepted = Accepted() +diff_cache = DiffCache() + + +@app.context_processor +def template_helpers() -> dict: + def static_url(filename: str) -> str: + """URL for a static asset, tagged with its modification time. + + Keeps a page and its scripts in lockstep: a browser holding on to an + older stylesheet or script after an upgrade would otherwise render a + page whose markup no longer matches. + """ + try: + stamp = int((Path(app.static_folder) / filename).stat().st_mtime) + except OSError: + stamp = 0 + return url_for("static", filename=filename, v=stamp) + + return {"static_url": static_url} + + +@app.after_request +def no_store_html(response: Response) -> Response: + # The listings are generated per request and go stale immediately. + if response.mimetype == "text/html": + response.headers["Cache-Control"] = "no-store" + return response + + +def resolve_in(root: Path, path: str) -> Path | None: + """Resolve ``path`` below ``root``, or None if it would escape it.""" + candidate = (root / path).resolve() + if not candidate.is_relative_to(root.resolve()): + logger.warning(f"Rejecting path outside of {root}: {path}") + return None + return candidate def collect_entries() -> list[dict]: @@ -270,6 +453,15 @@ def collect_entries() -> list[dict]: """ has_comparator = Config.comparator is not None + def entry(path: Path, message: str, result: str | None) -> dict: + key = str(path) + return { + "path": key, + "message": message, + "result": result, + "accepted": accepted.check(key), + } + def collect_one_sided(existing: Path, root: Path, message: str) -> list[dict]: """Walk a directory present on only one side. @@ -283,14 +475,7 @@ def collect_one_sided(existing: Path, root: Path, message: str) -> list[dict]: if child.is_dir(): entries.extend(collect_one_sided(child, root, message)) elif child.is_file() and comparable_file(child): - entries.append( - { - "path": str(child.relative_to(root)), - "comparable": True, - "message": message, - "result": "different", - } - ) + entries.append(entry(child.relative_to(root), message, "different")) return entries @@ -315,32 +500,11 @@ def collect(a: Path, b: Path) -> list[dict]: rel = common_path / name if name in left_files and name in right_files: result = Config.comparator.result(rel) if has_comparator else None - entries.append( - { - "path": str(rel), - "comparable": True, - "message": "", - "result": result, - } - ) + entries.append(entry(rel, "", result)) elif name in right_files: - entries.append( - { - "path": str(rel), - "comparable": True, - "message": "missing in reference (A)", - "result": "different", - } - ) + entries.append(entry(rel, "missing in reference (A)", "different")) else: - entries.append( - { - "path": str(rel), - "comparable": True, - "message": "missing in monitored (B)", - "result": "different", - } - ) + entries.append(entry(rel, "missing in monitored (B)", "different")) for name in sorted(left_dirs ^ right_dirs): if name in left_dirs: @@ -366,296 +530,57 @@ def collect(a: Path, b: Path) -> list[dict]: return entries -@app.route("/script.js") -def script_js(): - logger.debug("Serving script.js") - - return r""" -function updateRef(path) { - fetch(`/update_ref/${path}`) - .then(response => { - if (response.ok) { - alert(`Reference updated for ${path}`); - location.reload(); - } else { - alert(`Failed to update reference for ${path}: ${response.statusText}`); - } - }) - .catch(error => { - alert(`Error updating reference for ${path}: ${error}`); - }); -} -""" - - @app.route("/") def root(): logger.debug("Generating root directory listing") - has_comparator = Config.comparator is not None - entries = collect_entries() - - def badge(result: str | None) -> str: - if result is None: - return "" - return f'{result}' - - rows = [] - for e in entries: - path = e["path"] - if e["comparable"]: - name_cell = f'{path}' - else: - name_cell = f"{path}" - rows.append( - f'' - f'{badge(e["result"])}' - f'{name_cell}' - f'{e["message"]}' - f'' - f"" - ) - - log_link = "" - if Config.log_file is not None: - log_link = f'log file' - - head = r""" - - - -compare-html - - - - -""" - - header = rf"""
-
compare-html
-
-
reference (A): {Config.path_a}
-
monitored (B): {Config.path_b}
-
-
- -
- 0 total - 0 same - 0 diff - 0 pending -
-
- - - - -
- - {log_link} -
-
-""" - - table = ( - "" - "" - "" + "".join(rows) + "
StatusPathMessageActions
" + return render_template( + "index.html", + entries=collect_entries(), + live=Config.comparator is not None, + path_a=Config.path_a, + path_b=Config.path_b, + log_file=Config.log_file, ) - script = ( - r""" - - - -""" + +@app.route("/compare/") +def compare(path: str): + logger.debug(f"Generating comparison page for path: {path}") + + a = resolve_in(Config.path_a, path) + b = resolve_in(Config.path_b, path) + if a is None or b is None: + return "Invalid path", 400 + + return render_template( + "compare.html", + path=path, + file_a=Config.path_a / path, + file_b=Config.path_b / path, + live=Config.comparator is not None, + diff_available=Config.driver is not None and a.is_file() and b.is_file(), ) - return head + header + table + script + +@app.route("/api/entries") +def api_entries(): + logger.debug("Serving entries") + + return { + "live": Config.comparator is not None, + "entries": collect_entries(), + } @app.route("/status") def status(): + """Legacy status endpoint: a flat path -> result mapping.""" logger.debug("Serving comparison status") if Config.comparator is None: return {} - # Walk the filesystem the same way the page does so a poll reflects the - # current state (including files created/deleted after startup), rather - # than a cache the watchdog may not have kept current. return {e["path"]: e["result"] for e in collect_entries()} @@ -669,110 +594,78 @@ def logfile(): return send_from_directory(Config.log_file.parent, Config.log_file.name) -@app.route("/compare/") -def compare(path: str): - logger.debug(f"Generating comparison page for path: {path}") +def render_diff(path: str) -> tuple[bytes | None, dict, int]: + """Render (or fetch from cache) the diff for ``path``. + + Returns the PNG bytes, the statistics and an HTTP status code; the bytes + are None when no diff could be produced. + """ + if Config.driver is None: + return None, {"available": False, "error": "no browser driver"}, 404 + + a = resolve_in(Config.path_a, path) + b = resolve_in(Config.path_b, path) + if a is None or b is None: + return None, {"available": False, "error": "invalid path"}, 400 + if not a.is_file() or not b.is_file(): + return None, {"available": False, "error": "file missing on one side"}, 404 - if not isinstance(path, str): - raise TypeError("Path must be a string") - - return rf""" - - - - - - -
- {Config.path_a / path} - -
-
- diff - - -
-
- {Config.path_b / path} - -
- - - -""" + try: + image, stats = diff_cache.get(path) + except Exception as error: + logger.exception(f"Failed to render diff for path: {path}") + return None, {"available": False, "error": str(error)}, 500 + + return image, stats, 200 @app.route("/image_diff/") def image_diff(path: str): - logger.debug(f"Generating image diff for path: {path}") + logger.debug(f"Serving image diff for path: {path}") - if not isinstance(path, str): - raise TypeError("Path must be a string") + image, stats, code = render_diff(path) + if image is None: + return stats.get("error", "image diff not available"), code - if Config.driver is None: - return "Image diff not available without browser driver", 404 + return Response(image, mimetype="image/png") - if not (Config.path_a / path).is_file() or not (Config.path_b / path).is_file(): - return "Image diff not available: file missing on one side", 404 - with Config.browser_lock: - diff, _ = html_render_diff( - Config.path_a / path, - Config.path_b / path, - Config.browser, - ) - tmp = io.BytesIO() - diff.save(tmp, "JPEG", quality=70) - tmp.seek(0) - return send_file(tmp, mimetype="image/jpeg") +@app.route("/api/diff_info/") +def api_diff_info(path: str): + logger.debug(f"Serving diff info for path: {path}") + + _, stats, code = render_diff(path) + return stats, code @app.route("/file//") def file(variant: str, path: str): logger.debug(f"Serving file for variant: {variant}, path: {path}") - if not isinstance(variant, str) or not isinstance(path, str): - raise TypeError("Variant and path must be strings") if variant not in ["a", "b"]: - raise ValueError("Variant must be 'a' or 'b'") + return "Variant must be 'a' or 'b'", 404 variant_root = Config.path_a if variant == "a" else Config.path_b - if not (variant_root / path).is_file(): + resolved = resolve_in(variant_root, path) + if resolved is None: + return "Invalid path", 400 + + if not resolved.is_file(): side = "reference (A)" if variant == "a" else "monitored (B)" - return ( - "" - f"
file missing in {side}
" - ) + return render_template("missing.html", side=side), 404 return send_from_directory(variant_root, path) -@app.route("/update_ref/") -def update_ref(path: str): - logger.debug(f"Updating reference for path: {path}") - - if not isinstance(path, str): - raise TypeError("Path must be a string") - - src = Config.path_b / path - dst = Config.path_a / path +def do_update_ref(path: str): + src = resolve_in(Config.path_b, path) + dst = resolve_in(Config.path_a, path) + if src is None or dst is None: + return {"error": "invalid path"}, 400 if not src.exists(): - return f"Source file does not exist: {src}", 404 + return {"error": f"source does not exist: {src}"}, 404 dst.parent.mkdir(parents=True, exist_ok=True) @@ -781,6 +674,31 @@ def update_ref(path: str): else: shutil.copytree(src, dst, dirs_exist_ok=True) + accepted.mark(path) + + # The watchdog picks the copy up as well, but re-comparing right away keeps + # the UI from briefly showing the old result after an accepted diff. + if Config.comparator is not None and src.is_file(): + Config.comparator.submit(Path(path)) + + return {"ok": True, "path": path}, 200 + + +@app.route("/api/update_ref/", methods=["POST"]) +def api_update_ref(path: str): + logger.debug(f"Updating reference for path: {path}") + + return do_update_ref(path) + + +@app.route("/update_ref/", methods=["GET", "POST"]) +def update_ref(path: str): + """Legacy endpoint kept for scripted use; prefer POST /api/update_ref.""" + logger.debug(f"Updating reference for path: {path}") + + body, code = do_update_ref(path) + if code != 200: + return body["error"], code return "Reference updated", 200 diff --git a/src/htmlcmp/html_render_diff.py b/src/htmlcmp/html_render_diff.py index cae4f34..13f0f51 100755 --- a/src/htmlcmp/html_render_diff.py +++ b/src/htmlcmp/html_render_diff.py @@ -55,6 +55,19 @@ def screenshot(browser: webdriver.Remote, url: str) -> Image.Image: return Image.open(io.BytesIO(png)) +def content_bottom(image: Image.Image) -> int: + """Row just below the last pixel that differs from the page background. + + The background is sampled from the bottom right corner, so a page that + paints its whole window (a full-height gradient, say) simply yields the + full height and nothing is cropped. + """ + background = image.getpixel((image.width - 1, image.height - 1)) + canvas = Image.new(image.mode, image.size, background) + bbox = ImageChops.difference(image, canvas).getbbox() + return 0 if bbox is None else bbox[3] + + def get_browser( driver: str, max_width: int = 1000, max_height: int = 10000 ) -> webdriver.Remote: @@ -94,11 +107,18 @@ def html_render_diff( elif not isinstance(browser_b, webdriver.Remote): raise TypeError(f"Expected webdriver.Remote, got {type(browser_b)}") - image_a = screenshot(browser, to_url(a)) - image_b = screenshot(browser_b, to_url(b)) + image_a = screenshot(browser, to_url(a)).convert("RGB") + image_b = screenshot(browser_b, to_url(b)).convert("RGB") + + # The browser window is deliberately very tall so that long pages fit into + # one screenshot, which leaves most shots mostly empty. That empty tail is + # identical on both sides by construction and would otherwise dominate + # every position and area derived from the diff, so it is cropped away. + height = max(content_bottom(image_a), content_bottom(image_b), 1) + if height < min(image_a.height, image_b.height): + image_a = image_a.crop((0, 0, image_a.width, height)) + image_b = image_b.crop((0, 0, image_b.width, height)) - image_a = image_a.convert("RGB") - image_b = image_b.convert("RGB") diff = ImageChops.difference(image_a, image_b) return diff, (image_a, image_b) diff --git a/src/htmlcmp/static/common.js b/src/htmlcmp/static/common.js new file mode 100644 index 0000000..df61442 --- /dev/null +++ b/src/htmlcmp/static/common.js @@ -0,0 +1,38 @@ +"use strict"; + +/* Helpers shared by the index and compare pages. */ + +function encodePath(path) { + return path.split("/").map(encodeURIComponent).join("/"); +} + +let toastTimer = null; + +function showToast(message, isError) { + const el = document.getElementById("toast"); + if (!el) return; + el.textContent = message; + el.classList.toggle("error", !!isError); + el.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove("show"), 2600); +} + +/* Copy the monitored (B) file over the reference (A) file. + Resolves to an error message, or null on success. */ +async function updateRef(path) { + try { + const res = await fetch(`/api/update_ref/${encodePath(path)}`, { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return data.error || res.statusText || "request failed"; + return null; + } catch (e) { + return String(e); + } +} + +async function fetchEntries() { + const res = await fetch("/api/entries"); + if (!res.ok) throw new Error(res.statusText); + return await res.json(); +} diff --git a/src/htmlcmp/static/compare.js b/src/htmlcmp/static/compare.js new file mode 100644 index 0000000..8465572 --- /dev/null +++ b/src/htmlcmp/static/compare.js @@ -0,0 +1,340 @@ +"use strict"; + +const body = document.body; +const PATH = body.dataset.path; +const LIVE = body.dataset.live === "true"; + +const frameA = document.getElementById("frame-a"); +const frameB = document.getElementById("frame-b"); + +/* ---------------- view mode ---------------- */ + +function setView(view) { + body.dataset.view = view; + for (const b of document.getElementById("views").children) { + b.classList.toggle("active", b.dataset.view === view); + } + localStorage.setItem("htmlcmp.view", view); +} + +function setDiffOpen(open) { + body.dataset.diffOpen = open ? "true" : "false"; + localStorage.setItem("htmlcmp.diffOpen", open ? "true" : "false"); +} + +document.getElementById("views").addEventListener("click", (event) => { + const btn = event.target.closest("button"); + if (btn) setView(btn.dataset.view); +}); + +document.getElementById("toggle-diff").addEventListener("click", () => { + setDiffOpen(body.dataset.diffOpen !== "true"); +}); + +document.getElementById("expand-diff").addEventListener("click", () => { + document.getElementById("diff-col").classList.toggle("wide"); +}); + +setView(localStorage.getItem("htmlcmp.view") || "split"); +setDiffOpen(localStorage.getItem("htmlcmp.diffOpen") !== "false"); + +/* ---------------- navigation ---------------- */ + +const diffsOnlyBox = document.getElementById("diffs-only"); +const prevBtn = document.getElementById("prev"); +const nextBtn = document.getElementById("next"); +const positionEl = document.getElementById("position"); + +let entries = []; +let prevPath = null; +let nextPath = null; + +diffsOnlyBox.checked = localStorage.getItem("htmlcmp.diffsOnly") === "true"; +diffsOnlyBox.addEventListener("change", () => { + localStorage.setItem("htmlcmp.diffsOnly", diffsOnlyBox.checked ? "true" : "false"); + renderNav(); +}); + +function navCandidates() { + if (!diffsOnlyBox.checked) return entries; + return entries.filter((e) => e.result !== "same"); +} + +function renderNav() { + const candidates = navCandidates(); + prevPath = null; + nextPath = null; + for (const e of candidates) { + if (e.path < PATH) prevPath = e.path; + else if (e.path > PATH && nextPath === null) nextPath = e.path; + } + prevBtn.disabled = prevPath === null; + nextBtn.disabled = nextPath === null; + + const index = candidates.findIndex((e) => e.path === PATH); + positionEl.textContent = + candidates.length === 0 + ? "–" + : `${index >= 0 ? index + 1 : "–"} / ${candidates.length}`; +} + +function goTo(path) { + if (path) location.href = `/compare/${encodePath(path)}`; +} + +prevBtn.addEventListener("click", () => goTo(prevPath)); +nextBtn.addEventListener("click", () => goTo(nextPath)); + +/* ---------------- status ---------------- */ + +const statusBadge = document.getElementById("status-badge"); +const acceptedBadge = document.getElementById("accepted-badge"); + +function renderStatus(entry) { + if (entry && entry.result) { + statusBadge.hidden = false; + statusBadge.className = `badge ${entry.result}`; + statusBadge.textContent = entry.message ? `${entry.result} · ${entry.message}` : entry.result; + } else if (entry && entry.message) { + statusBadge.hidden = false; + statusBadge.className = "badge"; + statusBadge.textContent = entry.message; + } else { + statusBadge.hidden = true; + } + acceptedBadge.hidden = !(entry && entry.accepted); +} + +async function refreshEntries() { + try { + const data = await fetchEntries(); + entries = data.entries; + } catch (e) { + return; + } + renderNav(); + renderStatus(entries.find((e) => e.path === PATH)); +} + +/* ---------------- diff panel ---------------- */ + +const minimap = document.getElementById("minimap"); +const diffImg = document.getElementById("diff-img"); +const viewportEl = document.getElementById("viewport"); +const regionsEl = document.getElementById("regions"); +const statsEl = document.getElementById("diff-stats"); +const noteEl = document.getElementById("diff-note"); + +function percent(fraction) { + return `${(100 * fraction).toFixed(1)}%`; +} + +function showDiffPanel(available, note) { + minimap.hidden = !available; + statsEl.hidden = !available; + noteEl.hidden = available; + noteEl.textContent = note || ""; +} + +/* Whether a rendered diff exists can change while the page is open — updating + the reference creates a file that was missing a moment ago — so the panel + follows what the endpoint reports rather than the state at render time. */ +async function refreshDiffInfo() { + try { + const res = await fetch(`/api/diff_info/${encodePath(PATH)}`); + const info = await res.json(); + if (!res.ok || !info.available) { + showDiffPanel(false, `no rendered diff: ${info.error || res.statusText}`); + return; + } + showDiffPanel(true); + regionsEl.replaceChildren(); + if (info.identical) { + statsEl.textContent = "renders identically"; + return; + } + statsEl.innerHTML = + `first diff at ${percent(info.first_diff)}
` + + `bounding box ${percent(info.area)} of page`; + for (const [start, end] of info.regions || []) { + const bar = document.createElement("div"); + bar.className = "region"; + bar.style.top = `${100 * start}%`; + bar.style.height = `${100 * (end - start)}%`; + regionsEl.append(bar); + } + } catch (e) { + showDiffPanel(false, "no rendered diff"); + } +} + +function reloadDiffImage() { + const base = diffImg.src.split("?")[0]; + diffImg.src = `${base}?t=${Date.now()}`; + refreshDiffInfo(); +} + +/* The diff strip is a full-page render squeezed into the column, so a vertical + position on it maps onto the documents' scroll height. */ +minimap.addEventListener("click", (event) => { + if (document.getElementById("diff-col").classList.contains("wide")) return; + const rect = minimap.getBoundingClientRect(); + const fraction = (event.clientY - rect.top) / rect.height; + for (const frame of [frameA, frameB]) { + const win = frameWindow(frame); + if (!win) continue; + const doc = win.document.documentElement; + win.scrollTo({ top: fraction * doc.scrollHeight - win.innerHeight / 2 }); + } +}); + +function updateViewportIndicator() { + if (!viewportEl) return; + const win = frameWindow(frameA) || frameWindow(frameB); + if (!win) return; + const height = win.document.documentElement.scrollHeight; + if (!height) return; + const visible = win.innerHeight / height; + // Nothing to point at when the whole document fits on screen. + viewportEl.hidden = visible > 0.98; + viewportEl.style.top = `${(100 * win.scrollY) / height}%`; + viewportEl.style.height = `${Math.min(100, 100 * visible)}%`; +} + +/* ---------------- scroll sync ---------------- */ + +function frameWindow(frame) { + try { + return frame.contentWindow && frame.contentWindow.document ? frame.contentWindow : null; + } catch (e) { + return null; // cross-origin, should not happen for local files + } +} + +/* Whichever pane the user last scrolled drives the other one for a short + while; that keeps the echo scroll events the sync itself provokes from + bouncing back and fighting the pane being scrolled. */ +let driver = null; +let driverUntil = 0; + +function syncFrom(source, target) { + const now = performance.now(); + if (driver !== source && now < driverUntil) return; + + driver = source; + driverUntil = now + 100; + + updateViewportIndicator(); + + const from = frameWindow(source); + const to = frameWindow(target); + if (!from || !to) return; + if (to.scrollX === from.scrollX && to.scrollY === from.scrollY) return; + to.scrollTo(from.scrollX, from.scrollY); +} + +/* Listeners must be (re-)attached on every load: navigating an iframe replaces + its window, dropping anything registered on the previous one. */ +function attachFrame(frame, other) { + const win = frameWindow(frame); + if (!win) return; + win.addEventListener("scroll", () => syncFrom(frame, other), { passive: true }); + win.document.addEventListener("keydown", onKeyDown); + updateViewportIndicator(); +} + +frameA.addEventListener("load", () => attachFrame(frameA, frameB)); +frameB.addEventListener("load", () => attachFrame(frameB, frameA)); +attachFrame(frameA, frameB); +attachFrame(frameB, frameA); + +function reloadFrames() { + for (const frame of [frameA, frameB]) { + // eslint-disable-next-line no-self-assign + frame.src = frame.src; + } +} + +/* ---------------- actions ---------------- */ + +const updateBtn = document.getElementById("update-ref"); + +async function doUpdateRef() { + updateBtn.disabled = true; + const label = updateBtn.textContent; + updateBtn.textContent = "Updating…"; + const error = await updateRef(PATH); + updateBtn.textContent = label; + updateBtn.disabled = false; + if (error) { + showToast(`Update failed: ${error}`, true); + return; + } + showToast("Reference updated — diff accepted"); + acceptedBadge.hidden = false; + reloadFrames(); + reloadDiffImage(); + refreshEntries(); +} + +updateBtn.addEventListener("click", doUpdateRef); + +/* ---------------- keyboard ---------------- */ + +const help = document.getElementById("help"); +document.getElementById("help-btn").addEventListener("click", () => help.showModal()); + +function onKeyDown(event) { + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return; + + switch (event.key) { + case "ArrowLeft": + case "k": + goTo(prevPath); + break; + case "ArrowRight": + case "j": + goTo(nextPath); + break; + case "1": + setView("a"); + break; + case "2": + setView("split"); + break; + case "3": + setView("b"); + break; + case "d": + setDiffOpen(body.dataset.diffOpen !== "true"); + break; + case "u": + doUpdateRef(); + break; + case "r": + reloadFrames(); + reloadDiffImage(); + break; + case "Escape": + if (help.open) help.close(); + else location.href = "/"; + break; + case "?": + help.open ? help.close() : help.showModal(); + break; + default: + return; + } + event.preventDefault(); +} + +document.addEventListener("keydown", onKeyDown); + +/* ---------------- boot ---------------- */ + +refreshEntries(); +refreshDiffInfo(); +setInterval(updateViewportIndicator, 500); +if (LIVE) setInterval(refreshEntries, 2000); diff --git a/src/htmlcmp/static/index.js b/src/htmlcmp/static/index.js new file mode 100644 index 0000000..ab3f4db --- /dev/null +++ b/src/htmlcmp/static/index.js @@ -0,0 +1,152 @@ +"use strict"; + +const LIVE = document.body.dataset.live === "true"; +const tbody = document.querySelector("tbody"); + +let activeFilter = "all"; + +function rowMatchesFilter(tr) { + if (activeFilter === "all") return true; + if (activeFilter === "accepted") return tr.dataset.accepted === "true"; + return tr.dataset.status === activeFilter; +} + +function applyFilter() { + const q = document.getElementById("search").value.trim().toLowerCase(); + for (const tr of tbody.rows) { + const matchesSearch = !q || tr.dataset.path.toLowerCase().includes(q); + tr.hidden = !(rowMatchesFilter(tr) && matchesSearch); + } +} + +function updateSummary() { + const counts = { total: 0, same: 0, different: 0, pending: 0, accepted: 0 }; + for (const tr of tbody.rows) { + counts.total++; + if (counts[tr.dataset.status] !== undefined) counts[tr.dataset.status]++; + if (tr.dataset.accepted === "true") counts.accepted++; + } + for (const el of document.querySelectorAll("#filters .count")) { + el.textContent = counts[el.dataset.count]; + } +} + +function renderRow(tr, entry) { + const status = entry.result || ""; + if (tr.dataset.status !== status) { + tr.dataset.status = status; + const badge = tr.querySelector(".status .badge:not(.accepted)"); + if (status) { + if (badge) { + badge.className = `badge ${status}`; + badge.textContent = status; + } else { + tr.querySelector(".status").insertAdjacentHTML( + "afterbegin", + `` + ); + tr.querySelector(".status .badge").textContent = status; + } + } else if (badge) { + badge.remove(); + } + } + const accepted = !!entry.accepted; + if ((tr.dataset.accepted === "true") !== accepted) { + tr.dataset.accepted = accepted ? "true" : "false"; + tr.querySelector(".status .badge.accepted").hidden = !accepted; + } + const message = tr.querySelector(".message"); + if (message.textContent !== (entry.message || "")) { + message.textContent = entry.message || ""; + } +} + +/* Refresh statuses in place; reload only when the set of files changed. */ +async function poll() { + let data; + try { + data = await fetchEntries(); + } catch (e) { + return; + } + const known = new Set([...tbody.rows].map((tr) => tr.dataset.path)); + if (data.entries.length !== known.size || data.entries.some((e) => !known.has(e.path))) { + location.reload(); + return; + } + const byPath = new Map(data.entries.map((e) => [e.path, e])); + for (const tr of tbody.rows) { + const entry = byPath.get(tr.dataset.path); + if (entry) renderRow(tr, entry); + } + updateSummary(); + applyFilter(); +} + +async function updateAll() { + const rows = [...tbody.rows].filter((tr) => !tr.hidden); + if (rows.length === 0) { + showToast("No files in the current view.", true); + return; + } + if (!confirm(`Update the reference for ${rows.length} file(s) in the current view?`)) return; + + const btn = document.getElementById("update-all"); + const label = btn.textContent; + btn.disabled = true; + + const failed = []; + let done = 0; + for (const tr of rows) { + btn.textContent = `Updating ${++done}/${rows.length}…`; + const error = await updateRef(tr.dataset.path); + if (error) failed.push(tr.dataset.path); + } + + btn.disabled = false; + btn.textContent = label; + if (failed.length) { + showToast(`Updated ${rows.length - failed.length}/${rows.length}, ${failed.length} failed.`, true); + } else { + showToast(`Updated ${rows.length} reference file(s).`); + } + poll(); +} + +document.getElementById("search").addEventListener("input", applyFilter); + +document.getElementById("filters").addEventListener("click", (event) => { + const btn = event.target.closest("button"); + if (!btn) return; + activeFilter = btn.dataset.filter; + for (const b of event.currentTarget.children) b.classList.toggle("active", b === btn); + applyFilter(); +}); + +document.getElementById("update-all").addEventListener("click", updateAll); + +tbody.addEventListener("click", async (event) => { + const btn = event.target.closest("button[data-action='update-ref']"); + if (!btn) return; + const tr = btn.closest("tr"); + btn.disabled = true; + const error = await updateRef(tr.dataset.path); + btn.disabled = false; + if (error) { + showToast(`${tr.dataset.path}: ${error}`, true); + } else { + showToast(`Reference updated: ${tr.dataset.path}`); + poll(); + } +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "/" && document.activeElement !== document.getElementById("search")) { + event.preventDefault(); + document.getElementById("search").focus(); + } +}); + +updateSummary(); +if (LIVE) setInterval(poll, 1500); diff --git a/src/htmlcmp/static/style.css b/src/htmlcmp/static/style.css new file mode 100644 index 0000000..e402db1 --- /dev/null +++ b/src/htmlcmp/static/style.css @@ -0,0 +1,253 @@ +/* Shared styling for the compare-html server pages. */ + +:root { + color-scheme: light dark; + + --bg: #fafafa; + --surface: #ffffff; + --surface-alt: #f4f5f7; + --text: #1f2023; + --muted: #63666c; + --border: #e2e3e6; + --accent: #1a73e8; + --accent-text: #ffffff; + + --green: #137333; --green-bg: #e6f4ea; + --red: #c5221f; --red-bg: #fce8e6; + --amber: #b06000; --amber-bg: #fef7e0; + --blue: #1a56b8; --blue-bg: #e8f0fe; + + --radius: 8px; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16171a; + --surface: #1e2024; + --surface-alt: #24262b; + --text: #e6e7ea; + --muted: #9ba0a8; + --border: #33363c; + --accent: #6ea8fe; + --accent-text: #10131a; + + --green: #81c995; --green-bg: #1c3025; + --red: #f28b82; --red-bg: #38211f; + --amber: #fdd663; --amber-bg: #352b12; + --blue: #8ab4f8; --blue-bg: #1c2a44; + } +} + +* { box-sizing: border-box; } + +/* Several components below set an explicit display, which would otherwise + override the user agent's rule for the hidden attribute. */ +[hidden] { display: none !important; } + +html, body { height: 100%; } + +body { + margin: 0; + font-family: var(--sans); + font-size: 13px; + color: var(--text); + background: var(--bg); +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +code { font-family: var(--mono); } + +/* ---------- shared atoms ---------- */ + +.btn { + display: inline-flex; align-items: center; gap: 6px; + font: inherit; font-size: 12px; line-height: 1; + padding: 6px 10px; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); + cursor: pointer; + white-space: nowrap; +} +.btn:hover:not(:disabled) { background: var(--surface-alt); } +.btn:disabled { opacity: .45; cursor: not-allowed; } +.btn.primary { + background: var(--accent); color: var(--accent-text); border-color: transparent; + font-weight: 600; +} +.btn.primary:hover:not(:disabled) { filter: brightness(1.08); } +.btn.ghost { background: transparent; } +.btn.icon { padding: 6px 9px; font-size: 13px; } + +.badge { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: 999px; + font-size: 11px; font-weight: 600; white-space: nowrap; + background: var(--surface-alt); color: var(--muted); +} +.badge.same { background: var(--green-bg); color: var(--green); } +.badge.different { background: var(--red-bg); color: var(--red); } +.badge.pending { background: var(--amber-bg); color: var(--amber); } +.badge.accepted { background: var(--blue-bg); color: var(--blue); } + +.tag { + display: inline-block; width: 16px; height: 16px; line-height: 16px; + text-align: center; border-radius: 4px; + font-size: 10px; font-weight: 700; + background: var(--surface-alt); color: var(--muted); +} +.tag.a { background: var(--blue-bg); color: var(--blue); } +.tag.b { background: var(--amber-bg); color: var(--amber); } + +.spacer { flex: 1; } + +.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.segmented button { + font: inherit; font-size: 12px; line-height: 1; + padding: 6px 10px; border: 0; border-right: 1px solid var(--border); + background: var(--surface); color: var(--muted); cursor: pointer; +} +.segmented button:last-child { border-right: 0; } +.segmented button.active { background: var(--accent); color: var(--accent-text); font-weight: 600; } + +/* Counts live inside the filter buttons: one place that names each state. */ +.segmented .count { + display: inline-block; margin-left: 5px; padding: 1px 6px; border-radius: 999px; + font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; + background: var(--surface-alt); color: var(--muted); +} +.segmented .count.same { background: var(--green-bg); color: var(--green); } +.segmented .count.different { background: var(--red-bg); color: var(--red); } +.segmented .count.pending { background: var(--amber-bg); color: var(--amber); } +.segmented .count.accepted { background: var(--blue-bg); color: var(--blue); } +.segmented button.active .count { background: rgba(255, 255, 255, .22); color: inherit; } + +/* ---------- index page ---------- */ + +body.index { display: flex; flex-direction: column; } + +body.index header { + position: sticky; top: 0; z-index: 3; + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 12px 16px; +} +.headline { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; } +.title { font-size: 16px; font-weight: 600; } +.paths { font-size: 12px; color: var(--muted); margin-top: 4px; display: grid; gap: 2px; } +.paths > div { display: flex; align-items: center; gap: 6px; } + +.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; } +.toolbar input[type="search"] { + flex: 1 1 220px; min-width: 160px; + padding: 7px 10px; font: inherit; font-size: 13px; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); +} + +body.index main { flex: 1; overflow: auto; } + +table { width: 100%; border-collapse: collapse; } +thead th { + position: sticky; top: 0; z-index: 1; + text-align: left; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; + color: var(--muted); font-weight: 600; + padding: 8px 12px; background: var(--surface-alt); + border-bottom: 1px solid var(--border); +} +tbody td { padding: 5px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; } +tbody tr:hover { background: var(--surface-alt); } +td.status { white-space: nowrap; } +td.status .badge + .badge { margin-left: 4px; } +td.path { font-family: var(--mono); font-size: 12px; word-break: break-all; } +td.message { color: var(--muted); font-size: 12px; } +td.actions { text-align: right; white-space: nowrap; } + +.empty { padding: 40px; text-align: center; color: var(--muted); } + +/* ---------- compare page ---------- */ + +body.compare { display: flex; flex-direction: column; overflow: hidden; } + +body.compare header { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 8px 12px; + background: var(--surface); border-bottom: 1px solid var(--border); +} +header .group { display: flex; align-items: center; gap: 8px; min-width: 0; } +.crumb { + font-family: var(--mono); font-size: 12px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46vw; +} +.position { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; min-width: 64px; text-align: center; } +.switch { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); cursor: pointer; } + +main.panes { flex: 1; display: flex; min-height: 0; } + +.pane { display: flex; flex-direction: column; flex: 1 1 50%; min-width: 0; border-right: 1px solid var(--border); } +.pane:last-child { border-right: 0; } +.pane-head { + display: flex; align-items: center; gap: 6px; + padding: 5px 10px; font-size: 11px; color: var(--muted); + background: var(--surface-alt); border-bottom: 1px solid var(--border); +} +.pane-head .file { font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pane iframe { flex: 1; width: 100%; border: 0; background: #fff; } + +body.compare[data-view="a"] #pane-b, +body.compare[data-view="b"] #pane-a { display: none; } +body.compare[data-diff-open="false"] #diff-col { display: none; } + +#diff-col { + display: flex; flex-direction: column; + flex: 0 0 var(--diff-width, 72px); + border-right: 1px solid var(--border); + background: var(--surface-alt); +} +#diff-col.wide { --diff-width: 320px; } +.minimap { position: relative; flex: 1; min-height: 0; cursor: crosshair; overflow: hidden; } +.minimap img { display: block; width: 100%; height: 100%; object-fit: fill; background: #000; } +#diff-col.wide .minimap { overflow: auto; cursor: default; } +#diff-col.wide .minimap img { height: auto; object-fit: contain; } +.minimap .viewport { + position: absolute; left: 0; right: 0; + border: 1px solid color-mix(in srgb, var(--accent) 70%, transparent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + pointer-events: none; +} +.minimap .regions { position: absolute; inset: 0; pointer-events: none; } +.minimap .region { + position: absolute; left: 0; right: 0; min-height: 3px; + background: rgba(255, 76, 76, .75); + box-shadow: 0 0 0 1px rgba(255, 76, 76, .35); +} +.diff-stats { padding: 6px 8px; font-size: 10px; color: var(--muted); line-height: 1.4; border-top: 1px solid var(--border); } +.diff-note { padding: 10px 8px; font-size: 11px; color: var(--muted); text-align: center; } + +.toast { + position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); + padding: 9px 14px; border-radius: var(--radius); + background: var(--text); color: var(--bg); + font-size: 12px; z-index: 20; opacity: 0; pointer-events: none; + transition: opacity .15s ease; +} +.toast.show { opacity: 1; } +.toast.error { background: var(--red); color: #fff; } + +dialog#help { + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); padding: 16px 20px; + font-size: 13px; min-width: 260px; +} +dialog#help::backdrop { background: rgba(0, 0, 0, .35); } +dialog#help h2 { margin: 0 0 10px; font-size: 14px; } +dialog#help dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; margin: 0; } +dialog#help dt { font-family: var(--mono); color: var(--muted); } +kbd { + font-family: var(--mono); font-size: 11px; + border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; + padding: 1px 5px; background: var(--surface-alt); +} diff --git a/src/htmlcmp/templates/compare.html b/src/htmlcmp/templates/compare.html new file mode 100644 index 0000000..40c5079 --- /dev/null +++ b/src/htmlcmp/templates/compare.html @@ -0,0 +1,104 @@ + + + + + +{{ path }} — compare-html + + + + + + +
+
+ + {{ path }} + + +
+ +
+ +
+ + + + +
+ +
+ +
+
+ + + +
+ + + +
+
+ +
+
+
+ A + {{ file_a }} + + open +
+ +
+ + + +
+
+ B + {{ file_b }} + + open +
+ +
+
+ + +

Keyboard shortcuts

+
+
← / k
previous file
+
→ / j
next file
+
1 / 2 / 3
A only / side by side / B only
+
d
toggle diff panel
+
u
update reference (copy B over A)
+
r
reload both panes
+
Esc
back to index
+
?
this help
+
+
+ +
+ + diff --git a/src/htmlcmp/templates/index.html b/src/htmlcmp/templates/index.html new file mode 100644 index 0000000..8c7f5cb --- /dev/null +++ b/src/htmlcmp/templates/index.html @@ -0,0 +1,69 @@ + + + + + +compare-html + + + + + + +
+
+
+
compare-html
+
+
A reference {{ path_a }}
+
B monitored {{ path_b }}
+
+
+
+ +
+ +
+ + + + + +
+ + {% if log_file %}log file{% endif %} +
+
+ +
+ + + + + + + + + + + {% for e in entries %} + + + + + + + {% endfor %} + +
StatusPathMessage
+ {% if e.result %}{{ e.result }}{% endif %} + ✓ accepted + {{ e.path }}{{ e.message }}
+ {% if not entries %}
No comparable files found.
{% endif %} +
+ +
+ + diff --git a/src/htmlcmp/templates/missing.html b/src/htmlcmp/templates/missing.html new file mode 100644 index 0000000..7142e7f --- /dev/null +++ b/src/htmlcmp/templates/missing.html @@ -0,0 +1,15 @@ + + + + +file missing + + + + +
file missing in {{ side }}
+ +