From c8aae5e042fa0daa863e11d7af6ad1b670de8aec Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Thu, 5 Nov 2026 09:52:16 +0000 Subject: [PATCH] feat(web): GitHub-style Changes view for compare mode A "Changes" tab next to the compare overview (#/diff/files) lists every changed component the way a pull request lists changed files: a tree of the components by schema file (status, breaking dot, scroll-spy), and a card per component with its semantic changes, notes under the source lines they are about, and the source diff (split or unified, word highlights, @@ hunks with real line numbers, folded unchanged lines). Cards load as they scroll near, can be marked viewed (stored per pair of versions), and filters, layout and the current card are kept in the link. Keys: j/k, v, s, t, /. The source loading and the diff table move out of the side-by-side panel into views/codeDiff.ts, shared by both; it now also shows JSON Schema sources and colours the XML in the diff. --- README.md | 13 +- web/src/app.ts | 6 + web/src/ctx.ts | 2 +- web/src/styles.css | 628 +++++++++++++++++++++++++++++++++++- web/src/views/changes.ts | 606 ++++++++++++++++++++++++++++++++++ web/src/views/codeDiff.ts | 288 +++++++++++++++++ web/src/views/compare.ts | 17 +- web/src/views/dialogs.ts | 1 + web/src/views/sideBySide.ts | 197 +---------- 9 files changed, 1567 insertions(+), 191 deletions(-) create mode 100644 web/src/views/changes.ts create mode 100644 web/src/views/codeDiff.ts diff --git a/README.md b/README.md index 10627ec..13461e5 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,8 @@ own files on it. also be seen **side by side**: both versions' own class diagrams (laid out together, pan and zoom linked), their content trees lined up row by row, and their XSD source with a line and word diff (or unified), plus the full - page of either version. + page of either version. A **Changes** tab lists every changed component + with its source diff, as a pull request lists changed files. - **Share links** that restore the page, the diagram's options, added boxes, open tree nodes and zoom; light and dark themes; a short manual (Help). @@ -157,7 +158,15 @@ Several paths for one side are joined with commas. The JSON diff format is versioned (`format`, see `crates/core/src/diff.rs`). In the viewer, "Compare two versions" on the open page takes two schema sets; a link `?bundle=&compare=` opens the comparison directly. The overview -lists the changes with filters. Each changed component gets a page with +lists the changes with filters. The *Changes* tab (`#/diff/files`) shows +them the way a pull request shows changed files: a tree of the changed +components by schema file, and a card per component with its semantic +changes, notes on the source lines they are about, and the source diff +(split or unified, with word highlights and folded unchanged lines). Cards +can be marked as viewed (kept in the browser); filters, layout and the +current card are kept in the link; `j`/`k` move between cards, `v` marks +one viewed, `s` switches split and unified, `t` shows the tree and `/` +filters it. Each changed component gets a page with four views (kept in the link as `v=`): *Changes* marks what was added (+, green), removed (−, red, struck through) and changed (~, amber) on the later version's diagram; *Side by side* shows each version's own diagram or diff --git a/web/src/app.ts b/web/src/app.ts index d533cc3..d3afff5 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -9,6 +9,7 @@ import { openAbout, openHelp } from "./views/dialogs"; import { homePage } from "./views/home"; import { overviewPage } from "./views/overview"; import { diffBanner, diffComponentPage, diffLink, diffOverviewPage } from "./views/compare"; +import { diffFilesPage } from "./views/changes"; import { oldVersionBanner, versionCtx } from "./views/sideBySide"; import { componentPage, filePage } from "./views/pages"; import { sidebar, type Sidebar } from "./views/sidebar"; @@ -232,6 +233,7 @@ export class App implements AppCtx { const i = r.query.indexOf("/"); path = `#/diff/${r.query.slice(0, i)}/${encodeURIComponent(r.query.slice(i + 1))}`; } + else if (r.view === "difff") path = "#/diff/files"; else if (r.view !== "home") path = "#/" + r.view; return path + (qs ? "?" + qs : ""); } @@ -250,6 +252,7 @@ export class App implements AppCtx { if (parts[0] === "overview") return { view: "overview", params }; if (parts[0] === "diff") { if (parts.length >= 3) return { view: "diffc", query: `${parts[1]}/${decodeURIComponent(parts.slice(2).join("/"))}`, tab, params }; + if (parts[1] === "files") return { view: "difff", params }; return { view: "diff", params }; } if (parts[0] === "old" && this._cmp && parts.length >= 3) { @@ -570,6 +573,9 @@ export class App implements AppCtx { } else if (r.view === "diff" && this._cmp) { page = diffOverviewPage(this, this._cmp); if (this.opts.routing) document.title = `Changes · ${title}`; + } else if (r.view === "difff" && this._cmp) { + page = diffFilesPage(this, this._cmp); + if (this.opts.routing) document.title = `Changes, file by file · ${title}`; } else if (r.view === "diffc" && this._cmp && r.query) { const i = r.query.indexOf("/"); const cd = this._cmp.find(r.query.slice(0, i), r.query.slice(i + 1)); diff --git a/web/src/ctx.ts b/web/src/ctx.ts index b21c813..e2eb683 100644 --- a/web/src/ctx.ts +++ b/web/src/ctx.ts @@ -3,7 +3,7 @@ import type { Compare } from "./diff"; import type { Id, Model } from "./model"; export interface Route { - /** "home" | "comp" | "file" | "overview" | "missing" | "diff" | "diffc" (query: `slug/qname`) */ + /** "home" | "comp" | "file" | "overview" | "missing" | "diff" | "difff" (all changes with their source diff) | "diffc" (query: `slug/qname`) */ view: string; id?: Id; /** Sub view of a page: "diagram" | "tree" */ diff --git a/web/src/styles.css b/web/src/styles.css index 861d542..f748923 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -27,6 +27,8 @@ --se-del-bg: #fbe4e1; --se-chg: #8a5a00; --se-chg-bg: #fbefc7; + --se-hunk-bg: #e3eefa; + --se-hunk-text: #44607d; --se-class: #0563bb; --se-class-bg: #e3eefa; --se-prop: #1c2633; @@ -88,6 +90,8 @@ --se-del-bg: #3d1512; --se-chg: #f5c242; --se-chg-bg: #3a2f0c; + --se-hunk-bg: #0e2a48; + --se-hunk-text: #93c5f6; --se-class: #5aa8f2; --se-class-bg: #0e2a48; --se-prop: #eef1ef; @@ -1905,16 +1909,28 @@ ul.plain { justify-content: space-between; margin: 4px 0 6px; } -pre.xml .t-tag { +pre.xml .t-tag, +.codediff-table .t-tag { color: var(--se-accent-strong); } -pre.xml .t-attr { +pre.xml .t-attr, +.codediff-table .t-attr { color: var(--se-text-2); } pre.xml .t-str { color: var(--se-shape); white-space: pre; } +.codediff-table .t-str { + color: var(--se-shape); +} +.codediff-table .t-com, +.codediff-table .t-kw { + color: var(--se-text-3); +} +.codediff-table .t-com { + font-style: italic; +} .diagram .status .legend span.restr::before, .diagram .status .legend span.dep::before { border-top-style: dashed; @@ -2540,3 +2556,611 @@ h1.d-removed-text { .sbs-missing[hidden] { display: none; } + +/* ---- compare: Overview / Changes tabs ------------------------------------ */ +.diff-tabs { + display: flex; + gap: 4px; + margin: 10px 0 0; + border-bottom: 1px solid var(--se-border); + overflow-x: auto; +} +.diff-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + color: var(--se-text-2); + font-weight: 500; + white-space: nowrap; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.diff-tab:hover { + color: var(--se-text); + text-decoration: none; +} +.diff-tab[aria-current="page"] { + color: var(--se-text); + font-weight: 600; + border-bottom-color: var(--se-accent); +} +.diff-tab .count { + font-size: 12px; + padding: 0 7px; + border-radius: 999px; + background: var(--se-panel-2); + border: 1px solid var(--se-border); + color: var(--se-text-2); + font-weight: 600; +} +.diff-page:not(.diff-files) .diff-tabs { + margin-bottom: 8px; +} + +/* ---- compare: Changes, a card per component ----------------------------- */ +.main-inner:has(> .diff-files) { + max-width: none; +} +.diff-files { + --df-bar: 52px; + --df-view: 80vh; + --df-tree-w: 300px; +} +.df-sub { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 18px; + margin: 4px 0 0; + color: var(--se-text-2); + font-size: 14px; +} +.df-sub strong { + color: var(--se-text); + font-weight: 600; +} +.df-sub strong.d-removed-text { + color: var(--se-del); +} +.df-keys { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + font-size: 12.5px; +} +.df-keys kbd { + font: 11.5px var(--se-mono); + border: 1px solid var(--se-border); + border-bottom-width: 2px; + border-radius: 4px; + padding: 0 5px; + background: var(--se-panel); + color: var(--se-text); +} +.df-toolbar { + position: sticky; + top: 0; + z-index: 6; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 0; + background: var(--se-bg); + border-bottom: 1px solid var(--se-border); +} +.df-toolbar .input { + min-height: 30px; + padding: 2px 8px; + font-size: 14px; + width: auto; +} +.df-toolbar .spacer { + flex: 1; +} +.df-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--se-text-2); + cursor: pointer; + white-space: nowrap; +} +.df-progress { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--se-text-2); + white-space: nowrap; +} +.df-pbar { + width: 90px; + height: 6px; + border-radius: 9px; + background: var(--se-panel-2); + border: 1px solid var(--se-border); + overflow: hidden; +} +.df-pbar i { + display: block; + height: 100%; + width: 0; + background: var(--se-add); + transition: width var(--se-ease); +} +.df-shell { + display: grid; + grid-template-columns: var(--df-tree-w) minmax(0, 1fr); + gap: 16px; + align-items: start; + padding-top: 14px; +} +.df-shell.nav-hidden { + grid-template-columns: minmax(0, 1fr); +} +.df-shell.nav-hidden .df-side { + display: none; +} +.df-side { + position: sticky; + top: calc(var(--df-bar) + 14px); + max-height: calc(var(--df-view) - var(--df-bar) - 28px); + display: flex; + flex-direction: column; + min-width: 0; + background: var(--se-panel); + border: 1px solid var(--se-border); + border-radius: var(--se-radius); +} +.df-search { + display: flex; + gap: 6px; + padding: 8px; + border-bottom: 1px solid var(--se-border); +} +.df-search .input { + min-height: 32px; + font-size: 14px; + border-color: var(--se-border); + background: var(--se-bg); +} +.df-close { + display: none; +} +.df-tree { + overflow: auto; + padding: 4px 0 8px; + font-size: 13px; +} +.df-tree ul { + list-style: none; + margin: 0; + padding: 0; +} +.df-folder { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + border: 0; + background: none; + padding: 4px 10px; + cursor: pointer; + text-align: left; + font-weight: 600; +} +.df-folder:hover, +.df-leaf:hover { + background: var(--se-panel-2); + text-decoration: none; +} +.df-folder .icon, +.df-toggle .icon, +.df-sem summary .icon { + width: 14px; + height: 14px; + flex: none; + color: var(--se-text-2); + transition: transform var(--se-ease); +} +.df-folder[aria-expanded="false"] .icon, +.df-card.collapsed .df-toggle .icon, +.df-sem:not([open]) summary .icon { + transform: rotate(-90deg); +} +.df-fname { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 12.5px var(--se-mono); + font-weight: 600; +} +.df-fstat { + font-size: 11.5px; + font-weight: 500; + white-space: nowrap; +} +.df-leaf { + display: flex; + align-items: center; + gap: 7px; + padding: 2px 10px 2px 30px; + color: var(--se-text); +} +.df-leaf.on { + background: var(--se-accent-bg); + box-shadow: inset 2px 0 0 var(--se-accent); +} +.df-lname { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 12px var(--se-mono); +} +.df-leaf.viewed .df-lname { + color: var(--se-text-2); +} +.df-tick { + display: inline-flex; + width: 12px; + flex: none; + color: var(--se-add); +} +.df-tick .icon { + width: 12px; + height: 12px; +} +.df-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--se-danger); + flex: none; +} +.df-empty-tree { + padding: 8px 12px; +} +.dsq { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + flex: none; + border: 1px solid currentColor; + border-radius: 4px; + font: 700 11px/1 var(--se-mono); +} +.dsq.d-added { + color: var(--se-add); + background: var(--se-add-bg); +} +.dsq.d-removed { + color: var(--se-del); + background: var(--se-del-bg); +} +.dsq.d-changed { + color: var(--se-chg); + background: var(--se-chg-bg); +} +.dsq.d-renamed { + color: var(--se-accent); + background: var(--se-accent-bg); +} +.df-cards { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} +.df-card { + min-width: 0; + background: var(--se-panel); + border: 1px solid var(--se-border); + border-radius: var(--se-radius); + scroll-margin-top: calc(var(--df-bar) + 12px); +} +.df-card.on { + border-color: var(--se-border-strong); +} +.df-head { + position: sticky; + top: var(--df-bar); + z-index: 4; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 7px 10px; + background: var(--se-panel); + border-bottom: 1px solid var(--se-border); + border-radius: var(--se-radius) var(--se-radius) 0 0; +} +.df-card.collapsed .df-head { + border-bottom: 0; + border-radius: var(--se-radius); +} +.df-card.collapsed .df-body { + display: none; +} +.df-toggle { + display: grid; + place-items: center; + border: 0; + background: none; + padding: 2px; + cursor: pointer; +} +.df-stat { + display: inline-flex; + align-items: center; + gap: 6px; + font: 600 12px var(--se-mono); +} +.df-blocks { + display: inline-flex; + gap: 1px; +} +.df-blocks i { + width: 8px; + height: 8px; + border-radius: 1px; + background: var(--se-panel-2); + border: 1px solid var(--se-border); +} +.df-blocks i.a { + background: var(--se-add); + border-color: var(--se-add); +} +.df-blocks i.d { + background: var(--se-del); + border-color: var(--se-del); +} +.df-qn { + border: 0; + background: none; + padding: 0; + cursor: pointer; + font: 600 13.5px var(--se-mono); + color: var(--se-text); + text-align: left; + overflow-wrap: anywhere; + min-width: 0; +} +.df-qn:hover { + text-decoration: underline; +} +.df-qn .faint { + font-weight: 400; +} +.df-head .badge { + min-height: 20px; + padding: 0 8px; + font-size: 12px; +} +.badge.df-renamed { + background: var(--se-accent-bg); + color: var(--se-accent-strong); +} +.df-where { + flex: 1 1 0; + min-width: 60px; + font: 12px var(--se-mono); + color: var(--se-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.df-actions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 4px; +} +.df-actions a.btn { + color: var(--se-text-2); +} +.df-actions a.btn:hover { + color: var(--se-text); +} +.df-viewed[aria-pressed="true"] { + background: var(--se-add-bg); + border-color: var(--se-add); + color: var(--se-add); +} +.df-viewed .icon { + width: 14px; + height: 14px; +} +.df-body { + min-width: 0; +} +.df-sem { + padding: 8px 12px; + border-bottom: 1px solid var(--se-border); + background: var(--se-bg); + font-size: 13.5px; +} +.df-sem summary { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + list-style: none; + color: var(--se-text-2); + font-weight: 600; +} +.df-sem summary::-webkit-details-marker { + display: none; +} +.df-sem ul { + list-style: none; + margin: 6px 0 2px; + padding: 0; + display: grid; + gap: 3px; +} +.df-sem li, +.df-note { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; +} +.df-sem code, +.df-note code { + font: 12px var(--se-mono); + background: var(--se-panel-2); + border: 1px solid var(--se-border); + border-radius: 4px; + padding: 0 4px; + overflow-wrap: anywhere; +} +.df-sem code.df-old, +.df-note code.df-old { + color: var(--se-del); + text-decoration: line-through; +} +.df-sem code.df-new, +.df-note code.df-new { + color: var(--se-add); +} +.df-lbl { + font-weight: 500; +} +.df-imp { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--se-text-2); +} +.df-imp.breaking { + color: var(--se-danger); +} +.df-imp.nonBreaking { + color: var(--se-add); +} +.df-scroll { + overflow-x: auto; +} +.df-scroll .codediff-table { + font-size: 12.5px; + line-height: 20px; +} +.df-card .codediff-table tr:last-child td { + border-bottom-left-radius: var(--se-radius); +} +.codediff-table tr.cd-hunk td { + background: var(--se-hunk-bg); + color: var(--se-hunk-text); + padding: 2px 10px; + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.codediff-table tr.fold td { + background: var(--se-hunk-bg); +} +.codediff-table tr.cd-note td { + padding: 6px 10px 8px; + background: var(--se-panel); +} +.df-notes { + display: grid; + gap: 4px; +} +.df-note { + max-width: 760px; + padding: 6px 10px; + font: 13px/1.5 var(--se-font); + white-space: normal; + background: var(--se-panel); + border: 1px solid var(--se-border); + border-left: 3px solid var(--se-text-2); + border-radius: var(--se-radius-sm); +} +.df-note.breaking { + background: var(--se-danger-bg); + border-left-color: var(--se-danger); +} +.df-note.nonBreaking { + border-left-color: var(--se-add); +} +.df-load { + display: grid; + justify-items: center; + gap: 8px; + padding: 22px 16px; + text-align: center; + color: var(--se-text-2); + font-size: 14px; +} +.df-problem { + margin: 0; + padding: 10px 12px; +} +.df-empty { + padding: 40px 16px; + text-align: center; + color: var(--se-text-2); + background: var(--se-panel); + border: 1px dashed var(--se-border); + border-radius: var(--se-radius); +} +/* Narrower than WIDE (changes.ts): the tree is a drawer over the page. */ +@media (max-width: 1099px) { + .df-shell { + grid-template-columns: minmax(0, 1fr); + } + .df-side { + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 40; + width: min(86vw, 340px); + max-height: none; + border-radius: 0; + box-shadow: var(--se-shadow-raised); + } + .df-shell:not(.nav-open) .df-side { + display: none; + } + .df-close { + display: inline-flex; + } +} +@media (max-width: 699px) { + .df-layout, + .df-progress .df-pbar { + display: none; + } + .df-head { + position: static; + } + .df-toolbar .input { + flex: 1 1 130px; + } + .df-actions { + margin-left: 0; + } + .df-where { + flex-basis: 100%; + } +} +@media (prefers-reduced-motion: reduce) { + .df-pbar i, + .df-folder .icon, + .df-toggle .icon, + .df-sem summary .icon { + transition: none; + } +} diff --git a/web/src/views/changes.ts b/web/src/views/changes.ts new file mode 100644 index 0000000..3e46644 --- /dev/null +++ b/web/src/views/changes.ts @@ -0,0 +1,606 @@ +// Compare mode, all changes on one page in the manner of a pull request's +// "Files changed": a tree of the changed components by schema file on the left, +// a card per component on the right with what changed and its source diff. +import type { AppCtx } from "../ctx"; +import { MARKER, STATUS_LABEL, changeLabel, changeMark, type Change, type Compare, type ComponentDiff, type Impact } from "../diff"; +import { button, h, icon, plural, type Child } from "../dom"; +import { KIND_LABEL, KIND_SLUG, LANG, type Kind } from "../model"; +import { diffTable, lineDiff, loadSource, type LineDiff, type SourceSide } from "./codeDiff"; +import { diffHref, diffTabs, mark, qnameOf } from "./compare"; +import { whenVisible } from "./diagramPanel"; +import { versionCtx, type Which } from "./sideBySide"; + +const KINDS: Kind[] = ["complexType", "simpleType", "element", "attribute", "group", "attributeGroup"]; +const SHOW = ["all", "breaking", "added", "removed", "changed", "unviewed"] as const; +type Show = (typeof SHOW)[number]; +const SHOW_LABEL: Record = { all: "All changes", breaking: "Breaking only", added: "Added", removed: "Removed", changed: "Changed", unviewed: "Not viewed yet" }; +const IMPACT_LABEL: Record = { breaking: "Breaking", nonBreaking: "Non-breaking", info: "Docs" }; +/** Added and removed components longer than this start folded. */ +const LONG = 60; +/** Below this width the tree is a drawer; below NARROW the diff is always unified. */ +const WIDE = 1100; +const NARROW = 700; + +/** Folders closed in the tree, kept while the page is open. */ +const closedFiles = new Set(); + +interface Source { + a: SourceSide | null; + b: SourceSide | null; + d: LineDiff; + problems: string[]; +} + +interface Item { + c: ComponentDiff; + /** `slug/qname`, as in the component's diff link. */ + key: string; + dom: string; + file: string; + prefix: string; + local: string; + /** "renamed" for an added or removed component that looks renamed. */ + st: "added" | "removed" | "changed" | "renamed"; + docsOnly: boolean; + el?: HTMLElement; + body?: HTMLElement; + stat?: HTMLElement; + viewBtn?: HTMLButtonElement; + collapsed: boolean; + filled: boolean; + forced: boolean; + src?: Promise; + srcDone?: Source; +} + +const storeGet = (k: string): string[] => { + try { + const v = localStorage.getItem(k); + const x: unknown = v ? JSON.parse(v) : []; + return Array.isArray(x) ? x.filter((s): s is string => typeof s === "string") : []; + } catch { + return []; + } +}; +const storeSet = (k: string, v: string[]) => { + try { + localStorage.setItem(k, JSON.stringify(v)); + } catch { + /* private mode: viewed marks last for the page only */ + } +}; + +/** The marker square of a component: + − ~, or → for a likely rename. */ +function square(st: Item["st"]): HTMLElement { + const label = st === "renamed" ? "Possibly renamed" : STATUS_LABEL[st]; + return h("span", { class: `dsq d-${st}`, title: label, "aria-label": label }, st === "renamed" ? "→" : MARKER[st]); +} + +/** Five blocks in proportion to added and removed lines, as on a pull request. */ +function blocks(add: number, del: number): HTMLElement { + const tot = add + del; + const n = 5; + let a = tot ? Math.round((add / tot) * n) : 0; + let d = tot ? Math.min(n - a, Math.round((del / tot) * n)) : 0; + if (add && !a) a = 1; + if (del && !d && a < n) d = 1; + if (a + d > n) a = n - d; + const s = h("span", { class: "df-blocks", "aria-hidden": "true" }); + for (let i = 0; i < n; i++) s.append(h("i", { class: i < a ? "a" : i < a + d ? "d" : "" })); + return s; +} + +const clip = (v: string) => (v.length > 160 ? v.slice(0, 160) + "…" : v); + +/** One semantic change: impact, what, where, old → new. */ +function changeText(ch: Change): Child[] { + const what = changeLabel(ch.kind); + const out: Child[] = [h("span", { class: `df-imp ${ch.impact}` }, IMPACT_LABEL[ch.impact]), h("span", { class: `df-lbl d-${changeMark(ch.kind)}-text` }, what[0].toUpperCase() + what.slice(1))]; + const where = [ch.path, ch.subject].filter(Boolean).join("/"); + if (where) out.push(h("code", null, where)); + if (ch.kind !== "docsChanged" && (ch.old !== undefined || ch.new !== undefined)) { + if (ch.old !== undefined) out.push(h("code", { class: "df-old" }, clip(ch.old))); + if (ch.old !== undefined && ch.new !== undefined) out.push("→"); + if (ch.new !== undefined) out.push(h("code", { class: "df-new" }, clip(ch.new))); + } + return out; +} + +/** Which source line each semantic change is about, found by what the change names. */ +function annotate(c: ComponentDiff, src: Source): { a: Map; b: Map } { + const map = { a: new Map(), b: new Map() }; + if (c.status !== "changed") return map; + const la = src.a ? src.a.text.split("\n") : []; + const lb = src.b ? src.b.text.split("\n") : []; + for (const ch of c.changes ?? []) { + if (ch.kind === "docsChanged") continue; + let side: "a" | "b" = /Removed$/.test(ch.kind) ? "a" : "b"; + if (side === "a" ? !src.a : !src.b) side = side === "a" ? "b" : "a"; + const lines = side === "a" ? la : lb; + const subj = (ch.subject ?? "").replace(/^@/, "").split(",")[0].trim(); + const local = subj.slice(subj.lastIndexOf(":") + 1); + let pats: string[]; + if (LANG.json) { + pats = /^enumeration/.test(ch.kind) ? [`"${subj}"`] : local ? [`"${local}":`, `"${local}"`] : []; + } else if (/^enumeration/.test(ch.kind)) pats = [`value="${subj}"`]; + else if (/^facet/.test(ch.kind)) pats = [`:${subj} `, `:${subj}>`]; + else if (ch.kind === "baseChanged") pats = ["base="]; + else if (ch.kind === "typeChanged") pats = ["type="]; + else if (ch.kind === "substitutionGroupChanged") pats = ["substitutionGroup="]; + else if (/^memberType/.test(ch.kind)) pats = [subj, "memberTypes="]; + else if (ch.kind === "itemTypeChanged") pats = ["itemType="]; + else if (ch.kind === "structureChanged") pats = [":sequence", ":choice", ":all"]; + else pats = local ? [`name="${local}"`, `ref="${subj}"`, `:${local}"`, `"${local}"`] : []; + let at = -1; + for (const p of pats) { + at = lines.findIndex((l) => l.includes(p)); + if (at >= 0) break; + } + if (at < 0) at = 0; + const m = map[side]; + if (!m.has(at)) m.set(at, []); + m.get(at)!.push(ch); + } + return map; +} + +/** The pair of versions, so marks of one comparison do not show on another. */ +const viewedKey = (cmp: Compare) => `se.viewed:${cmp.oldTitle}|${cmp.newTitle}|${cmp.cur.title}`; + +/** Every changed component with its source diff, filters and a viewed mark, kept in the link. */ +export function diffFilesPage(ctx: AppCtx, cmp: Compare): HTMLElement { + const d = cmp.d; + const P = ctx.route.params; + const t = d.summary.total; + const vkey = viewedKey(cmp); + const viewed = new Set(storeGet(vkey)); + const wide = () => window.innerWidth >= WIDE; + + const items: Item[] = d.components.map((c, i) => { + const q = qnameOf(c); + const colon = q.indexOf(":"); + return { + c, + key: `${KIND_SLUG[c.kind]}/${q}`, + dom: `dc-${i}`, + file: (c.new ?? c.old)?.file ?? "(no file)", + prefix: colon > 0 ? q.slice(0, colon + 1) : "", + local: colon > 0 ? q.slice(colon + 1) : q, + st: c.possiblyRenamed && c.status !== "changed" ? "renamed" : c.status, + docsOnly: c.status === "changed" && c.impact === "info", + collapsed: false, + filled: false, + forced: false, + }; + }); + + // ---- state kept in the link ------------------------------------------------ + const state = { + show: (SHOW as readonly string[]).includes(P.show ?? "") ? (P.show as Show) : "all", + k: P.k ?? "", + docs: P.docs !== "0", + q: P.q ?? "", + unified: P.code === "unified", + nav: P.nav !== "0", + }; + const save = () => ctx.setParams({ show: state.show === "all" ? undefined : state.show, k: state.k || undefined, docs: state.docs ? undefined : "0", q: state.q || undefined, code: state.unified ? "unified" : undefined, nav: state.nav ? undefined : "0" }); + const unifiedNow = () => state.unified || window.innerWidth < NARROW; + + // ---- head -------------------------------------------------------------------- + const page = h("div", { class: "diff-page diff-files" }); + const kbd = (k: string) => h("kbd", null, k); + page.append( + h("header", { class: "page-head" }, h("h1", null, "Changes from ", h("span", { class: "d-removed-text" }, cmp.oldTitle), " to ", h("span", { class: "d-added-text" }, cmp.newTitle))), + h( + "p", + { class: "df-sub" }, + h("span", null, h("strong", null, (t.added + t.removed + t.changed).toLocaleString()), " components in ", h("strong", null, Object.keys(d.summary.byFile).length.toLocaleString()), " files"), + h("span", null, mark("added"), t.added.toLocaleString(), " ", mark("removed"), t.removed.toLocaleString(), " ", mark("changed"), t.changed.toLocaleString()), + h("span", null, h("strong", { class: "d-removed-text" }, d.summary.breakingChanges.toLocaleString()), ` breaking ${d.summary.breakingChanges === 1 ? "change" : "changes"} in `, h("strong", null, t.breaking.toLocaleString()), " components"), + h("span", { class: "df-keys hide-mobile" }, kbd("j"), kbd("k"), " next / previous ", kbd("v"), " viewed ", kbd("s"), " split ", kbd("t"), " tree ", kbd("/"), " filter") + ) + ); + const tabCount = h("span", { class: "count" }); + page.append(diffTabs(ctx, cmp, "files", tabCount)); + + // ---- toolbar --------------------------------------------------------------- + const opt = (v: string, label: string, cur: string) => h("option", { value: v, selected: v === cur }, label); + const showSel = h("select", { class: "input", "aria-label": "Show" }, SHOW.map((s) => opt(s, SHOW_LABEL[s], state.show))) as HTMLSelectElement; + const kindSel = h("select", { class: "input", "aria-label": "Kind" }, opt("", "All kinds", state.k), KINDS.filter((x) => d.summary.byKind[x]).map((x) => opt(KIND_SLUG[x], KIND_LABEL[x], state.k))) as HTMLSelectElement; + const docsBox = h("input", { type: "checkbox", checked: !state.docs }) as HTMLInputElement; + const vText = h("span"); + const vBar = h("i"); + const splitBtn = button("Split", () => setMode(false), { cls: "small", pressed: !state.unified, title: "Old on the left, new on the right (s)" }); + const uniBtn = button("Unified", () => setMode(true), { cls: "small", pressed: state.unified, title: "Removed lines above added ones (s)" }); + const collapseBtn = button("Collapse all", () => { + const anyOpen = shown.some((it) => !it.collapsed); + shown.forEach((it) => setCollapsed(it, anyOpen)); + collapseBtn.querySelector("span")!.textContent = anyOpen ? "Expand all" : "Collapse all"; + }, { cls: "small" }); + const navBtn = button("Tree", () => toggleNav(), { cls: "small", icon: "columns", title: "Show or hide the component tree (t)" }); + const toolbar = h( + "div", + { class: "df-toolbar" }, + navBtn, + showSel, + kindSel, + h("label", { class: "df-check" }, docsBox, " Hide docs-only"), + h("span", { class: "spacer" }), + h("span", { class: "df-progress", title: "Components you marked as viewed" }, vText, h("span", { class: "df-pbar" }, vBar)), + h("div", { class: "btn-group df-layout", role: "group", "aria-label": "Diff layout" }, splitBtn, uniBtn), + collapseBtn + ); + page.append(toolbar); + + // ---- tree and cards ---------------------------------------------------------- + const search = h("input", { class: "input", type: "search", placeholder: "Filter components…", value: state.q, "aria-label": "Filter components", autocomplete: "off" }) as HTMLInputElement; + const tree = h("nav", { class: "df-tree", "aria-label": "Changed components" }); + const side = h("aside", { class: "df-side" }, h("div", { class: "df-search" }, search, button("", () => toggleNav(false), { cls: "small icon-only ghost df-close", icon: "close", title: "Close the tree" })), tree); + const cards = h("div", { class: "df-cards" }); + const shell = h("div", { class: "df-shell" }, side, cards); + page.append(shell); + + // ---- one card --------------------------------------------------------------- + const openHref = (c: ComponentDiff) => (c.new ? ctx.href(c.new.id) : c.old ? ctx.hrefRoute({ view: "old", id: c.old.id }) : null); + const navLink = (href: string, go: () => void, ic: string, title: string) => + h("a", { class: "btn small icon-only ghost", href, title, "aria-label": title, onClick: (ev: Event) => { + const me = ev as MouseEvent; + if (me.metaKey || me.ctrlKey || me.shiftKey) return; + ev.preventDefault(); + go(); + } }, icon(ic)); + const where = (c: ComponentDiff) => { + const f = (c.new ?? c.old)?.file ?? ""; + const o = c.old?.line; + const n = c.new?.line; + if (c.status === "added" || o === undefined) return n === undefined ? f : `${f}:${n}`; + if (c.status === "removed" || n === undefined) return `${f}:${o}`; + const fo = c.old?.file && c.old.file !== c.new?.file ? `${c.old.file}:` : ""; + return o === n && !fo ? `${f}:${n}` : `${fo || f + ":"}${o} → ${fo ? f + ":" : ""}${n}`; + }; + const card = (it: Item): HTMLElement => { + if (it.el) return it.el; + const c = it.c; + const isViewed = viewed.has(it.key); + it.collapsed = isViewed; + const stat = h("span", { class: "df-stat" }, h("span", { class: "faint" }, "…")); + const vb = button("Viewed", () => setViewed(it, !viewed.has(it.key)), { cls: "small df-viewed", icon: isViewed ? "check" : undefined, pressed: isViewed, title: "Mark as viewed (v)" }); + const toggle = () => setCollapsed(it, !it.collapsed); + const ex = openHref(c); + const head = h( + "div", + { class: "df-head" }, + h("button", { type: "button", class: "df-toggle", "aria-label": "Collapse", "aria-expanded": String(!isViewed), onClick: toggle }, icon("down")), + stat, + square(it.st), + h("button", { type: "button", class: "df-qn", title: it.c.old && it.c.new && it.c.old.qname !== it.c.new.qname ? `was ${it.c.old.qname}` : undefined, onClick: toggle }, it.prefix ? h("span", { class: "faint" }, it.prefix) : null, it.local), + h("span", { class: `badge k-${c.kind}` }, KIND_LABEL[c.kind]), + c.impact === "breaking" ? h("span", { class: "badge danger" }, "breaking") : null, + it.docsOnly ? h("span", { class: "badge" }, "docs only") : null, + c.possiblyRenamed ? h("span", { class: "badge df-renamed" }, c.status === "removed" ? "renamed to " : "renamed from ", c.possiblyRenamed) : null, + h("span", { class: "df-where", title: where(c) }, where(c)), + h( + "span", + { class: "df-actions" }, + ex ? navLink(ex, () => (c.new ? ctx.go(c.new.id) : ctx.goRoute({ view: "old", id: c.old!.id })), "external", c.new ? `Open in the explorer (${cmp.newTitle})` : `Open in the explorer (${cmp.oldTitle})`) : null, + navLink(diffHref(ctx, c), () => ctx.goRoute({ view: "diffc", query: it.key }), "columns", "Open the page of this change: diagram, tree and both versions"), + vb + ) + ); + const body = h("div", { class: "df-body" }); + const el = h("section", { class: "df-card" + (isViewed ? " collapsed" : ""), id: it.dom, "aria-label": qnameOf(c) }, head, body); + Object.assign(it, { el, body, stat, viewBtn: vb }); + return el; + }; + + const loadOf = (it: Item): Promise => { + if (it.src) return it.src; + const c = it.c; + const one = (which: Which, id: number | undefined) => (id === undefined ? Promise.resolve(null) : loadSource(versionCtx(ctx, cmp, which), id, which === "old" ? cmp.oldTitle : cmp.newTitle)); + it.src = Promise.all([one("old", c.old?.id), one("new", c.new?.id)]).then(([ra, rb]) => { + const problems = [ra, rb].filter((x): x is string => typeof x === "string"); + const a = ra && typeof ra === "object" ? ra : null; + const b = rb && typeof rb === "object" ? rb : null; + if (!a && !b) return problems[0] ?? "No source is known for it."; + const s: Source = { a, b, d: lineDiff(a, b), problems }; + it.srcDone = s; + it.stat?.replaceChildren(h("span", { class: "d-added-text" }, `+${s.d.added}`), h("span", { class: "d-removed-text" }, `−${s.d.removed}`), blocks(s.d.added, s.d.removed)); + it.stat?.setAttribute("title", `${plural(s.d.added, "line")} added, ${plural(s.d.removed, "line")} removed`); + return s; + }); + return it.src; + }; + + const drawDiff = (it: Item, host: HTMLElement, s: Source) => { + const ann = annotate(it.c, s); + const note = (sd: "a" | "b", i: number) => { + const list = ann[sd].get(i); + return list ? h("div", { class: "df-notes" }, list.map((ch) => h("div", { class: `df-note ${ch.impact}` }, changeText(ch)))) : null; + }; + const table = diffTable(s.a, s.b, s.d, { unified: unifiedNow() || it.c.status !== "changed", context: 3, hunkLabel: it.c.status === "changed" ? qnameOf(it.c) : undefined, note }); + host.replaceChildren(...s.problems.map((p) => h("p", { class: "muted small df-problem" }, p)), h("div", { class: "df-scroll" }, table)); + }; + + const fill = (it: Item) => { + if (it.filled || !it.body) return; + it.filled = true; + const c = it.c; + const ch = c.changes ?? []; + if (ch.length) { + const nb = ch.filter((x) => x.impact === "breaking").length; + it.body.append(h("details", { class: "df-sem", open: true }, h("summary", null, icon("down"), plural(ch.length, "semantic change"), nb ? h("span", { class: "df-imp breaking" }, ` · ${nb} breaking`) : null), h("ul", null, ch.map((x) => h("li", null, changeText(x)))))); + } else if (c.status === "removed") { + it.body.append(h("div", { class: "df-sem" }, h("span", { class: "df-imp breaking" }, "Breaking"), " Documents that use ", h("code", null, qnameOf(c)), " are no longer valid.", c.possiblyRenamed ? [" It looks like it was renamed to ", h("code", null, c.possiblyRenamed), "."] : null)); + } + const host = h("div", { class: "df-diff" }, h("p", { class: "faint small df-problem" }, "Loading…")); + it.body.append(host); + void loadOf(it).then((s) => { + if (typeof s === "string") { + host.replaceChildren(h("p", { class: "muted small df-problem" }, s)); + return; + } + const lines = Math.max(s.a?.lines.length ?? 0, s.b?.lines.length ?? 0); + if ((it.docsOnly || (c.status !== "changed" && lines > LONG)) && !it.forced) { + host.replaceChildren(h("div", { class: "df-load" }, h("span", null, it.docsOnly ? "Only the documentation changed, so this diff is hidden." : `${plural(lines, "line")}, all ${c.status === "added" ? "new" : "removed"}. Long diffs are hidden.`), button("Load diff", () => { + it.forced = true; + drawDiff(it, host, s); + }, { cls: "small" }))); + } else drawDiff(it, host, s); + }); + }; + const redraw = (it: Item) => { + const host = it.body?.querySelector(".df-diff"); + if (host && it.srcDone && host.querySelector("table")) drawDiff(it, host, it.srcDone); + }; + + const setCollapsed = (it: Item, v: boolean) => { + it.collapsed = v; + it.el?.classList.toggle("collapsed", v); + it.el?.querySelector(".df-toggle")?.setAttribute("aria-expanded", String(!v)); + if (!v) fill(it); + }; + const leafOf = (it: Item) => tree.querySelector(`[data-dom="${it.dom}"]`); + const setViewed = (it: Item, v: boolean) => { + if (v) viewed.add(it.key); + else viewed.delete(it.key); + storeSet(vkey, [...viewed]); + if (it.viewBtn) { + it.viewBtn.setAttribute("aria-pressed", String(v)); + it.viewBtn.replaceChildren(...(v ? [icon("check")] : []), h("span", null, "Viewed")); + } + if (v && !it.collapsed) { + setCollapsed(it, true); + const r = it.el!.getBoundingClientRect(); + if (scroller && r.top < rootTop() + barH()) it.el!.scrollIntoView({ block: "start" }); + } + const leaf = leafOf(it); + leaf?.classList.toggle("viewed", v); + const tick = leaf?.querySelector(".df-tick"); + if (tick) tick.replaceChildren(...(v ? [icon("check")] : [])); + progress(); + }; + const progress = () => { + const n = items.filter((it) => viewed.has(it.key)).length; + vText.textContent = `${n.toLocaleString()} / ${items.length.toLocaleString()} viewed`; + vBar.style.width = `${items.length ? (n / items.length) * 100 : 0}%`; + }; + + // ---- filtering --------------------------------------------------------------- + let shown: Item[] = []; + const visible = () => { + const needle = state.q.trim().toLowerCase(); + return items.filter((it) => { + const c = it.c; + const s = state.show; + if (s === "breaking" ? c.impact !== "breaking" : s === "unviewed" ? viewed.has(it.key) : s !== "all" && c.status !== s) return false; + if (state.k && KIND_SLUG[c.kind] !== state.k) return false; + if (!state.docs && it.docsOnly) return false; + return !needle || qnameOf(c).toLowerCase().includes(needle) || (c.old?.qname ?? "").toLowerCase().includes(needle); + }); + }; + let io: IntersectionObserver | null = null; + const build = () => { + const vis = visible(); + const byFile = new Map(); + for (const it of vis) { + if (!byFile.has(it.file)) byFile.set(it.file, []); + byFile.get(it.file)!.push(it); + } + shown = []; + tree.replaceChildren(); + cards.replaceChildren(); + for (const f of [...byFile.keys()].sort()) { + const list = byFile.get(f)!.sort((x, y) => x.local.localeCompare(y.local)); + const n = { added: 0, removed: 0, changed: 0 }; + list.forEach((it) => n[it.c.status]++); + const closed = closedFiles.has(f); + const ul = h("ul", { hidden: closed }); + const folder = h("button", { type: "button", class: "df-folder", "aria-expanded": String(!closed), onClick: () => { + const open = folder.getAttribute("aria-expanded") === "true"; + folder.setAttribute("aria-expanded", String(!open)); + ul.hidden = open; + if (open) closedFiles.add(f); + else closedFiles.delete(f); + } }, icon("down"), h("span", { class: "df-fname", title: f }, f), h("span", { class: "df-fstat" }, n.added ? h("span", { class: "d-added-text" }, `+${n.added} `) : null, n.removed ? h("span", { class: "d-removed-text" }, `−${n.removed} `) : null, n.changed ? h("span", { class: "d-changed-text" }, `~${n.changed}`) : null)); + for (const it of list) { + const isViewed = viewed.has(it.key); + ul.append(h("li", null, h("a", { class: "df-leaf" + (isViewed ? " viewed" : ""), href: diffHref(ctx, it.c), "data-dom": it.dom, title: `${qnameOf(it.c)} (${KIND_LABEL[it.c.kind].toLowerCase()})`, onClick: (ev: Event) => { + const me = ev as MouseEvent; + if (me.metaKey || me.ctrlKey || me.shiftKey) return; + ev.preventDefault(); + go(it); + if (!wide()) toggleNav(false); + } }, square(it.st), h("span", { class: "df-lname" }, it.local), it.c.impact === "breaking" ? h("span", { class: "df-dot", title: "Breaking" }) : null, h("span", { class: "df-tick", "aria-hidden": "true" }, isViewed ? icon("check") : null)))); + cards.append(card(it)); + shown.push(it); + } + tree.append(folder, ul); + } + if (!vis.length) { + cards.append(h("div", { class: "df-empty" }, "No changed component matches these filters.")); + tree.append(h("p", { class: "faint small df-empty-tree" }, "Nothing matches.")); + } + tabCount.textContent = vis.length === items.length ? items.length.toLocaleString() : `${vis.length.toLocaleString()} / ${items.length.toLocaleString()}`; + progress(); + current = null; + observe(); + spy(); + }; + + // ---- scrolling: lazy card bodies, the tree follows the card at the top ------------- + let scroller: HTMLElement | null = null; + /** The window scrolls when the page is not inside a scrolling element. */ + const isDoc = () => scroller === document.scrollingElement; + const rootTop = () => (!scroller || isDoc() ? 0 : scroller.getBoundingClientRect().top); + const barH = () => toolbar.offsetHeight; + const observe = () => { + if (!scroller) return; + io?.disconnect(); + io = new IntersectionObserver((es) => { + for (const e of es) { + if (!e.isIntersecting) continue; + const it = shown.find((x) => x.el === e.target); + if (!it) continue; + void loadOf(it); + if (!it.collapsed) fill(it); + io?.unobserve(e.target); + } + }, { root: isDoc() ? null : scroller, rootMargin: "1200px 0px" }); + shown.forEach((it) => io!.observe(it.el!)); + }; + let current: Item | null = null; + const spy = () => { + if (!scroller) return; + const y = rootTop() + barH() + 40; + let best: Item | null = null; + for (const it of shown) { + if (it.el!.getBoundingClientRect().top <= y) best = it; + else break; + } + best = best ?? shown[0] ?? null; + if (best === current) return; + current?.el?.classList.remove("on"); + tree.querySelectorAll(".df-leaf.on").forEach((x) => x.classList.remove("on")); + current = best; + if (!best) return; + best.el?.classList.add("on"); + const leaf = leafOf(best); + if (leaf) { + leaf.classList.add("on"); + const lr = leaf.getBoundingClientRect(); + const tr = tree.getBoundingClientRect(); + if (lr.top < tr.top || lr.bottom > tr.bottom) tree.scrollTop += lr.top < tr.top ? lr.top - tr.top - 8 : lr.bottom - tr.bottom + 8; + } + if (ctx.route.params.at !== best.key && (scroller.scrollTop > 0 || ctx.route.params.at)) ctx.setParams({ at: best.key }); + }; + const go = (it: Item) => { + if (it.collapsed) setCollapsed(it, false); + fill(it); + it.el?.scrollIntoView({ block: "start" }); + }; + + // ---- controls -------------------------------------------------------------------- + const rebuild = () => { + save(); + build(); + }; + showSel.addEventListener("change", () => { + state.show = showSel.value as Show; + rebuild(); + }); + kindSel.addEventListener("change", () => { + state.k = kindSel.value; + rebuild(); + }); + docsBox.addEventListener("change", () => { + state.docs = !docsBox.checked; + rebuild(); + }); + let qt: ReturnType | undefined; + search.addEventListener("input", () => { + clearTimeout(qt); + qt = setTimeout(() => { + state.q = search.value; + rebuild(); + }, 150); + }); + const setMode = (u: boolean) => { + state.unified = u; + splitBtn.setAttribute("aria-pressed", String(!u)); + uniBtn.setAttribute("aria-pressed", String(u)); + save(); + items.forEach(redraw); + }; + let drawerOpen = false; + const toggleNav = (v?: boolean) => { + if (wide()) { + state.nav = v ?? !state.nav; + save(); + } else drawerOpen = v ?? !drawerOpen; + applyNav(); + }; + const applyNav = () => { + const open = wide() ? state.nav : drawerOpen; + shell.classList.toggle("nav-hidden", wide() && !state.nav); + shell.classList.toggle("nav-open", !wide() && drawerOpen); + navBtn.setAttribute("aria-pressed", String(open)); + }; + applyNav(); + + const onKey = (ev: KeyboardEvent) => { + if (!page.isConnected) { + window.removeEventListener("keydown", onKey); + return; + } + const target = ev.composedPath()[0] as HTMLElement | undefined; + if (ev.metaKey || ev.ctrlKey || ev.altKey || (target && (target.closest?.("input, select, textarea, [contenteditable]") || target.isContentEditable))) return; + const i = current ? shown.indexOf(current) : -1; + if (ev.key === "j" && shown[i + 1]) go(shown[i + 1]); + else if (ev.key === "k" && shown[i - 1]) go(shown[i - 1]); + else if (ev.key === "v" && current) setViewed(current, !viewed.has(current.key)); + else if (ev.key === "t") toggleNav(); + else if (ev.key === "s" && window.innerWidth >= NARROW) setMode(!state.unified); + else if (ev.key === "/") { + if (!wide() && !drawerOpen) toggleNav(true); + search.focus(); + } else return; + ev.preventDefault(); + ev.stopPropagation(); + }; + window.addEventListener("keydown", onKey); + + build(); + // Wire the scrolling once the page is in place: the app scrolls its main area, not the window. + whenVisible(page, () => { + scroller = scrollParent(page); + if (!scroller) return; + let raf = 0; + (isDoc() ? window : scroller).addEventListener("scroll", () => { + if (!raf) raf = requestAnimationFrame(() => { + raf = 0; + spy(); + }); + }, { passive: true }); + const sizes = () => { + page.style.setProperty("--df-bar", `${barH()}px`); + page.style.setProperty("--df-view", `${isDoc() ? window.innerHeight : scroller!.clientHeight}px`); + applyNav(); + }; + new ResizeObserver(sizes).observe(toolbar); + new ResizeObserver(sizes).observe(scroller); + sizes(); + observe(); + const at = P.at ? shown.find((it) => it.key === P.at) : undefined; + if (at) go(at); + else spy(); + }); + return page; +} + +function scrollParent(el: HTMLElement): HTMLElement | null { + for (let p = el.parentElement; p; p = p.parentElement) { + const o = getComputedStyle(p).overflowY; + if (o === "auto" || o === "scroll") return p; + } + return document.scrollingElement as HTMLElement | null; +} diff --git a/web/src/views/codeDiff.ts b/web/src/views/codeDiff.ts new file mode 100644 index 0000000..cfddd15 --- /dev/null +++ b/web/src/views/codeDiff.ts @@ -0,0 +1,288 @@ +// The source of a component in two versions and a line diff between them, +// drawn side by side (split) or unified. Shared by the side-by-side view of +// one component and the list of all changes. +import type { AppCtx } from "../ctx"; +import { h, icon, plural } from "../dom"; +import { jsonSource } from "../loader"; +import type { Id } from "../model"; +import { highlight, highlightJson, snippet } from "../source"; +import { diffSeq, intraLine, pairRows, type Row } from "../textdiff"; +import { link } from "./common"; + +/** The highlighted source of a component in one version, cut into lines. */ +export interface SourceSide { + text: string; + /** The number of the first line in its file (1 for JSON, which is shown re-indented). */ + startLine: number; + lines: HTMLElement[]; + where: string; +} + +/** Cut the highlighted source into one fragment per line (spans broken at line ends are split). */ +function splitLines(pre: HTMLElement): HTMLElement[] { + const lines: HTMLElement[] = []; + let cur = h("span", { class: "cl" }); + for (const node of [...pre.childNodes]) { + const text = node.textContent ?? ""; + if (!text.includes("\n")) { + cur.appendChild(node); + continue; + } + text.split("\n").forEach((part, i) => { + if (i > 0) { + lines.push(cur); + cur = h("span", { class: "cl" }); + } + if (!part) return; + if (node.nodeType === Node.TEXT_NODE) cur.append(part); + else { + const c = node.cloneNode(false) as HTMLElement; + c.textContent = part; + cur.appendChild(c); + } + }); + } + lines.push(cur); + return lines; +} + +/** + * The source of a component in the version `vctx` shows: the XSD declaration + * cut from its file, or the JSON schema at its pointer. A string says why there is none. + */ +export async function loadSource(vctx: AppCtx, id: Id, title: string): Promise { + const m = vctx.m; + const c = m.comp(id); + if (c.file === undefined) return "No source location is known for it."; + const file = c.file; + const name = m.b.files[file]?.name ?? ""; + const text = await vctx.sourceText(file); + if (text === null) return `The schema files of ${title} are not available here: the bundle was loaded without them.`; + const linkTo = (target: Id, t: string) => link(vctx, target, t); + if (c.json) { + const pointer = c.json.pointer ?? ""; + let json: string; + try { + json = await jsonSource(name, text, pointer); + } catch { + return "Could not find the schema in the file."; + } + return { text: json, startLine: 1, lines: splitLines(highlightJson(m, file, json, linkTo)), where: `${name}#${pointer}` }; + } + if (!c.line) return "No source location is known for it."; + const sn = snippet(text, c.line); + if (!sn) return "Could not find the declaration in the file."; + return { text: sn.text, startLine: sn.startLine, lines: splitLines(highlight(m, file, sn.text, linkTo)), where: `${name}:${c.line}` }; +} + +/** The line diff of two sources, with the changed words of each changed line. */ +export interface LineDiff { + rows: Row[]; + ranges: Map; + /** Lines only in the new / only in the old source (a changed line counts in both). */ + added: number; + removed: number; +} + +export function lineDiff(a: SourceSide | null, b: SourceSide | null): LineDiff { + const ta = a ? a.text.split("\n") : []; + const tb = b ? b.text.split("\n") : []; + // Lines differing only in indentation or spacing count as the same. + const norm = (l: string) => l.trim().replace(/\s+/g, " "); + const rows = pairRows(diffSeq(ta.map(norm), tb.map(norm))); + const ranges: LineDiff["ranges"] = new Map(); + let added = 0; + let removed = 0; + rows.forEach((r, i) => { + if (r.t === "chg") { + // Word highlights help only when the lines are much alike; otherwise the whole line is the change. + const rg = intraLine(ta[r.a!], tb[r.b!]); + const len = (s: string) => s.replace(/\s+/g, "").length || 1; + const marked = (list: [number, number][], s: string) => list.reduce((n, [f, t]) => n + s.slice(f, t).replace(/\s+/g, "").length, 0) / len(s); + if (marked(rg.a, ta[r.a!]) < 0.65 || marked(rg.b, tb[r.b!]) < 0.65) ranges.set(i, rg); + } + if (r.t === "ins" || r.t === "chg") added++; + if (r.t === "del" || r.t === "chg") removed++; + }); + return { rows, ranges, added, removed }; +} + +export interface DiffTableOptions { + unified: boolean; + /** Unchanged lines kept around each change; longer stretches fold. Infinity shows all. */ + context: number; + /** Put a `@@ -a,n +b,m @@ label` line above each hunk. */ + hunkLabel?: string; + /** A note to show under the line `i` (0-based in the snippet) of the old (a) or new (b) side. */ + note?: (side: "a" | "b", i: number) => HTMLElement | null; +} + +type Item = { row: number } | { fold: true; from: number; to: number }; + +/** Changed rows with `context` unchanged lines around them; longer unchanged stretches fold. */ +function visibleRows(rows: Row[], context: number): Item[] { + const all = rows.map((_, i) => ({ row: i })); + if (!Number.isFinite(context)) return all; + const keep = new Uint8Array(rows.length); + rows.forEach((r, i) => { + if (r.t === "eq") return; + for (let j = Math.max(0, i - context); j <= Math.min(rows.length - 1, i + context); j++) keep[j] = 1; + }); + if (!keep.some(Boolean)) return all; + const out: Item[] = []; + for (let i = 0; i < rows.length; ) { + if (keep[i]) { + out.push({ row: i++ }); + continue; + } + let j = i; + while (j < rows.length && !keep[j]) j++; + if (j - i <= 2) for (let k = i; k < j; k++) out.push({ row: k }); + else out.push({ fold: true, from: i, to: j }); + i = j; + } + return out; +} + +/** Give the links of a cloned line the behaviour of the original's. */ +function rewire(clone: HTMLElement, orig: HTMLElement): void { + const a = clone.querySelectorAll("a"); + const b = orig.querySelectorAll("a"); + a.forEach((el, i) => + el.addEventListener("click", (ev) => { + const me = ev as MouseEvent; + if (me.metaKey || me.ctrlKey || me.shiftKey) return; + ev.preventDefault(); + b[i]?.dispatchEvent(new MouseEvent("click", { bubbles: false, cancelable: true })); + }) + ); +} + +/** Wrap the characters [from, to) of a line in a highlight. */ +function markRanges(el: HTMLElement, ranges: [number, number][]): void { + if (!ranges.length) return; + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const texts: Text[] = []; + while (walker.nextNode()) texts.push(walker.currentNode as Text); + let at = 0; + for (const t of texts) { + const s = at; + const e = at + t.data.length; + at = e; + const cuts = ranges.filter(([a, b]) => a < e && b > s); + if (!cuts.length) continue; + const frag = document.createDocumentFragment(); + let pos = s; + for (const [a, b] of cuts) { + const from = Math.max(a, s); + const to = Math.min(b, e); + if (from > pos) frag.append(t.data.slice(pos - s, from - s)); + frag.append(h("span", { class: "ch" }, t.data.slice(from - s, to - s))); + pos = to; + } + if (pos < e) frag.append(t.data.slice(pos - s)); + t.replaceWith(frag); + } +} + +const MK: Record = { del: "−", ins: "+", chg: "~", eq: "" }; + +/** The line diff as a table: split (old left, new right, rows aligned) or unified (removed lines above added ones). */ +export function diffTable(a: SourceSide | null, b: SourceSide | null, d: LineDiff, o: DiffTableOptions): HTMLTableElement { + const { rows, ranges } = d; + const unified = o.unified; + const cols = unified ? 4 : 6; + const table = h("table", { class: `codediff-table ${unified ? "unified" : "split"}` }); + if (!unified) table.appendChild(h("colgroup", null, h("col", { class: "c-ln" }), h("col", { class: "c-mk" }), h("col", { class: "c-code" }), h("col", { class: "c-ln" }), h("col", { class: "c-mk" }), h("col", { class: "c-code" }))); + else table.appendChild(h("colgroup", null, h("col", { class: "c-ln" }), h("col", { class: "c-ln" }), h("col", { class: "c-mk" }), h("col", { class: "c-code" }))); + + const line = (side: "a" | "b", i: number, ri: number): HTMLElement => { + const src = side === "a" ? a! : b!; + const el = src.lines[i].cloneNode(true) as HTMLElement; + // Clones lose the listeners of the links: put them back by delegating to the original. + rewire(el, src.lines[i]); + const rg = ranges.get(ri); + if (rg) markRanges(el, side === "a" ? rg.a : rg.b); + return el; + }; + const code = (side: "a" | "b", i: number | undefined, ri: number) => (i === undefined ? h("td", { class: "code blank" }) : h("td", { class: "code" }, line(side, i, ri))); + const num = (n: number | undefined, base: number | undefined) => h("td", { class: "ln" }, n === undefined || base === undefined ? "" : String(base + n)); + const mk = (t: string) => h("td", { class: "mk", "aria-hidden": t ? undefined : "true" }, t ? h("span", { class: "dmark" }, t) : ""); + const noteRow = (notes: (HTMLElement | null)[]): HTMLElement[] => { + const n = notes.filter((x): x is HTMLElement => !!x); + return n.length ? [h("tr", { class: "cd-note" }, h("td", { colspan: cols }, n))] : []; + }; + const note = (side: "a" | "b", i: number | undefined) => (o.note && i !== undefined ? o.note(side, i) : null); + + /** The table rows of a run of diff rows; in the unified layout a block's removed lines come before its added ones. */ + const rowEls = (from: number, to: number): HTMLElement[] => { + const out: HTMLElement[] = []; + for (let k = from; k < to; ) { + const r = rows[k]; + if (!unified) { + out.push(h("tr", { class: `cd-${r.t}` }, num(r.a, a?.startLine), mk(r.t === "eq" || r.t === "ins" ? "" : MK[r.t]), code("a", r.a, k), num(r.b, b?.startLine), mk(r.t === "eq" || r.t === "del" ? "" : MK[r.t]), code("b", r.b, k))); + out.push(...noteRow([note("a", r.t === "eq" ? undefined : r.a), note("b", r.b)])); + k++; + continue; + } + if (r.t === "eq") { + out.push(h("tr", { class: "cd-eq" }, num(r.a, a?.startLine), num(r.b, b?.startLine), mk(""), code("b", r.b, k))); + out.push(...noteRow([note("b", r.b)])); + k++; + continue; + } + let e = k; + while (e < to && rows[e].t !== "eq") e++; + for (let x = k; x < e; x++) { + const q = rows[x]; + if (q.a === undefined) continue; + out.push(h("tr", { class: "cd-del" + (q.t === "chg" ? " cd-pair" : "") }, num(q.a, a?.startLine), h("td", { class: "ln" }), mk("−"), code("a", q.a, x))); + out.push(...noteRow([note("a", q.a)])); + } + for (let x = k; x < e; x++) { + const q = rows[x]; + if (q.b === undefined) continue; + out.push(h("tr", { class: "cd-ins" + (q.t === "chg" ? " cd-pair" : "") }, h("td", { class: "ln" }), num(q.b, b?.startLine), mk("+"), code("b", q.b, x))); + out.push(...noteRow([note("b", q.b)])); + } + k = e; + } + return out; + }; + + const hunkHead = (from: number, to: number): HTMLElement | null => { + if (o.hunkLabel === undefined) return null; + const slice = rows.slice(from, to); + const fa = slice.find((r) => r.a !== undefined); + const fb = slice.find((r) => r.b !== undefined); + const na = slice.filter((r) => r.a !== undefined).length; + const nb = slice.filter((r) => r.b !== undefined).length; + const sa = fa && a ? a.startLine + fa.a! : 0; + const sb = fb && b ? b.startLine + fb.b! : 0; + return h("tr", { class: "cd-hunk" }, h("td", { colspan: cols }, `@@ -${sa},${na} +${sb},${nb} @@ ${o.hunkLabel}`)); + }; + + const tbody = h("tbody"); + const items = visibleRows(rows, o.context); + for (let i = 0; i < items.length; ) { + const it = items[i]; + if ("fold" in it) { + const btn = h("button", { type: "button", class: "fold-btn", onClick: () => tr.replaceWith(...rowEls(it.from, it.to)) }, icon("plus"), ` Show ${plural(it.to - it.from, "unchanged line")}`); + const tr = h("tr", { class: "fold" }, h("td", { colspan: cols }, btn)); + tbody.appendChild(tr); + i++; + continue; + } + // A hunk: the shown rows up to the next fold. + let j = i; + while (j < items.length && !("fold" in items[j])) j++; + const from = (it as { row: number }).row; + const to = (items[j - 1] as { row: number }).row + 1; + const head = hunkHead(from, to); + if (head) tbody.appendChild(head); + tbody.append(...rowEls(from, to)); + i = j; + } + table.appendChild(tbody); + return table; +} diff --git a/web/src/views/compare.ts b/web/src/views/compare.ts index a006325..89396dc 100644 --- a/web/src/views/compare.ts +++ b/web/src/views/compare.ts @@ -23,7 +23,7 @@ function impactBadge(i: Impact): HTMLElement | null { return null; } -const qnameOf = (c: ComponentDiff) => c.new?.qname ?? c.old?.qname ?? c.name; +export const qnameOf = (c: ComponentDiff) => c.new?.qname ?? c.old?.qname ?? c.name; export function diffHref(ctx: AppCtx, c: ComponentDiff): string { return ctx.hrefRoute({ view: "diffc", query: `${KIND_SLUG[c.kind]}/${qnameOf(c)}` }); @@ -71,6 +71,20 @@ async function download(cmp: Compare, format: "md" | "html" | "json"): Promise { + const r = { view: key === "files" ? "difff" : "diff" }; + return h("a", { class: "diff-tab", href: ctx.hrefRoute(r), "aria-current": key === active ? "page" : undefined, onClick: (ev: Event) => { + const me = ev as MouseEvent; + if (me.metaKey || me.ctrlKey || me.shiftKey) return; + ev.preventDefault(); + if (key !== active) ctx.goRoute(r); + } }, label, extra); + }; + return h("nav", { class: "diff-tabs", "aria-label": "Compare views" }, tab("overview", "Overview"), tab("files", "Changes", count ?? h("span", { class: "count" }, cmp.d.components.length.toLocaleString()))); +} + /** The overview of all changes, with filters kept in the link. */ export function diffOverviewPage(ctx: AppCtx, cmp: Compare): HTMLElement { const d = cmp.d; @@ -78,6 +92,7 @@ export function diffOverviewPage(ctx: AppCtx, cmp: Compare): HTMLElement { const t = d.summary.total; const page = h("div", { class: "diff-page" }); page.appendChild(h("header", { class: "page-head" }, h("h1", null, "Changes from ", h("span", { class: "d-removed-text" }, cmp.oldTitle), " to ", h("span", { class: "d-added-text" }, cmp.newTitle)), h("p", { class: "lead" }, `${plural(t.added + t.removed + t.changed, "global component")} differ: ${t.added.toLocaleString()} added, ${t.removed.toLocaleString()} removed, ${t.changed.toLocaleString()} changed. ${plural(d.summary.breakingChanges, "breaking change")} in ${plural(t.breaking, "component")}; ${plural(t.docsOnly, "component")} changed only in their documentation.`))); + page.appendChild(diffTabs(ctx, cmp, "overview")); const filter = { st: P.st ?? "", k: P.k ?? "", imp: P.imp ?? "", ns: P.ns ?? "", q: P.q ?? "" }; const stat = (label: string, value: number, m: Mark | null, set: Partial, cls = "") => h("button", { type: "button", class: `stat ${cls}`, onClick: () => { diff --git a/web/src/views/dialogs.ts b/web/src/views/dialogs.ts index baad7b9..cfdf8dc 100644 --- a/web/src/views/dialogs.ts +++ b/web/src/views/dialogs.ts @@ -103,6 +103,7 @@ export function openHelp(host: HTMLElement): void { "Comparing versions", false, [b("Compare two versions"), " on the open page takes an earlier and a later schema set (or open ", code("?bundle=&compare="), "). ", b("Changes"), " lists what was added (", code("+"), "), removed (", code("−"), ") and changed (", code("~"), "), with breaking changes flagged; filter it and export it as Markdown, HTML or JSON."], + ["Its ", b("Changes"), " tab shows every changed component as a card, like the changed files of a pull request: what changed, a note at the source line it is about, and the source diff. Mark a card ", b("Viewed"), " to fold it and track your progress. Keys: ", code("j"), " / ", code("k"), " next and previous card, ", code("v"), " viewed, ", code("s"), " split or unified, ", code("t"), " the tree, ", code("/"), " filter."], ["A changed component's page has four views. ", b("Changes"), " lists the changes and marks them on the later version's diagram. ", b("Side by side"), " shows each version's own diagram or content tree, left old and right new, laid out so matching boxes and rows sit at the same place, and the XSD source of both with the changed lines and words highlighted (", b("Unified"), " puts them in one column). ", b("Old"), " and ", b("New"), " show the whole normal page of either version."], ["Side by side, ", b("Linked"), " pans and zooms both sides together, double-clicking a box or opening a tree node does it on both sides, and each side has its own ", b("Export"), ". Links on the left lead to the earlier version's pages. Markers and struck-through text tell what differs without relying on colour."] ), diff --git a/web/src/views/sideBySide.ts b/web/src/views/sideBySide.ts index 779a9e0..b520233 100644 --- a/web/src/views/sideBySide.ts +++ b/web/src/views/sideBySide.ts @@ -4,14 +4,12 @@ import type { AppCtx, Route } from "../ctx"; import { Diagram, layoutGraph, type View } from "../diagram"; import { MARKER, type Compare, type ComponentDiff, type Mark } from "../diff"; -import { button, h, icon, plural, type Child } from "../dom"; +import { button, h, plural, type Child } from "../dom"; import { buildNeighborhood, defaultNeighborhood, toMermaid, toPlantUml, type GraphSpec, type NeighborhoodOptions } from "../graph"; import { KIND_SLUG, type Id, type Model } from "../model"; -import { highlight, snippet } from "../source"; -import { diffSeq, intraLine, pairRows, type Row } from "../textdiff"; import { TreeView, pairTrees, rootNode, setTreeDiff, type TNode } from "../tree"; import { boxActions, exportControls, fullscreenButton, refitOnResize, whenVisible } from "./diagramPanel"; -import { link } from "./common"; +import { diffTable, lineDiff, loadSource, type LineDiff, type SourceSide } from "./codeDiff"; export type Which = "old" | "new"; @@ -434,85 +432,7 @@ export function sbsTreePanel(ctx: AppCtx, cmp: Compare, oldFocus: Id | undefined // ---- XSD source ---------------------------------------------------------------- -/** Cut the highlighted source into one fragment per line (spans broken at line ends are split). */ -function splitLines(pre: HTMLElement): HTMLElement[] { - const lines: HTMLElement[] = []; - let cur = h("span", { class: "cl" }); - for (const node of [...pre.childNodes]) { - const text = node.textContent ?? ""; - if (!text.includes("\n")) { - cur.appendChild(node); - continue; - } - text.split("\n").forEach((part, i) => { - if (i > 0) { - lines.push(cur); - cur = h("span", { class: "cl" }); - } - if (!part) return; - if (node.nodeType === Node.TEXT_NODE) cur.append(part); - else { - const c = node.cloneNode(false) as HTMLElement; - c.textContent = part; - cur.appendChild(c); - } - }); - } - lines.push(cur); - return lines; -} - -/** Wrap the characters [from, to) of a line in a highlight. */ -function markRanges(el: HTMLElement, ranges: [number, number][]): void { - if (!ranges.length) return; - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); - const texts: Text[] = []; - while (walker.nextNode()) texts.push(walker.currentNode as Text); - let at = 0; - for (const t of texts) { - const s = at; - const e = at + t.data.length; - at = e; - const cuts = ranges.filter(([a, b]) => a < e && b > s); - if (!cuts.length) continue; - const frag = document.createDocumentFragment(); - let pos = s; - for (const [a, b] of cuts) { - const from = Math.max(a, s); - const to = Math.min(b, e); - if (from > pos) frag.append(t.data.slice(pos - s, from - s)); - frag.append(h("span", { class: "ch" }, t.data.slice(from - s, to - s))); - pos = to; - } - if (pos < e) frag.append(t.data.slice(pos - s)); - t.replaceWith(frag); - } -} - -interface SourceSide { - text: string; - startLine: number; - lines: HTMLElement[]; - where: string; -} - -async function loadSide(ctx: AppCtx, cmp: Compare, which: Which, id: Id | undefined): Promise { - if (id === undefined) return null; - const vctx = versionCtx(ctx, cmp, which); - const m = vctx.m; - const c = m.comp(id); - if (c.file === undefined || !c.line) return "No source location is known for it."; - const text = await vctx.sourceText(c.file); - if (text === null) return `The .xsd files of ${which === "old" ? cmp.oldTitle : cmp.newTitle} are not available here: the bundle was loaded without them.`; - const sn = snippet(text, c.line); - if (!sn) return "Could not find the declaration in the file."; - const pre = highlight(m, c.file, sn.text, (target, t) => link(vctx, target, t)); - return { text: sn.text, startLine: sn.startLine, lines: splitLines(pre), where: `${m.b.files[c.file].name}:${c.line}` }; -} - -const MK: Record = { del: "−", ins: "+", chg: "~", eq: "" }; - -/** The XSD of both versions with a line diff: side by side (rows aligned, one scroll) or unified. */ +/** The source of both versions with a line diff: side by side (rows aligned, one scroll) or unified. */ export function sbsSourcePanel(ctx: AppCtx, cmp: Compare, c: ComponentDiff): HTMLElement { const P = ctx.route.params; let unified = P.code === "unified" || (P.code === undefined && typeof window !== "undefined" && window.innerWidth < 700); @@ -537,84 +457,27 @@ export function sbsSourcePanel(ctx: AppCtx, cmp: Compare, c: ComponentDiff): HTM const wrap = h("div", { class: "codediff" }, h("div", { class: "codediff-tools" }, h("div", { class: "btn-group" }, sbsBtn, uniBtn), ctxBtn, counts), body); let a: SourceSide | null = null; let b: SourceSide | null = null; - let rows: Row[] = []; - let ranges = new Map(); - - const lineA = (i: number) => { - const el = a!.lines[i].cloneNode(true) as HTMLElement; - // Clones lose the listeners of the links: put them back by delegating to the original. - rewire(el, a!.lines[i]); - return el; - }; - const lineB = (i: number) => { - const el = b!.lines[i].cloneNode(true) as HTMLElement; - rewire(el, b!.lines[i]); - return el; - }; - const code = (side: "a" | "b", r: Row, idx: number | undefined): HTMLElement => { - if (idx === undefined) return h("td", { class: "code blank" }); - const el = side === "a" ? lineA(idx) : lineB(idx); - const rg = ranges.get(rows.indexOf(r)); - if (rg) markRanges(el, side === "a" ? rg.a : rg.b); - return h("td", { class: "code" }, el); - }; - const num = (n: number | undefined, base: number | undefined) => h("td", { class: "ln" }, n === undefined || base === undefined ? "" : String(base + n)); - const mk = (t: string) => h("td", { class: "mk", "aria-hidden": t ? undefined : "true" }, t ? h("span", { class: "dmark" }, t) : ""); + let d: LineDiff | null = null; const draw = () => { - if (!a && !b) return; - const table = h("table", { class: `codediff-table ${unified ? "unified" : "split"}` }); - if (!unified) table.appendChild(h("colgroup", null, h("col", { class: "c-ln" }), h("col", { class: "c-mk" }), h("col", { class: "c-code" }), h("col", { class: "c-ln" }), h("col", { class: "c-mk" }), h("col", { class: "c-code" }))); - else table.appendChild(h("colgroup", null, h("col", { class: "c-ln" }), h("col", { class: "c-ln" }), h("col", { class: "c-mk" }), h("col", { class: "c-code" }))); - const tbody = h("tbody"); - const show = visibleRows(rows, changedOnly ? 3 : Infinity); - for (const item of show) { - if (typeof item === "object" && "fold" in item) { - const btn = h("button", { type: "button", class: "fold-btn", onClick: () => { - const extra = rows.slice(item.from, item.to); - const frag = document.createDocumentFragment(); - for (const r of extra) frag.append(...rowEls(r)); - tr.replaceWith(frag); - } }, icon("plus"), ` ${plural(item.to - item.from, "unchanged line")}`); - const tr = h("tr", { class: "fold" }, h("td", { colspan: unified ? 4 : 6 }, btn)); - tbody.appendChild(tr); - continue; - } - tbody.append(...rowEls(item)); - } - table.appendChild(tbody); + if (!d) return; + const table = diffTable(a, b, d, { unified, context: changedOnly ? 3 : Infinity }); body.replaceChildren(h("div", { class: "codediff-heads" + (unified ? " unified" : "") }, sideHead("old", cmp.oldTitle, a), unified ? null : sideHead("new", cmp.newTitle, b)), h("div", { class: "codediff-scroll" }, table)); }; const sideHead = (which: Which, title: string, s: SourceSide | null) => h("div", { class: `side-head side-${which}` }, which === "old" ? "Old" : "New", h("span", { class: "faint" }, ` · ${title}`), s ? h("span", { class: "faint small mono" }, ` ${s.where}`) : h("span", { class: "faint small" }, " (none)"), unified && b ? [h("span", { class: "faint" }, " → "), "New", h("span", { class: "faint" }, ` · ${cmp.newTitle}`), h("span", { class: "faint small mono" }, ` ${b.where}`)] : null); - const rowEls = (r: Row): HTMLElement[] => { - const cls = `cd-${r.t}`; - if (!unified) return [h("tr", { class: cls }, num(r.a, a?.startLine), mk(r.t === "eq" || r.t === "ins" ? "" : MK[r.t]), code("a", r, r.a), num(r.b, b?.startLine), mk(r.t === "eq" || r.t === "del" ? "" : MK[r.t]), code("b", r, r.b))]; - if (r.t === "eq") return [h("tr", { class: cls }, num(r.a, a?.startLine), num(r.b, b?.startLine), mk(""), code("b", r, r.b))]; - const out: HTMLElement[] = []; - if (r.a !== undefined) out.push(h("tr", { class: "cd-del" + (r.t === "chg" ? " cd-pair" : "") }, num(r.a, a?.startLine), h("td", { class: "ln" }), mk("−"), code("a", r, r.a))); - if (r.b !== undefined) out.push(h("tr", { class: "cd-ins" + (r.t === "chg" ? " cd-pair" : "") }, h("td", { class: "ln" }), num(r.b, b?.startLine), mk("+"), code("b", r, r.b))); - return out; - }; + const side = (which: Which, id: Id | undefined) => (id === undefined ? Promise.resolve(null) : loadSource(versionCtx(ctx, cmp, which), id, which === "old" ? cmp.oldTitle : cmp.newTitle)); const load = async () => { - const [ra, rb] = await Promise.all([loadSide(ctx, cmp, "old", c.old?.id), loadSide(ctx, cmp, "new", c.new?.id)]); + const [ra, rb] = await Promise.all([side("old", c.old?.id), side("new", c.new?.id)]); const problems = [ra, rb].filter((x): x is string => typeof x === "string"); - a = typeof ra === "object" ? ra : null; - b = typeof rb === "object" ? rb : null; + a = ra && typeof ra === "object" ? ra : null; + b = rb && typeof rb === "object" ? rb : null; if (!a && !b) { body.replaceChildren(...(problems.length ? problems : ["No source is known for it."]).map((p) => h("p", { class: "muted small" }, p))); return; } - const ta = a ? a.text.split("\n") : []; - const tb = b ? b.text.split("\n") : []; - // Lines differing only in indentation or spacing count as the same. - const norm = (l: string) => l.trim().replace(/\s+/g, " "); - rows = pairRows(diffSeq(ta.map(norm), tb.map(norm))); - ranges = new Map(); - rows.forEach((r, i) => { - if (r.t === "chg") ranges.set(i, intraLine(ta[r.a!], tb[r.b!])); - }); - const n = (t: string) => rows.filter((r) => r.t === t).length; + d = lineDiff(a, b); + const n = (t: string) => d!.rows.filter((r) => r.t === t).length; counts.replaceChildren(h("span", { class: "dmark d-added" }, "+"), ` ${n("ins")} added · `, h("span", { class: "dmark d-removed" }, "−"), ` ${n("del")} removed · `, h("span", { class: "dmark d-changed" }, "~"), ` ${plural(n("chg"), "changed line")}`); draw(); for (const p of problems) body.prepend(h("p", { class: "muted small" }, p)); @@ -623,42 +486,6 @@ export function sbsSourcePanel(ctx: AppCtx, cmp: Compare, c: ComponentDiff): HTM return wrap; } -/** Changed rows with `context` unchanged lines around them; longer unchanged stretches fold. */ -function visibleRows(rows: Row[], context: number): (Row | { fold: true; from: number; to: number })[] { - if (!Number.isFinite(context)) return rows; - const keep = new Uint8Array(rows.length); - rows.forEach((r, i) => { - if (r.t === "eq") return; - for (let j = Math.max(0, i - context); j <= Math.min(rows.length - 1, i + context); j++) keep[j] = 1; - }); - if (!keep.some(Boolean)) return rows; - const out: (Row | { fold: true; from: number; to: number })[] = []; - for (let i = 0; i < rows.length; ) { - if (keep[i]) { - out.push(rows[i++]); - continue; - } - let j = i; - while (j < rows.length && !keep[j]) j++; - if (j - i <= 2) for (let k = i; k < j; k++) out.push(rows[k]); - else out.push({ fold: true, from: i, to: j }); - i = j; - } - return out; -} - -/** Give the links of a cloned line the behaviour of the original's. */ -function rewire(clone: HTMLElement, orig: HTMLElement): void { - const a = clone.querySelectorAll("a"); - const b = orig.querySelectorAll("a"); - a.forEach((el, i) => el.addEventListener("click", (ev) => { - const me = ev as MouseEvent; - if (me.metaKey || me.ctrlKey || me.shiftKey) return; - ev.preventDefault(); - b[i]?.dispatchEvent(new MouseEvent("click", { bubbles: false, cancelable: true })); - })); -} - // ---- a page of the earlier version ------------------------------------------- /** A component of the earlier version, shown as a normal page of that version. */