diff --git a/README.md b/README.md index 03f6e85..9c1b19b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,11 @@ your own files on it. added, removed and changed type, element, attribute, cardinality and enumeration value, rated breaking or not, with likely renames and namespace changes detected; changes are highlighted in the diagrams and - trees, and exported as Markdown, HTML or JSON. + trees, and exported as Markdown, HTML or JSON. Each changed component can + 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. - **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). @@ -122,10 +126,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 -both versions side by side, and diagrams and content trees of the later -version mark what was added (+, green), removed (−, red, struck through) -and changed (~, amber). +lists the changes with filters. 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 +content tree next to the other, laid out so matching boxes and rows line +up, and the component's XSD source of both versions with a line diff; +*Old* and *New* show the full page of either version. Pass +`&compareSources=` when the earlier version's .xsd files are not next +to its bundle. ## Layout diff --git a/web/src/app.ts b/web/src/app.ts index a9f4201..d92af30 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -8,7 +8,8 @@ import { matdataMark } from "./logo"; import { openAbout, openHelp } from "./views/dialogs"; import { homePage } from "./views/home"; import { overviewPage } from "./views/overview"; -import { diffBanner, diffComponentPage, diffOverviewPage } from "./views/compare"; +import { diffBanner, diffComponentPage, diffLink, diffOverviewPage } from "./views/compare"; +import { oldVersionBanner, versionCtx } from "./views/sideBySide"; import { componentPage, filePage } from "./views/pages"; import { sidebar, type Sidebar } from "./views/sidebar"; @@ -173,6 +174,8 @@ export class App implements AppCtx { } if (this._cmp?.oldUrl) u.searchParams.set("compare", this._cmp.oldUrl); else u.searchParams.delete("compare"); + if (this._cmp?.oldUrl && this._cmp.oldSourcesUrl) u.searchParams.set("compareSources", this._cmp.oldSourcesUrl); + else u.searchParams.delete("compareSources"); const complete = this.source?.kind !== "files" && (!this._cmp || !!this._cmp.oldUrl); return { url: u.toString(), complete }; } @@ -220,6 +223,10 @@ export class App implements AppCtx { if (r.view === "comp" && r.id !== undefined && this.model) { const c = this.model.comp(r.id); path = c.parent === undefined && c.name ? `#/${KIND_SLUG[c.kind]}/${encodeURIComponent(this.model.qname(r.id))}` : `#/id/${r.id}`; + } else if (r.view === "old" && r.id !== undefined && this._cmp) { + const om = this._cmp.old; + const c = om.comp(r.id); + path = c.parent === undefined && c.name ? `#/old/${KIND_SLUG[c.kind]}/${encodeURIComponent(om.qname(r.id))}` : `#/old/id/${r.id}`; } else if (r.view === "file" && r.id !== undefined) path = `#/file/${r.id}`; else if (r.view === "diffc" && r.query) { const i = r.query.indexOf("/"); @@ -242,9 +249,15 @@ export class App implements AppCtx { if (!m) return { view: "home", params }; 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("/"))}`, params }; + if (parts.length >= 3) return { view: "diffc", query: `${parts[1]}/${decodeURIComponent(parts.slice(2).join("/"))}`, tab, params }; return { view: "diff", params }; } + if (parts[0] === "old" && this._cmp && parts.length >= 3) { + const om = this._cmp.old; + const id = parts[1] === "id" ? Number(parts[2]) : om.find(parts[1], decodeURIComponent(parts.slice(2).join("/"))); + if (id !== undefined && Number.isInteger(id) && om.c[id]) return { view: "old", id, tab, params }; + return { view: "missing", query: decodeURIComponent(parts.slice(2).join("/")), params }; + } if (parts[0] === "file" && parts[1] !== undefined) { const n = Number(parts[1]); if (Number.isInteger(n) && m.b.files[n]) return { view: "file", id: n, params }; @@ -288,10 +301,10 @@ export class App implements AppCtx { } // ---- loading ------------------------------------------------------------ - async start(src?: string | null, focus?: string | null, sources?: string | null, compare?: string | null): Promise { + async start(src?: string | null, focus?: string | null, sources?: string | null, compare?: string | null, compareSources?: string | null): Promise { this.server = await serverStatus(); if (src && compare) { - await this.openCompare({ url: compare }, { url: src, sources: sources ?? undefined }); + await this.openCompare({ url: compare, sources: compareSources ?? undefined }, { url: src, sources: sources ?? undefined }); return; } if (src) { @@ -417,6 +430,7 @@ export class App implements AppCtx { this.sources = memorySources(newer.files.filter((f) => /\.xsd$/i.test(f.name))); } const oldUrl = "url" in older ? older.url : undefined; + const oldSources = "url" in older ? urlSources(new URL(older.sources ?? older.url, document.baseURI).href) : memorySources(older.files.filter((f) => /\.xsd$/i.test(f.name))); if (this.opts.routing) { const u = new URL(location.href); u.searchParams.delete("src"); @@ -424,10 +438,12 @@ export class App implements AppCtx { else u.searchParams.delete("bundle"); if (oldUrl && "url" in newer) u.searchParams.set("compare", oldUrl); else u.searchParams.delete("compare"); + if (oldUrl && "url" in older && older.sources) u.searchParams.set("compareSources", older.sources); + else u.searchParams.delete("compareSources"); history.replaceState(history.state, "", u.toString()); } const home = !location.hash || location.hash === "#/" || !this.opts.routing; - this.setBundle(b, { old: a, diff, oldUrl }); + this.setBundle(b, { old: a, diff, oldUrl, oldSources, oldSourcesUrl: "url" in older ? older.sources : undefined }); if (home) this.goRoute({ view: "diff" }); } catch (e) { this.showError(e as Error, () => this.showOpen()); @@ -494,10 +510,11 @@ export class App implements AppCtx { ); } - setBundle(b: Bundle, cmp: { old: Bundle; diff: import("./diff").SchemaDiff; oldUrl?: string } | null = null): void { + setBundle(b: Bundle, cmp: { old: Bundle; diff: import("./diff").SchemaDiff; oldUrl?: string; oldSources?: SourceProvider; oldSourcesUrl?: string } | null = null): void { const t0 = performance.now(); this.model = new Model(b); this._cmp = cmp ? new Compare(new Model(cmp.old), this.model, cmp.diff, cmp.oldUrl) : null; + if (cmp?.oldSources) this._cmp?.setOldSources(cmp.oldSources, cmp.oldSourcesUrl); if (this.opts.routing && !cmp) { const u = new URL(location.href); if (u.searchParams.has("compare")) { @@ -546,6 +563,11 @@ export class App implements AppCtx { const banner = this._cmp ? diffBanner(this, this._cmp, r.id) : null; if (banner) page.insertBefore(banner, page.firstChild); if (this.opts.routing) document.title = `${this.model.label(r.id)} · ${title}`; + } else if (r.view === "old" && r.id !== undefined && this._cmp) { + const cmp = this._cmp; + page = componentPage(versionCtx(this, cmp, "old"), r.id); + page.insertBefore(oldVersionBanner(this, cmp, r.id, (cd) => diffLink(this, cd, "Compare the versions")), page.firstChild); + if (this.opts.routing) document.title = `${cmp.old.label(r.id)} (${cmp.oldTitle}) · ${title}`; } else if (r.view === "file" && r.id !== undefined) { page = filePage(this, r.id); if (this.opts.routing) document.title = `${this.model.b.files[r.id]?.name} · ${title}`; diff --git a/web/src/diagram.ts b/web/src/diagram.ts index 7a1dfe8..f423893 100644 --- a/web/src/diagram.ts +++ b/web/src/diagram.ts @@ -46,7 +46,7 @@ function ellipsize(text: string, max: number, font = FONT): string { return text.slice(0, Math.max(1, lo)) + "…"; } -interface Sized { +export interface Sized { w: number; h: number; /** Rows of (group header | attribute) in display order. */ @@ -89,6 +89,8 @@ export interface DiagramCallbacks { onViewChange?: (view: View | null) => void; /** Actions offered when a box is clicked. Without them, a click opens the entity. */ nodeMenu?: (id: number) => NodeAction[]; + /** Every pan and zoom by the user, at once (to move a linked diagram along). */ + onTransform?: (view: View) => void; } export interface View { @@ -111,6 +113,61 @@ export interface RenderOptions { view?: View; /** Use ELK's stress layout (for maps) instead of layered. */ algorithm?: "layered" | "stress"; + /** + * Positions from a layout of a bigger graph (compare mode: both versions + * together), so boxes sit at the same place in two diagrams. Only the + * boxes and lines of `spec` are drawn, each at the size of the bigger graph. + */ + shared?: Promise; +} + +/** A graph laid out by ELK and the box sizes it was given. */ +export interface LaidGraph { + laid: ElkNode; + sizes: Map; +} + +/** Lay out a graph with ELK, boxes sized by their text. */ +export async function layoutGraph(spec: GraphSpec, opts: { direction: "DOWN" | "RIGHT"; algorithm?: "layered" | "stress" }): Promise { + await document.fonts?.ready; + const sizes = new Map(); + for (const n of spec.nodes) sizes.set(n.key, size(n)); + const algorithm = opts.algorithm ?? "layered"; + const elkGraph: ElkNode = { + id: "root", + layoutOptions: { + "elk.algorithm": algorithm, + "elk.direction": opts.direction, + "elk.spacing.nodeNode": algorithm === "stress" ? "60" : "36", + "elk.layered.spacing.nodeNodeBetweenLayers": "70", + "elk.layered.spacing.edgeNodeBetweenLayers": "30", + "elk.spacing.edgeNode": "24", + "elk.spacing.edgeEdge": "16", + "elk.spacing.edgeLabel": "6", + "elk.edgeRouting": algorithm === "stress" ? "POLYLINE" : "ORTHOGONAL", + "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES", + "elk.layered.crossingMinimization.semiInteractive": "true", + "elk.layered.mergeEdges": "false", + "elk.edgeLabels.inline": "false", + "elk.stress.desiredEdgeLength": "180", + "elk.padding": "[top=20,left=20,bottom=20,right=20]", + }, + children: spec.nodes.map((n) => { + const sz = sizes.get(n.key)!; + return { id: n.key, width: sz.w, height: sz.h }; + }), + edges: spec.edges.map((e) => ({ + id: e.key, + // Generalisations are drawn from the super class (source) to the sub + // class so the hierarchy reads top-down; the triangle sits at the source. + sources: [e.from], + targets: [e.to], + labels: e.label ? [{ text: edgeLabel(e.label), width: Math.ceil(measure(edgeLabel(e.label), EDGE_FONT)) + 6, height: 14 }] : undefined, + layoutOptions: e.kind === "gen" || e.kind === "restr" ? { "elk.layered.priority.direction": "10" } : undefined, + })), + }; + return { laid: await layout(elkGraph), sizes }; } export class Diagram { @@ -158,49 +215,19 @@ export class Diagram { const my = ++this.seq; this.spec = spec; this.closeMenu(); - // Boxes are sized by measuring text, so wait for the web fonts. - await document.fonts?.ready; - if (my !== this.seq) return; - const sizes = new Map(); - for (const n of spec.nodes) sizes.set(n.key, size(n)); - const algorithm = opts.algorithm ?? "layered"; - const elkGraph: ElkNode = { - id: "root", - layoutOptions: { - "elk.algorithm": algorithm, - "elk.direction": opts.direction, - "elk.spacing.nodeNode": algorithm === "stress" ? "60" : "36", - "elk.layered.spacing.nodeNodeBetweenLayers": "70", - "elk.layered.spacing.edgeNodeBetweenLayers": "30", - "elk.spacing.edgeNode": "24", - "elk.spacing.edgeEdge": "16", - "elk.spacing.edgeLabel": "6", - "elk.edgeRouting": algorithm === "stress" ? "POLYLINE" : "ORTHOGONAL", - "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", - "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES", - "elk.layered.crossingMinimization.semiInteractive": "true", - "elk.layered.mergeEdges": "false", - "elk.edgeLabels.inline": "false", - "elk.stress.desiredEdgeLength": "180", - "elk.padding": "[top=20,left=20,bottom=20,right=20]", - }, - children: spec.nodes.map((n) => { - const sz = sizes.get(n.key)!; - return { id: n.key, width: sz.w, height: sz.h }; - }), - edges: spec.edges.map((e) => ({ - id: e.key, - // Generalisations are drawn from the super class (source) to the sub - // class so the hierarchy reads top-down; the triangle sits at the source. - sources: [e.from], - targets: [e.to], - labels: e.label ? [{ text: edgeLabel(e.label), width: Math.ceil(measure(edgeLabel(e.label), EDGE_FONT)) + 6, height: 14 }] : undefined, - layoutOptions: e.kind === "gen" || e.kind === "restr" ? { "elk.layered.priority.direction": "10" } : undefined, - })), - }; let laid: ElkNode; + let sizes: Map; try { - laid = await layout(elkGraph); + if (opts.shared) { + const shared = await opts.shared; + laid = shared.laid; + // Each box shows its own lines in the room the bigger graph gave it. + sizes = new Map(spec.nodes.map((n) => { + const own = size(n); + const room = shared.sizes.get(n.key); + return [n.key, room ? { ...own, w: room.w, h: room.h } : own]; + })); + } else ({ laid, sizes } = await layoutGraph(spec, opts)); } catch (e) { this.cb.onStatus?.(`layout failed: ${(e as Error).message}`); return; @@ -482,6 +509,7 @@ export class Diagram { /** `byUser`: a pan or zoom worth remembering in the link to this view. */ protected applyTransform(byUser = true): void { this.viewport.setAttribute("transform", `translate(${this.tx} ${this.ty}) scale(${this.scale})`); + if (byUser) this.cb.onTransform?.(this.view); if (byUser && this.cb.onViewChange) { clearTimeout(this.viewTimer); this.viewTimer = setTimeout(() => this.cb.onViewChange?.({ tx: this.tx, ty: this.ty, scale: this.scale }), 400); @@ -493,6 +521,17 @@ export class Diagram { /** The smallest scale "fit" goes down to; beyond it the view centres on the focus. */ minFitScale = 0.45; + /** The current pan and zoom. */ + get view(): View { + return { tx: this.tx, ty: this.ty, scale: this.scale }; + } + + /** Pan and zoom to `v` (a linked diagram moved), without telling onTransform. */ + setView(v: View): void { + ({ tx: this.tx, ty: this.ty, scale: this.scale } = v); + this.applyTransform(false); + } + fit(): void { const box = this.svg.getBoundingClientRect(); if (!box.width || !this.content.w) return; @@ -661,6 +700,12 @@ text.attr.d-removed{fill:${v("--se-del")};text-decoration:line-through} text.attr.d-changed{fill:${v("--se-chg")}} .edge.d-added path{stroke:${v("--se-add")};stroke-width:2} .edge.d-changed path{stroke:${v("--se-chg")};stroke-width:2} +.node.d-removed rect.box{stroke:${v("--se-del")};stroke-width:2.5;stroke-dasharray:6 3} +.node.d-removed text.name{fill:${v("--se-del")};text-decoration:line-through} +.edge.d-removed path{stroke:${v("--se-del")};stroke-width:2;stroke-dasharray:6 3} +.edge.d-added text{fill:${v("--se-add")}} +.edge.d-removed text{fill:${v("--se-del")};text-decoration:line-through} +.edge.d-changed text{fill:${v("--se-chg")}} ${this.extraCss()}`; } diff --git a/web/src/diff.ts b/web/src/diff.ts index 422f343..637435c 100644 --- a/web/src/diff.ts +++ b/web/src/diff.ts @@ -148,6 +148,10 @@ export class Compare { readonly byNew = new Map(); readonly byOld = new Map(); private marks = new Map(); + private oldSources: ((file: number, name: string) => Promise) | null = null; + private oldCache = new Map>(); + /** Where the earlier version's .xsd files are, when given apart from its bundle (for share links). */ + oldSourcesUrl?: string; constructor( readonly old: Model, @@ -162,6 +166,24 @@ export class Compare { } } + /** Where the text of the earlier version's schema files comes from. */ + setOldSources(p: (file: number, name: string) => Promise, url?: string): void { + this.oldSources = p; + this.oldSourcesUrl = url; + this.oldCache.clear(); + } + + /** The text of a schema file of the earlier version, when it can be had. */ + oldSourceText(file: number): Promise { + if (!this.oldSources) return Promise.resolve(null); + let p = this.oldCache.get(file); + if (!p) { + p = this.oldSources(file, this.old.b.files[file]?.name ?? ""); + this.oldCache.set(file, p); + } + return p; + } + get oldTitle(): string { return this.d.old.title ?? (this.d.old.version ? `version ${this.d.old.version}` : this.old.title); } diff --git a/web/src/dom.ts b/web/src/dom.ts index e263d00..8818afc 100644 --- a/web/src/dom.ts +++ b/web/src/dom.ts @@ -96,6 +96,9 @@ const ICONS: Record = { maximize: '', minimize: '', code: '', + columns: '', + history: '', + file: '', settings: '', }; diff --git a/web/src/element.ts b/web/src/element.ts index 0e83858..9f4e317 100644 --- a/web/src/element.ts +++ b/web/src/element.ts @@ -55,7 +55,8 @@ export class SchemaExplorerElement extends HTMLElement { const focus = this.getAttribute("focus") ?? q?.get("focus") ?? null; const sources = this.getAttribute("sources") ?? q?.get("sources") ?? null; const compare = this.getAttribute("compare") ?? q?.get("compare") ?? null; - void this.app.start(src, focus, sources, compare); + const compareSources = this.getAttribute("compare-sources") ?? q?.get("compareSources") ?? null; + void this.app.start(src, focus, sources, compare, compareSources); } disconnectedCallback(): void { diff --git a/web/src/styles.css b/web/src/styles.css index 06c8b33..861d542 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2273,3 +2273,270 @@ h1.d-removed-text { margin-left: 0; } } + +/* ---- compare: view switch and side by side ------------------------------ */ +.view-switch { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + margin: 4px 0 14px; +} +.diff-banner.side-old { + border-left-color: var(--se-del); +} +.diff-banner.side-new { + border-left-color: var(--se-add); +} +.sbs-panel { + position: relative; + margin-bottom: 6px; +} +.sbs-panel:fullscreen, +.sbs-panel.fs { + background: var(--se-bg); + padding: 10px; + display: flex; + flex-direction: column; + overflow: auto; +} +.sbs-panel.fs { + position: fixed; + inset: 0; + z-index: 50; +} +.sbs-panel:fullscreen .sbs-grid, +.sbs-panel.fs .sbs-grid { + flex: 1; +} +.sbs-panel:fullscreen .sbs-pane, +.sbs-panel.fs .sbs-pane { + height: 100%; + resize: none; +} +.sbs-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} +.sbs-tools .spacer { + flex: 1; +} +.sbs-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; +} +.diagram.sbs-pane { + height: max(420px, calc(100vh - 300px)); +} +.diagram.sbs-pane.side-old { + border-top: 3px solid var(--se-del); +} +.diagram.sbs-pane.side-new { + border-top: 3px solid var(--se-add); +} +.sbs-label { + font-weight: 600; + font-size: 13px; + padding: 3px 8px; + border-radius: var(--se-radius-sm); + background: var(--se-panel); + box-shadow: var(--se-shadow); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 70%; +} +.sbs-label.side-old { + color: var(--se-del); +} +.sbs-label.side-new { + color: var(--se-add); +} +.sbs-missing { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--se-text-3); + font-size: 14px; + pointer-events: none; +} +.sbs-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; +} +.side-head.side-old { + color: var(--se-del); +} +.side-head.side-new { + color: var(--se-add); +} +.uml .node.d-removed rect.box { + stroke: var(--se-del); + stroke-width: 2.5; + stroke-dasharray: 6 3; +} +.uml .node.d-removed text.name { + fill: var(--se-del); + text-decoration: line-through; +} +.uml .edge.d-removed path { + stroke: var(--se-del); + stroke-width: 2; + stroke-dasharray: 6 3; +} +.uml .edge.d-removed text { + fill: var(--se-del); + text-decoration: line-through; +} +.cm-tree .tn rect.gap { + fill: none; + stroke: var(--se-border); + stroke-dasharray: 2 4; +} + +/* ---- compare: XSD source diff -------------------------------------------- */ +.codediff-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 8px; +} +.codediff-heads { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 12px; +} +.codediff-heads.unified { + grid-template-columns: 1fr; +} +.codediff-scroll { + max-height: max(420px, calc(100vh - 240px)); + overflow: auto; + background: var(--se-panel); + border-radius: var(--se-radius); + box-shadow: var(--se-shadow); +} +.codediff-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font: 12px/1.5 var(--se-mono); +} +.codediff-table col.c-ln { + width: 3.6em; +} +.codediff-table col.c-mk { + width: 1.6em; +} +.codediff-table td { + padding: 0 6px; + vertical-align: top; +} +.codediff-table td.ln { + text-align: right; + color: var(--se-text-3); + user-select: none; + border-right: 1px solid var(--se-border); +} +.codediff-table td.mk { + text-align: center; + font-weight: 700; + padding: 0; + user-select: none; +} +.codediff-table td.code { + tab-size: 2; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.codediff-table.split td.code:nth-child(3) { + border-right: 1px solid var(--se-border-strong); +} +.codediff-table .cl a { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--se-accent); + text-underline-offset: 2px; +} +.codediff-table td.code.blank { + background: repeating-linear-gradient(135deg, transparent 0 6px, var(--se-panel-2) 6px 9px); +} +/* Split view: the old side of a row is red, the new side green; unified: by row. */ +.codediff-table.split tr.cd-del td:nth-child(-n + 3), +.codediff-table.split tr.cd-chg td:nth-child(-n + 3), +.codediff-table.unified tr.cd-del td { + background-color: var(--se-del-bg); +} +.codediff-table.split tr.cd-ins td:nth-child(n + 4), +.codediff-table.split tr.cd-chg td:nth-child(n + 4), +.codediff-table.unified tr.cd-ins td { + background-color: var(--se-add-bg); +} +.codediff-table.split tr.cd-chg td.mk, +.codediff-table.split tr.cd-chg td.mk .dmark { + color: var(--se-chg); +} +.codediff-table tr.cd-del td.mk, +.codediff-table.split tr.cd-chg td:nth-child(2) { + color: var(--se-del); +} +.codediff-table tr.cd-ins td.mk, +.codediff-table.split tr.cd-chg td:nth-child(5) { + color: var(--se-add); +} +.codediff-table tr.cd-del td.code .ch, +.codediff-table.split tr.cd-chg td:nth-child(3) .ch { + background: color-mix(in srgb, var(--se-del) 28%, transparent); + text-decoration: line-through; + text-decoration-color: var(--se-del); + border-radius: 2px; +} +.codediff-table tr.cd-ins td.code .ch, +.codediff-table.split tr.cd-chg td:nth-child(6) .ch { + background: color-mix(in srgb, var(--se-add) 28%, transparent); + outline: 1px solid color-mix(in srgb, var(--se-add) 50%, transparent); + border-radius: 2px; +} +.codediff-table tr.fold td { + background: var(--se-panel-2); + padding: 2px 8px; +} +.fold-btn { + border: 0; + background: none; + color: var(--se-accent); + font: 12px var(--se-font); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} +.fold-btn .icon { + width: 12px; + height: 12px; +} +@media (max-width: 860px) { + .sbs-grid { + grid-template-columns: minmax(0, 1fr); + } + .diagram.sbs-pane { + height: 420px; + } + .codediff-table { + font-size: 11px; + } + .codediff-table col.c-ln { + width: 2.8em; + } +} +.sbs-missing[hidden] { + display: none; +} diff --git a/web/src/textdiff.ts b/web/src/textdiff.ts new file mode 100644 index 0000000..50241b3 --- /dev/null +++ b/web/src/textdiff.ts @@ -0,0 +1,143 @@ +// A small sequence diff (Myers' O(ND) algorithm) for lines and for the +// words inside a changed line. No dependency: the inputs are short snippets. + +export type OpKind = "eq" | "del" | "ins"; +/** One step of an edit script; `a` / `b` are indexes into the old / new sequence. */ +export interface Op { + t: OpKind; + a?: number; + b?: number; +} + +/** The shortest edit script that turns `a` into `b`. */ +export function diffSeq(a: readonly T[], b: readonly T[], eq: (x: T, y: T) => boolean = (x, y) => x === y): Op[] { + // Common head and tail are cheap to take off first. + let head = 0; + while (head < a.length && head < b.length && eq(a[head], b[head])) head++; + let tail = 0; + while (tail < a.length - head && tail < b.length - head && eq(a[a.length - 1 - tail], b[b.length - 1 - tail])) tail++; + const ops: Op[] = []; + for (let i = 0; i < head; i++) ops.push({ t: "eq", a: i, b: i }); + const n = a.length - head - tail; + const m = b.length - head - tail; + const A = (i: number) => a[head + i]; + const B = (j: number) => b[head + j]; + if (n === 0 || m === 0) { + for (let i = 0; i < n; i++) ops.push({ t: "del", a: head + i }); + for (let j = 0; j < m; j++) ops.push({ t: "ins", b: head + j }); + } else if (n * m > 25_000_000) { + // Too big to be worth it: everything changed. + for (let i = 0; i < n; i++) ops.push({ t: "del", a: head + i }); + for (let j = 0; j < m; j++) ops.push({ t: "ins", b: head + j }); + } else { + const max = n + m; + const off = max; + const v = new Int32Array(2 * max + 2); + const trace: Int32Array[] = []; + let found = false; + for (let d = 0; d <= max && !found; d++) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + let x = k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) ? v[off + k + 1] : v[off + k - 1] + 1; + let y = x - k; + while (x < n && y < m && eq(A(x), B(y))) { + x++; + y++; + } + v[off + k] = x; + if (x >= n && y >= m) { + found = true; + break; + } + } + } + // Walk back through the saved frontiers. + const rev: Op[] = []; + let x = n; + let y = m; + for (let d = trace.length - 1; d >= 0; d--) { + const vd = trace[d]; + const k = x - y; + const prevK = k === -d || (k !== d && vd[off + k - 1] < vd[off + k + 1]) ? k + 1 : k - 1; + const px = d === 0 ? 0 : vd[off + prevK]; + const py = px - prevK; + while (x > px && y > py) { + x--; + y--; + rev.push({ t: "eq", a: head + x, b: head + y }); + } + if (d === 0) break; + if (x === px) rev.push({ t: "ins", b: head + --y }); + else rev.push({ t: "del", a: head + --x }); + } + for (let i = rev.length - 1; i >= 0; i--) ops.push(rev[i]); + } + for (let i = 0; i < tail; i++) ops.push({ t: "eq", a: a.length - tail + i, b: b.length - tail + i }); + return ops; +} + +export type RowKind = "eq" | "del" | "ins" | "chg"; +/** A row of a side-by-side view: a line of the old text, of the new text, or both. */ +export interface Row { + t: RowKind; + a?: number; + b?: number; +} + +/** Pair the removed and added lines of each change block, so a changed line sits next to its new form. */ +export function pairRows(ops: Op[]): Row[] { + const rows: Row[] = []; + let dels: number[] = []; + let ins: number[] = []; + const flush = () => { + const n = Math.max(dels.length, ins.length); + for (let i = 0; i < n; i++) { + const a = dels[i]; + const b = ins[i]; + rows.push(a !== undefined && b !== undefined ? { t: "chg", a, b } : a !== undefined ? { t: "del", a } : { t: "ins", b }); + } + dels = []; + ins = []; + }; + for (const o of ops) { + if (o.t === "eq") { + flush(); + rows.push({ t: "eq", a: o.a, b: o.b }); + } else if (o.t === "del") dels.push(o.a!); + else ins.push(o.b!); + } + flush(); + return rows; +} + +/** Character ranges [from, to) that differ between two versions of a line, word by word. */ +export function intraLine(a: string, b: string): { a: [number, number][]; b: [number, number][] } { + const tok = (s: string) => s.match(/\w+|\s+|[^\w\s]/g) ?? []; + const ta = tok(a); + const tb = tok(b); + const pa = offsets(ta); + const pb = offsets(tb); + const out = { a: [] as [number, number][], b: [] as [number, number][] }; + const push = (list: [number, number][], from: number, to: number) => { + const last = list[list.length - 1]; + if (last && last[1] === from) last[1] = to; + else list.push([from, to]); + }; + // Whitespace counts as equal whatever its width, and is never highlighted on its own. + const norm = (t: string) => (/^\s+$/.test(t) ? " " : t); + for (const o of diffSeq(ta.map(norm), tb.map(norm))) { + if (o.t === "del" && norm(ta[o.a!]) !== " ") push(out.a, pa[o.a!], pa[o.a!] + ta[o.a!].length); + else if (o.t === "ins" && norm(tb[o.b!]) !== " ") push(out.b, pb[o.b!], pb[o.b!] + tb[o.b!].length); + } + return out; +} + +function offsets(tokens: string[]): number[] { + const out: number[] = []; + let at = 0; + for (const t of tokens) { + out.push(at); + at += t.length; + } + return out; +} diff --git a/web/src/tree.ts b/web/src/tree.ts index 10731b8..cbc1818 100644 --- a/web/src/tree.ts +++ b/web/src/tree.ts @@ -5,6 +5,7 @@ import { Diagram, MONO, SANS, measure, type DiagramCallbacks, type NodeAction, type View } from "./diagram"; import { s } from "./dom"; import { MARKER, type Compare, type Mark } from "./diff"; +import { diffSeq } from "./textdiff"; import { isId, occursText, type Id, type Model, type Particle, type Ref } from "./model"; /** Compare mode: the diff whose changes the tree marks. */ @@ -48,6 +49,12 @@ export interface TNode { children: () => TNode[]; /** Compare mode: how it differs from the earlier version. */ diff?: Mark; + /** Side-by-side compare: a blank row standing for an item only the other version has. */ + gap?: boolean; + /** A blank inside a blank: drawn as nothing. */ + gapHidden?: boolean; + /** Side-by-side compare: the room the node takes, the same on both sides so rows line up. */ + align?: { w: number; h: number; note: boolean; occ: boolean }; } const NAME_FONT = `600 13px ${SANS}`; @@ -144,6 +151,11 @@ interface Placed { } function nodeSize(n: TNode): { w: number; h: number } { + if (n.align) return { w: n.align.w, h: n.align.h }; + return ownSize(n); +} + +function ownSize(n: TNode): { w: number; h: number } { if (n.kind === "sequence" || n.kind === "choice" || n.kind === "all") return { w: COMP_W, h: COMP_H }; if (n.kind === "attrs") { const lines = n.lines ?? []; @@ -168,13 +180,14 @@ function isOpen(n: TNode, open: Set): boolean { function place(n: TNode, x: number, y: number, open: Set, budget: { n: number }): Placed { const { w, h } = nodeSize(n); - const extra = n.note ? 14 : 0; + const extra = n.note || n.align?.note ? 14 : 0; + const occ = n.min !== 1 || n.max !== 1 || n.align?.occ ? 12 : 0; const o = isOpen(n, open) && budget.n > 0; const span = Math.max(w, n.note ? Math.ceil(measure(n.note, SMALL_FONT)) + 6 : 0); const p: Placed = { n, x, y: y + extra, w, h, span, kids: [], open: o }; budget.n--; if (!o) { - (p as Placed & { total: number }).total = h + extra + (n.min !== 1 || n.max !== 1 ? 12 : 0); + (p as Placed & { total: number }).total = h + extra + occ; return p; } let cy = y; @@ -184,7 +197,7 @@ function place(n: TNode, x: number, y: number, open: Set, budget: { n: n cy += (k as Placed & { total: number }).total + GAP_Y; } const kidsH = Math.max(0, cy - y - GAP_Y); - const own = h + extra + (n.min !== 1 || n.max !== 1 ? 12 : 0); + const own = h + extra + occ; // Centre the node on its children. if (kidsH > own) p.y = y + extra + (kidsH - own) / 2; (p as Placed & { total: number }).total = Math.max(own, kidsH); @@ -222,15 +235,16 @@ export class TreeView extends Diagram { maxY = Math.max(maxY, p.y + p.h + 16); nodes.appendChild(this.drawTNode(p)); if (!p.kids.length) return; - const sx = p.x + p.w + (p.n.expandable ? 8 : 0); - const sy = p.y + p.h / 2; - const bx = p.x + p.span + GAP_X / 2; - const ys = p.kids.map((k) => k.y + k.h / 2); - edges.appendChild(s("path", { d: `M${sx} ${sy}H${bx}M${bx} ${Math.min(...ys, sy)}V${Math.max(...ys, sy)}` })); - for (const k of p.kids) { - edges.appendChild(s("path", { d: `M${bx} ${k.y + k.h / 2}H${k.x}` })); - draw(k); + const real = p.kids.filter((k) => !k.n.gap); + if (real.length && !p.n.gap) { + const sx = p.x + p.w + (p.n.expandable ? 8 : 0); + const sy = p.y + p.h / 2; + const bx = p.x + p.span + GAP_X / 2; + const ys = real.map((k) => k.y + k.h / 2); + edges.appendChild(s("path", { d: `M${sx} ${sy}H${bx}M${bx} ${Math.min(...ys, sy)}V${Math.max(...ys, sy)}` })); + for (const k of real) edges.appendChild(s("path", { d: `M${bx} ${k.y + k.h / 2}H${k.x}` })); } + for (const k of p.kids) draw(k); }; draw(laid); return { w: maxX, h: maxY }; @@ -243,6 +257,12 @@ export class TreeView extends Diagram { const n = p.n; const optional = n.min === 0; const many = n.max === null || n.max > 1; + if (n.gap) { + // Only the other version has this item: a faint blank keeps the rows lined up. + const g = s("g", { class: "tn gap", transform: `translate(${p.x} ${p.y})` }); + if (!n.gapHidden) g.appendChild(s("rect", { class: "gap", width: p.w, height: p.h, rx: 4 })); + return g; + } const g = s("g", { class: `tn ${n.kind}${optional ? " optional" : ""}${n.diff ? ` d-${n.diff}` : ""}`, transform: `translate(${p.x} ${p.y})` }); if (n.note) g.appendChild(s("text", { class: "note", x: 0, y: -7 }, n.note)); if (n.kind === "sequence" || n.kind === "choice" || n.kind === "all") { @@ -343,6 +363,85 @@ export class TreeView extends Diagram { .tn text.attr-line.d-added{fill:${v("--se-add")}} .tn text.attr-line.d-changed{fill:${v("--se-chg")}} .tn text.attr-line.d-removed{fill:${v("--se-del")};text-decoration:line-through} +.tn rect.gap{fill:none;stroke:${v("--se-border")};stroke-dasharray:2 4} `; } } + +// ---- side by side ---------------------------------------------------------- + +/** What identifies a node among its siblings in both versions. */ +const matchKey = (n: TNode) => (n.kind === "element" || n.kind === "group" ? `${n.kind} ${n.label}` : n.kind === "any" ? "any" : `${n.kind} ${n.note ?? ""}`); +const isCompositor = (n: TNode) => n.kind === "sequence" || n.kind === "choice" || n.kind === "all"; + +/** Line a node's attribute list up with the other version's, marking what differs. */ +function markAttrLines(a: string[], b: string[]): [string[], string[]] { + const name = (l: string) => l.split(" : ")[0].replace(/ \?$/, ""); + const ma = new Map(a.map((l) => [name(l), l])); + const mb = new Map(b.map((l) => [name(l), l])); + const side = (lines: string[], other: Map, gone: Mark) => + lines.map((l) => { + const o = other.get(name(l)); + return o === undefined ? markLine(gone, l) : o !== l ? markLine("changed", l) : l; + }); + return [side(a, mb, "removed"), side(b, ma, "added")]; +} + +function sameNode(a: TNode, b: TNode): boolean { + return a.typeName === b.typeName && a.min === b.min && a.max === b.max && a.kind === b.kind && (a.lines ?? []).join("\n") === (b.lines ?? []).join("\n"); +} + +/** Pair the children of two versions of a node: matching items share a row, the rest get a blank on the other side. */ +function pairKids(a: TNode[], b: TNode[]): [TNode | undefined, TNode | undefined][] { + const ops = diffSeq(a, b, (x, y) => matchKey(x) === matchKey(y)); + const out: [TNode | undefined, TNode | undefined][] = []; + let dels: TNode[] = []; + let ins: TNode[] = []; + const flush = () => { + // A compositor that changed kind (sequence → choice) still lines up with the old one. + while (dels.length && ins.length && isCompositor(dels[0]) && isCompositor(ins[0]) && dels[0].note === ins[0].note) out.push([dels.shift(), ins.shift()]); + for (const x of dels) out.push([x, undefined]); + for (const y of ins) out.push([undefined, y]); + dels = []; + ins = []; + }; + for (const o of ops) { + if (o.t === "eq") { + flush(); + out.push([a[o.a!], b[o.b!]]); + } else if (o.t === "del") dels.push(a[o.a!]); + else ins.push(b[o.b!]); + } + flush(); + return out; +} + +/** + * The content trees of two versions, made to line up row by row: matching + * items sit at the same place, an item only one version has leaves a blank on + * the other side, and what differs is marked (+, −, ~). Keys are shared, so + * opening a node opens it on both sides. + */ +export function pairTrees(a: TNode | undefined, b: TNode | undefined, key = "r", inGap = false): [TNode, TNode] { + let kids: [TNode, TNode][] | null = null; + const children = () => (kids ??= pairKids(a?.expandable ? a.children() : [], b?.expandable ? b.children() : []).map(([x, y], i) => pairTrees(x, y, `${key}.${i}`, !a || !b))); + const expandable = !!(a?.expandable || b?.expandable); + let la: string[] | undefined = a?.lines; + let lb: string[] | undefined = b?.lines; + if (a?.kind === "attrs" && b?.kind === "attrs") [la, lb] = markAttrLines(a.lines ?? [], b.lines ?? []); + const diffA: Mark | undefined = !b ? "removed" : a && !sameNode(a, b) ? "changed" : undefined; + const diffB: Mark | undefined = !a ? "added" : b && !sameNode(a, b) ? "changed" : undefined; + const one = (n: TNode | undefined, lines: string[] | undefined, diff: Mark | undefined, other: TNode): TNode => + n + ? { ...n, key, lines, diff: n.kind === "attrs" ? undefined : diff, expandable, children: () => children().map((p) => p[n === a ? 0 : 1]) } + : { ...other, key, gap: true, gapHidden: inGap, diff: undefined, note: undefined, lines: other.kind === "attrs" ? lines : other.lines, expandable, children: () => children().map((p) => p[other === a ? 1 : 0]) }; + const left = one(a, la, diffA, b!); + const right = one(b, lb, diffB, a!); + // The same room on both sides. + const sa = ownSize(left.gap ? { ...left, gap: false } : left); + const sb = ownSize(right.gap ? { ...right, gap: false } : right); + const align = { w: Math.max(sa.w, sb.w), h: Math.max(sa.h, sb.h), note: !!(a?.note || b?.note), occ: [a, b].some((n) => n && (n.min !== 1 || n.max !== 1)) }; + left.align = align; + right.align = align; + return [left, right]; +} diff --git a/web/src/views/compare.ts b/web/src/views/compare.ts index 3c7a64b..b146bdb 100644 --- a/web/src/views/compare.ts +++ b/web/src/views/compare.ts @@ -6,6 +6,9 @@ import { MARKER, STATUS_LABEL, changeLabel, changeMark, type Change, type Compar import { diffReport } from "../loader"; import { KIND_LABEL, KIND_SLUG, docsText, isId, occursText, type Id, type Kind, type Model } from "../model"; import { section } from "./common"; +import { diagramPanel } from "./diagramPanel"; +import { componentPage } from "./pages"; +import { diagramFocus, sbsDiagramPanel, sbsSourcePanel, sbsTreePanel, treeFocus, versionCtx, type Which } from "./sideBySide"; const KINDS: Kind[] = ["complexType", "simpleType", "element", "attribute", "group", "attributeGroup"]; @@ -26,7 +29,7 @@ export function diffHref(ctx: AppCtx, c: ComponentDiff): string { return ctx.hrefRoute({ view: "diffc", query: `${KIND_SLUG[c.kind]}/${qnameOf(c)}` }); } -function diffLink(ctx: AppCtx, c: ComponentDiff, text?: string): HTMLElement { +export function diffLink(ctx: AppCtx, c: ComponentDiff, text?: string): HTMLElement { return h("a", { class: text ? "" : `mono d-${c.status}-text`, href: diffHref(ctx, c), onClick: (ev: Event) => { const me = ev as MouseEvent; if (me.metaKey || me.ctrlKey || me.shiftKey) return; @@ -297,14 +300,78 @@ function changeRow(c: Change): HTMLElement { return h("tr", { class: `d-row d-${m}` }, h("td", { class: "mk" }, mark(m)), h("td", null, changeLabel(c.kind)), h("td", { class: "mono" }, c.path ? h("span", { class: "faint" }, c.path + " › ") : null, c.subject ?? ""), h("td", null, values), h("td", null, impactBadge(c.impact) ?? h("span", { class: "faint small" }, "non-breaking"))); } -/** One changed, added or removed component with both versions side by side. */ +const VIEWS = ["changes", "sbs", "old", "new"] as const; +type CompareView = (typeof VIEWS)[number]; + +/** Changes / Side by side / Old / New, kept in the link (`v=`). */ +function viewSwitch(ctx: AppCtx, cmp: Compare, c: ComponentDiff, v: CompareView): HTMLElement { + const query = `${KIND_SLUG[c.kind]}/${qnameOf(c)}`; + const labels: Record = { + changes: ["Changes", "table", "What changed, highlighted on the later version"], + sbs: ["Side by side", "columns", "Both versions next to each other: diagram, content tree and XSD source"], + old: ["Old", "history", `The whole page as it was in ${cmp.oldTitle}`], + new: ["New", "file", `The whole page as it is in ${cmp.newTitle}`], + }; + return h( + "div", + { class: "view-switch", role: "group", "aria-label": "Compare view" }, + h("div", { class: "btn-group" }, VIEWS.map((k) => { + const [label, ic, title] = labels[k]; + const missing = (k === "old" && !c.old) || (k === "new" && !c.new); + return button(label, () => ctx.goRoute({ view: "diffc", query, params: k === "changes" ? {} : { v: k } }), { cls: "small", icon: ic, pressed: v === k, title: missing ? `${title} (it is not in that version: shows the other one)` : title }); + })), + h("span", { class: "faint small vs-titles" }, h("span", { class: "d-removed-text" }, "Old"), ` = ${cmp.oldTitle} · `, h("span", { class: "d-added-text" }, "New"), ` = ${cmp.newTitle}`) + ); +} + +/** One version's whole normal page of the component; the other version's when it is not in this one. */ +function versionView(ctx: AppCtx, cmp: Compare, c: ComponentDiff, want: Which): HTMLElement[] { + const side = (w: Which) => (w === "old" ? c.old : c.new); + const which: Which = side(want) ? want : want === "old" ? "new" : "old"; + const id = side(which)!.id; + const title = (w: Which) => (w === "old" ? cmp.oldTitle : cmp.newTitle); + const vctx = versionCtx(ctx, cmp, which, { id, route: ctx.route }); + const note = + which !== want + ? h("div", { class: `diff-banner d-${c.status}` }, mark(c.status), " ", h("span", null, h("code", null, qnameOf(c)), ` is not in ${title(want)}. This is the page of ${title(which)}.`)) + : h("div", { class: `diff-banner side-${which}` }, h("span", null, "The page of ", h("strong", null, title(which)), which === "old" ? ", the earlier version. Its links lead to that version's pages." : ", the later version.")); + return [note, componentPage(vctx, id)]; +} + +/** The diagram (or content tree) and the XSD source of both versions next to each other. */ +function sbsView(ctx: AppCtx, cmp: Compare, c: ComponentDiff): HTMLElement[] { + const query = `${KIND_SLUG[c.kind]}/${qnameOf(c)}`; + const fo = diagramFocus(cmp.old, c.old?.id); + const fn = diagramFocus(cmp.cur, c.new?.id); + const to = treeFocus(cmp.old, c.old?.id); + const tn = treeFocus(cmp.cur, c.new?.id); + const hasDiagram = fo !== undefined || fn !== undefined; + const hasTree = to !== undefined || tn !== undefined; + const out: HTMLElement[] = []; + if (hasDiagram || hasTree) { + const tab = ctx.route.tab === "tree" || !hasDiagram ? "tree" : "diagram"; + const go = (t: string) => ctx.goRoute({ view: "diffc", query, tab: t, params: { v: "sbs", ...(ctx.route.params.sync ? { sync: ctx.route.params.sync } : {}) } }); + const tabs = h("div", { class: "btn-group", style: { marginLeft: "auto" } }, hasDiagram ? button("Diagram", () => go("diagram"), { icon: "diagram", cls: "small", pressed: tab === "diagram" }) : null, hasTree ? button("Content tree", () => go("tree"), { icon: "tree", cls: "small", pressed: tab === "tree" }) : null); + out.push(section(tab === "diagram" ? "Diagram" : "Content tree", { tools: [tabs] })); + out.push(tab === "diagram" ? sbsDiagramPanel(ctx, cmp, fo, fn) : sbsTreePanel(ctx, cmp, to, tn)); + } + out.push(section("XSD source"), sbsSourcePanel(ctx, cmp, c)); + return out; +} + +/** One changed, added or removed component: what changed, both versions side by side, or either version's page. */ export function diffComponentPage(ctx: AppCtx, cmp: Compare, c: ComponentDiff): HTMLElement { const page = h("div", { class: "diff-page" }); + const v: CompareView = (VIEWS as readonly string[]).includes(ctx.route.params.v ?? "") ? (ctx.route.params.v as CompareView) : "changes"; const back = h("a", { href: ctx.hrefRoute({ view: "diff" }), onClick: (ev: Event) => { ev.preventDefault(); ctx.goRoute({ view: "diff" }); } }, "All changes"); page.appendChild(h("div", { class: "crumbs" }, back, h("span", { class: "sep" }, "›"), h("span", null, qnameOf(c)))); + if (v === "old" || v === "new") { + page.append(viewSwitch(ctx, cmp, c, v), ...versionView(ctx, cmp, c, v)); + return page; + } const open = c.new ? button(`Open in ${cmp.newTitle}`, () => ctx.go(c.new!.id), { icon: "external", cls: "small" }) : null; page.appendChild( h( @@ -318,11 +385,18 @@ export function diffComponentPage(ctx: AppCtx, cmp: Compare, c: ComponentDiff): open) ) ); + page.appendChild(viewSwitch(ctx, cmp, c, v)); + if (v === "sbs") { + page.append(...sbsView(ctx, cmp, c)); + return page; + } const ch = c.changes ?? []; if (ch.length) { const sorted = [...ch].sort((a, b) => (a.impact === b.impact ? 0 : a.impact === "breaking" ? -1 : b.impact === "breaking" ? 1 : a.impact === "info" ? 1 : -1)); page.append(section("Changes", { count: ch.length }), h("table", { class: "ptable change-table" }, h("thead", null, h("tr", null, h("th", null, h("span", { class: "sr-only" }, "Kind")), h("th", null, "Change"), h("th", null, "Where"), h("th", null, "Old → new"), h("th", null, "Impact"))), h("tbody", null, sorted.map(changeRow)))); } + const fn = diagramFocus(cmp.cur, c.new?.id); + if (fn !== undefined) page.append(section("Diagram of the new version, changes marked"), diagramPanel(ctx, fn), h("p", { class: "faint hint" }, "Removed lines are listed struck through in their box. To see each version's own diagram, choose ", h("strong", null, "Side by side"), ".")); const labels: [string, string] = [cmp.oldTitle, cmp.newTitle]; const o = c.old ? c.old.id : undefined; const n = c.new ? c.new.id : undefined; diff --git a/web/src/views/diagramPanel.ts b/web/src/views/diagramPanel.ts index 1bd581e..fe6ccf0 100644 --- a/web/src/views/diagramPanel.ts +++ b/web/src/views/diagramPanel.ts @@ -74,7 +74,7 @@ const zoomButtons = (d: Diagram) => h("div", { class: "btn-group" }, button("", () => d.zoomBy(1.25), { cls: "small icon-only", icon: "plus", title: "Zoom in" }), button("", () => d.zoomBy(0.8), { cls: "small icon-only", icon: "minus", title: "Zoom out" }), button("", () => d.fit(), { cls: "small icon-only", icon: "fit", title: "Fit" })); const idsParam = (set: Set) => [...set].join(" "); -const parseIds = (s: string | undefined, max: number) => new Set((s ?? "").split(" ").map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < max)); +const parseIds = (s: string | undefined, max: number) => new Set((s ?? "").split(" ").filter(Boolean).map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < max)); /** The class diagram of a complex type and its neighbours. */ export function diagramPanel(ctx: AppCtx, focus: Id, opts: { tall?: boolean } = {}): HTMLElement { diff --git a/web/src/views/dialogs.ts b/web/src/views/dialogs.ts index 55925c3..26a6e56 100644 --- a/web/src/views/dialogs.ts +++ b/web/src/views/dialogs.ts @@ -99,6 +99,13 @@ export function openHelp(host: HTMLElement): void { false, [b("Overview map"), " on the start page draws the complex types of a namespace by derivation. Narrow it to a folder of files, allow more boxes, or add ", b("Containment"), " arrows. Big namespaces show the most connected types first."] ), + section( + "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."], + ["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."] + ), section( "Sharing and display", false, diff --git a/web/src/views/sideBySide.ts b/web/src/views/sideBySide.ts new file mode 100644 index 0000000..779a9e0 --- /dev/null +++ b/web/src/views/sideBySide.ts @@ -0,0 +1,679 @@ +// Compare mode, both versions next to each other: each version's own class +// diagram (laid out together so matching boxes sit at the same place), its +// content tree lined up row by row, and its XSD source with a line diff. +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 { 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"; + +export type Which = "old" | "new"; + +/** + * The context of one version: its model, links to its own pages and its own + * schema files. `here` keeps links to the component itself on the compare page. + */ +export function versionCtx(ctx: AppCtx, cmp: Compare, which: Which, here?: { id: Id; route: Route }): AppCtx { + const m = which === "old" ? cmp.old : cmp.cur; + const target = (id: Id, extra: Partial = {}): Partial => { + if (here && id === here.id) return { ...here.route, tab: extra.tab ?? here.route.tab, params: { v: here.route.params.v ?? which, ...(extra.params ?? {}) } }; + return { view: which === "old" ? "old" : "comp", id, tab: extra.tab, params: extra.params ?? {} }; + }; + return { + get m() { + return m; + }, + get route() { + return ctx.route; + }, + get prefs() { + return ctx.prefs; + }, + get embed() { + return ctx.embed; + }, + cmp: null, + go: (id, extra) => ctx.goRoute(target(id, extra)), + goRoute: (r) => ctx.goRoute(r), + href: (id, extra) => ctx.hrefRoute(target(id, extra)), + hrefRoute: (r) => ctx.hrefRoute(r), + setPrefs: (p) => ctx.setPrefs(p), + toast: (msg) => ctx.toast(msg), + setParams: (p) => ctx.setParams(p), + shareLink: () => ctx.shareLink(), + refresh: () => ctx.refresh(), + sourceText: (file) => (which === "old" ? cmp.oldSourceText(file) : ctx.sourceText(file)), + }; +} + +/** The complex type a component's class diagram is centred on. */ +export function diagramFocus(m: Model, id: Id | undefined): Id | undefined { + if (id === undefined) return undefined; + const c = m.comp(id); + if (c.kind === "complexType") return id; + if (c.kind === "element") { + const t = m.elementType(id); + return m.isComplex(t) ? t : undefined; + } + return undefined; +} + +/** The component a content tree starts from. */ +export function treeFocus(m: Model, id: Id | undefined): Id | undefined { + if (id === undefined) return undefined; + const c = m.comp(id); + if (c.kind === "complexType" || c.kind === "group") return id; + if (c.kind === "element" && m.isComplex(m.elementType(id))) return id; + return undefined; +} + +// ---- class diagrams ---------------------------------------------------------- + +/** Name the boxes and lines by qualified name, so both versions' graphs share keys. */ +function keyed(g: GraphSpec, m: Model): GraphSpec { + const map = new Map(); + for (const n of g.nodes) map.set(n.key, n.id !== undefined ? `${n.key[0]}:${m.qname(n.id) || m.label(n.id)}` : n.key); + const k = (key: string) => map.get(key) ?? key; + const seen = new Map(); + return { + omitted: g.omitted, + nodes: g.nodes.map((n) => ({ ...n, key: k(n.key), attrs: n.attrs.map((a) => ({ ...a })) })), + edges: g.edges.map((e) => { + const base = `${e.kind}|${k(e.from)}|${k(e.to)}|${e.label ?? ""}`; + const i = (seen.get(base) ?? 0) + 1; + seen.set(base, i); + return { ...e, key: i > 1 ? `${base}#${i}` : base, from: k(e.from), to: k(e.to) }; + }), + }; +} + +const prefix = (d: Mark | undefined, t: string) => (d ? `${MARKER[d]} ${t}` : t); + +/** Mark what differs between the two versions' graphs (on both), and the graph of both together for the layout. */ +function markGraphs(a: GraphSpec, b: GraphSpec, cmp: Compare): GraphSpec { + const na = new Map(a.nodes.map((n) => [n.key, n])); + const nb = new Map(b.nodes.map((n) => [n.key, n])); + const lineKey = (t: string) => t; + const sig = (x: { type?: string; mult?: string }) => `${x.type ?? ""}|${x.mult ?? ""}`; + const changedComp = (m: "old" | "new", id: Id | undefined) => { + if (id === undefined) return false; + const cd = m === "old" ? cmp.byOld.get(id) : cmp.byNew.get(id); + return cd?.status === "changed" && cd.impact !== "info"; + }; + for (const n of a.nodes) { + const o = nb.get(n.key); + if (!o) { + n.diff = "removed"; + n.name = prefix("removed", n.name); + continue; + } + const la = new Map(n.attrs.map((x) => [lineKey(x.text), x])); + const lb = new Map(o.attrs.map((x) => [lineKey(x.text), x])); + let differs = changedComp("old", n.id) || changedComp("new", o.id); + for (const x of n.attrs) { + const y = lb.get(lineKey(x.text)); + const d: Mark | undefined = !y ? "removed" : sig(x) !== sig(y) ? "changed" : undefined; + if (d) { + differs = true; + x.diff = d; + } + } + for (const y of o.attrs) { + const x = la.get(lineKey(y.text)); + const d: Mark | undefined = !x ? "added" : sig(x) !== sig(y) ? "changed" : undefined; + if (d) { + differs = true; + y.diff = d; + } + } + for (const x of n.attrs) if (x.diff) x.text = prefix(x.diff, x.text); + for (const y of o.attrs) if (y.diff) y.text = prefix(y.diff, y.text); + if (differs) { + n.diff = o.diff = "changed"; + n.name = prefix("changed", n.name); + o.name = prefix("changed", o.name); + } + } + for (const n of b.nodes) + if (!na.has(n.key)) { + n.diff = "added"; + n.name = prefix("added", n.name); + } + const ea = new Map(a.edges.map((e) => [e.key, e])); + const eb = new Map(b.edges.map((e) => [e.key, e])); + for (const e of a.edges) { + const o = eb.get(e.key); + const d: Mark | undefined = !o ? "removed" : (o.mult ?? "") !== (e.mult ?? "") ? "changed" : undefined; + if (d) e.diff = d; + if (o && d) o.diff = d; + } + for (const e of b.edges) if (!ea.has(e.key)) e.diff = "added"; + // A line drawn from the using side (no multiplicity on it): the diff knows whether the child changed. + for (const e of b.edges) { + const from = nb.get(e.from)?.id; + if (e.diff || e.kind !== "comp" || e.id === undefined || from === undefined) continue; + if (cmp.childMarks(from).children.get(cmp.cur.comp(e.id).name ?? "") !== "changed") continue; + e.diff = "changed"; + const o = ea.get(e.key); + if (o) o.diff = "changed"; + } + for (const e of [...a.edges, ...b.edges]) if (e.diff && e.label) e.label = prefix(e.diff, e.label); + // Both together: a box gets the lines of both versions, so it has room for either. + const nodes = b.nodes.map((n) => { + const o = na.get(n.key); + if (!o) return n; + const have = new Set(n.attrs.map((x) => x.text)); + return { ...n, attrs: [...n.attrs, ...o.attrs.filter((x) => !have.has(x.text))], hiddenAttrs: Math.max(n.hiddenAttrs, o.hiddenAttrs) }; + }); + for (const n of a.nodes) if (!nb.has(n.key)) nodes.push(n); + const edges = [...b.edges, ...a.edges.filter((e) => !eb.has(e.key))]; + return { nodes, edges, omitted: Math.max(a.omitted, b.omitted) }; +} + +const empty: GraphSpec = { nodes: [], edges: [], omitted: 0 }; + +interface Pane { + wrap: HTMLElement; + which: Which; + title: string; + note: HTMLElement; +} + +function pane(cls: string, which: Which, title: string, missing: boolean): Pane { + const note = h("div", { class: "sbs-missing", hidden: !missing }, `Not in ${title}`); + const wrap = h("div", { class: `diagram sbs-pane ${cls} side-${which}` }, note); + return { wrap, which, title, note }; +} + +const paneHead = (which: Which, title: string) => h("span", { class: `sbs-label side-${which}` }, which === "old" ? "Old" : "New", h("span", { class: "faint" }, ` · ${title}`)); + +/** The link toggle shared by both side-by-side panels. */ +function syncButton(ctx: AppCtx, get: () => boolean, set: (v: boolean) => void): HTMLButtonElement { + const b = button("Linked", () => { + set(!get()); + b.setAttribute("aria-pressed", String(get())); + ctx.setParams({ sync: get() ? undefined : "0" }); + }, { cls: "small", icon: "link", pressed: get(), title: "Pan and zoom both sides together" }); + return b; +} + +const zoomBoth = (ds: Diagram[], sync: () => boolean) => + h( + "div", + { class: "btn-group" }, + button("", () => ds.forEach((d, i) => (i === 0 || !sync() ? d.zoomBy(1.25) : null)), { cls: "small icon-only", icon: "plus", title: "Zoom in" }), + button("", () => ds.forEach((d, i) => (i === 0 || !sync() ? d.zoomBy(0.8) : null)), { cls: "small icon-only", icon: "minus", title: "Zoom out" }), + button("", () => ds.forEach((d) => d.fit()), { cls: "small icon-only", icon: "fit", title: "Fit" }) + ); + +/** Move the other diagram along with a pan or zoom, while linked. */ +function link2(a: Diagram, b: Diagram, sync: () => boolean): { onA: (v: View) => void; onB: (v: View) => void } { + return { onA: (v) => sync() && b.setView(v), onB: (v) => sync() && a.setView(v) }; +} + +/** The old and the new class diagram next to each other, laid out together. */ +export function sbsDiagramPanel(ctx: AppCtx, cmp: Compare, oldFocus: Id | undefined, newFocus: Id | undefined): HTMLElement { + const P = ctx.route.params; + const p = ctx.prefs; + const flag = (k: string, def: boolean) => (P[k] === undefined ? def : P[k] === "1"); + const focusAny = (newFocus ?? oldFocus)!; + const base = defaultNeighborhood(focusAny); + const o = { + depth: (P.depth === "2" ? 2 : P.depth === "1" ? 1 : p.depth) as 1 | 2, + inherited: flag("inh", false), + incoming: flag("inc", p.incoming), + derived: flag("sub", base.derived), + enums: flag("enum", p.enums), + }; + // Opened boxes are kept by qualified name, so they open on both sides. + const expanded = new Set((P.xq ?? "").split(" ").filter(Boolean)); + const openAttrs = new Set((P.openq ?? "").split(" ").filter(Boolean)); + let direction: "DOWN" | "RIGHT" = P.dir === "right" ? "RIGHT" : P.dir === "down" ? "DOWN" : p.direction; + let sync = P.sync !== "0"; + const bit = (v: boolean) => (v ? "1" : "0"); + const save = () => ctx.setParams({ depth: String(o.depth), dir: direction.toLowerCase(), inh: bit(o.inherited), inc: bit(o.incoming), sub: bit(o.derived), enum: bit(o.enums), xq: [...expanded].join(" ") || undefined, openq: [...openAttrs].join(" ") || undefined }); + const ids = (m: Model, names: Set) => new Set([...names].map((q) => m.find("ct", q)).filter((x): x is Id => x !== undefined)); + const optsFor = (m: Model, focus: Id): NeighborhoodOptions => ({ ...defaultNeighborhood(focus), ...o, expanded: ids(m, expanded), openAttrs: ids(m, openAttrs) }); + + const left = pane("", "old", cmp.oldTitle, oldFocus === undefined); + const right = pane("", "new", cmp.newTitle, newFocus === undefined); + const statusOf = (pn: Pane) => { + const t = h("span"); + pn.wrap.append(h("div", { class: "status" }, t)); + return t; + }; + const sides = [left, right].map((pn) => { + const which = pn.which; + const m = which === "old" ? cmp.old : cmp.cur; + const focus = which === "old" ? oldFocus : newFocus; + const vctx = versionCtx(ctx, cmp, which); + const status = statusOf(pn); + const toggleSet = (set: Set, id: Id) => { + const q = m.qname(id); + if (!q) return; + if (set.has(q)) set.delete(q); + else set.add(q); + run(true); + }; + const d = new Diagram(pn.wrap, { + onNavigate: (id) => vctx.go(id), + onExpand: (id) => (m.comp(id).kind === "complexType" ? toggleSet(expanded, id) : vctx.go(id)), + nodeMenu: (id) => boxActions(vctx, id, id === focus || m.comp(id).kind !== "complexType" || !m.qname(id) ? undefined : { expanded: expanded.has(m.qname(id)), toggle: () => toggleSet(expanded, id) }), + onToggleAttrs: (id) => toggleSet(openAttrs, id), + onStatus: (t) => (status.textContent = t), + onTransform: (v) => (which === "old" ? links.onA(v) : links.onB(v)), + }); + let graph: GraphSpec | null = null; + const { exportBtn, exportMenu } = exportControls(vctx, d, `${focus !== undefined ? m.label(focus) : "diagram"}-${pn.title}`, () => (graph ? { plantuml: toPlantUml(graph), mermaid: toMermaid(graph) } : null), pn.wrap); + pn.wrap.prepend(h("div", { class: "dtools" }, paneHead(which, pn.title), h("span", { class: "spacer" }), exportBtn), exportMenu); + refitOnResize(pn.wrap, d); + return { d, m, focus, setGraph: (g: GraphSpec) => (graph = g) }; + }); + const links = link2(sides[0].d, sides[1].d, () => sync); + const overlay = h("div", { class: "overlay" }, "Laying out…"); + const wrap = h("div", { class: "sbs-panel" }); + let started = false; + const run = (keepView = false) => { + if (!started) return; + wrap.appendChild(overlay); + save(); + const [a, b] = sides.map((s) => (s.focus !== undefined ? keyed(buildNeighborhood(s.m, optsFor(s.m, s.focus)), s.m) : { ...empty })); + const both = markGraphs(a, b, cmp); + const shared = layoutGraph(both, { direction }); + sides[0].setGraph(a); + sides[1].setGraph(b); + void Promise.all([sides[0].d.render(a, { direction, keepView, shared }), sides[1].d.render(b, { direction, keepView, shared })]).finally(() => { + overlay.remove(); + if (sync && keepView) sides[1].d.setView(sides[0].d.view); + }); + }; + const toggle = (label: string, get: () => boolean, set: (v: boolean) => void, title: string) => { + const b = button(label, () => { + set(!get()); + b.setAttribute("aria-pressed", String(get())); + run(true); + }, { cls: "small", pressed: get(), title }); + return b; + }; + const depthBtn = button(`Depth ${o.depth}`, () => { + o.depth = o.depth === 1 ? 2 : 1; + depthBtn.querySelector("span")!.textContent = `Depth ${o.depth}`; + run(false); + }, { cls: "small", title: "How many steps away from this type to show" }); + const dirBtn = button("", () => { + direction = direction === "DOWN" ? "RIGHT" : "DOWN"; + run(false); + }, { cls: "small icon-only", icon: "tree", title: "Flip the layout direction" }); + const tools = h( + "div", + { class: "sbs-tools" }, + depthBtn, + toggle("Inherited", () => o.inherited, (v) => (o.inherited = v), "List inherited attributes and child elements in this type's box"), + toggle("Used by", () => o.incoming, (v) => (o.incoming = v), "Show the types and elements that use this type"), + toggle("Derived", () => o.derived, (v) => (o.derived = v), "Show the types derived from this one"), + toggle("Enumerations", () => o.enums, (v) => (o.enums = v), "Show enumerated simple types as boxes"), + dirBtn, + h("span", { class: "spacer" }), + syncButton(ctx, () => sync, (v) => { + sync = v; + if (v) sides[1].d.setView(sides[0].d.view); + }), + zoomBoth(sides.map((s) => s.d), () => sync), + fullscreenButton(wrap) + ); + wrap.append(tools, h("div", { class: "sbs-grid" }, left.wrap, right.wrap), legend()); + whenVisible(wrap, () => { + started = true; + run(false); + }); + return wrap; +} + +function legend(): HTMLElement { + const item = (m: Mark, text: string) => h("span", { class: `sbs-key d-${m}` }, h("span", { class: `dmark d-${m}` }, MARKER[m]), " ", text); + return h("p", { class: "faint hint sbs-legend" }, item("removed", "only in the old version (left)"), item("added", "only in the new version (right)"), item("changed", "in both, but different"), h("span", null, "Double-click a box to add its neighbours on both sides.")); +} + +// ---- content trees ----------------------------------------------------------- + +/** The old and the new content tree next to each other, lined up row by row. */ +export function sbsTreePanel(ctx: AppCtx, cmp: Compare, oldFocus: Id | undefined, newFocus: Id | undefined): HTMLElement { + const P = ctx.route.params; + const open = new Set((P.tree ?? "").split(" ").filter(Boolean)); + let sync = P.sync !== "0"; + setTreeDiff(null); + const ra = oldFocus !== undefined ? rootNode(cmp.old, oldFocus) : undefined; + const rb = newFocus !== undefined ? rootNode(cmp.cur, newFocus) : undefined; + const [la, lb] = ra || rb ? pairTrees(ra, rb) : [undefined, undefined]; + const left = pane("tall", "old", cmp.oldTitle, oldFocus === undefined); + const right = pane("tall", "new", cmp.newTitle, newFocus === undefined); + const isCompositor = (n: TNode) => n.kind === "sequence" || n.kind === "choice" || n.kind === "all" || n.kind === "type"; + const toggleNode = (n: TNode, isOpen: boolean) => { + if (isCompositor(n)) { + if (isOpen) open.delete("-" + n.key); + else open.add("-" + n.key); + } else if (isOpen) open.add(n.key); + else open.delete(n.key); + run(true); + }; + const expandBelow = (n: TNode, levels: number) => { + if (levels <= 0 || !n.expandable) return; + if (n.kind === "element" || n.kind === "group") open.add(n.key); + for (const c of n.children()) expandBelow(c, c.kind === "element" ? levels - 1 : levels); + }; + const views = [left, right].map((pn) => { + const which = pn.which; + const m = which === "old" ? cmp.old : cmp.cur; + const vctx = versionCtx(ctx, cmp, which); + const status = h("span"); + pn.wrap.append(h("div", { class: "status" }, status)); + const t: TreeView = new TreeView(pn.wrap, { + onToggle: toggleNode, + actions: (n: TNode) => { + const a: { label: string; icon?: string; run: () => void }[] = []; + if (n.expandable && n.kind !== "type") { + const isOpen = isCompositor(n) ? !open.has("-" + n.key) : open.has(n.key); + a.push({ label: isOpen ? "Collapse (both sides)" : "Expand (both sides)", icon: isOpen ? "minus" : "plus", run: () => toggleNode(n, !isOpen) }); + } + if (n.id !== undefined) a.push({ label: n.kind === "group" ? "Open the group" : n.kind === "type" ? "Open the type" : "Open the element", icon: "external", run: () => vctx.go(n.id!) }); + if (n.typeId !== undefined && n.typeId !== n.id && !m.comp(n.typeId).builtin) a.push({ label: `Open the type ${m.label(n.typeId)}`, icon: "external", run: () => vctx.go(n.typeId!) }); + return a; + }, + onStatus: (s) => (status.textContent = s), + onTransform: (v) => (which === "old" ? links.onA(v) : links.onB(v)), + }); + const { exportBtn, exportMenu } = exportControls(vctx, t, `tree-${pn.title}`, null, pn.wrap); + pn.wrap.prepend(h("div", { class: "dtools" }, paneHead(which, pn.title), h("span", { class: "spacer" }), exportBtn), exportMenu); + refitOnResize(pn.wrap, t); + return t; + }); + const links = link2(views[0], views[1], () => sync); + const wrap = h("div", { class: "sbs-panel" }); + let started = false; + const run = (keepView = false) => { + if (!started) return; + if (la) views[0].show(la, open, { keepView }); + if (lb) views[1].show(lb, open, { keepView }); + if (sync) views[1].setView(views[0].view); + ctx.setParams({ tree: [...open].join(" ") || undefined }); + }; + const tools = h( + "div", + { class: "sbs-tools" }, + button("Expand 2 levels", () => { + if (la) expandBelow(la, 2); + if (lb) expandBelow(lb, 2); + run(false); + }, { cls: "small", title: "Open the child elements two levels deep, on both sides" }), + button("Collapse all", () => { + open.clear(); + run(false); + }, { cls: "small" }), + h("span", { class: "spacer" }), + syncButton(ctx, () => sync, (v) => { + sync = v; + if (v) views[1].setView(views[0].view); + }), + zoomBoth(views, () => sync), + fullscreenButton(wrap) + ); + const item = (m: Mark, text: string) => h("span", { class: `sbs-key d-${m}` }, h("span", { class: `dmark d-${m}` }, MARKER[m]), " ", text); + wrap.append(tools, h("div", { class: "sbs-grid" }, left.wrap, right.wrap), h("p", { class: "faint hint sbs-legend" }, item("removed", "only in the old version"), item("added", "only in the new version"), item("changed", "type or multiplicity changed"), h("span", null, "A dotted blank stands for an item the other side has. Opening a node opens it on both sides."))); + whenVisible(wrap, () => { + started = true; + run(false); + }); + return wrap; +} + +// ---- 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. */ +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); + let changedOnly = P.ctx !== "all"; + const body = h("div", { class: "codediff-body" }, h("p", { class: "faint small" }, "Loading…")); + const counts = h("span", { class: "faint small" }); + const sbsBtn = button("Side by side", () => setMode(false), { cls: "small", pressed: !unified }); + const uniBtn = button("Unified", () => setMode(true), { cls: "small", pressed: unified }); + const ctxBtn = button("Unchanged lines", () => { + changedOnly = !changedOnly; + ctxBtn.setAttribute("aria-pressed", String(!changedOnly)); + ctx.setParams({ ctx: changedOnly ? undefined : "all" }); + draw(); + }, { cls: "small", pressed: !changedOnly, title: "Show every line, not only the changes and a few lines around them" }); + const setMode = (u: boolean) => { + unified = u; + sbsBtn.setAttribute("aria-pressed", String(!u)); + uniBtn.setAttribute("aria-pressed", String(u)); + ctx.setParams({ code: u ? "unified" : "split" }); + draw(); + }; + 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) : ""); + + 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); + 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 load = async () => { + const [ra, rb] = await Promise.all([loadSide(ctx, cmp, "old", c.old?.id), loadSide(ctx, cmp, "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; + 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; + 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)); + }; + whenVisible(wrap, () => void load()); + 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. */ +export function oldVersionBanner(ctx: AppCtx, cmp: Compare, id: Id, diffLink: (c: ComponentDiff) => Child): HTMLElement { + const m = cmp.old; + const c = m.comp(id); + const cd = cmp.byOld.get(id); + const later = c.parent === undefined && c.name ? cmp.cur.find(KIND_SLUG[c.kind], m.qname(id)) : undefined; + return h( + "div", + { class: `diff-banner side-old${cd ? ` d-${cd.status}` : ""}` }, + h("span", { class: "dmark d-removed", "aria-hidden": "true" }, "◀"), + h("span", null, "You are looking at ", h("strong", null, cmp.oldTitle), ", the earlier version. Links lead to its pages."), + cd ? diffLink(cd) : c.parent === undefined ? h("span", { class: "faint small" }, "Unchanged.") : null, + later !== undefined ? button(`Open in ${cmp.newTitle}`, () => ctx.go(later), { icon: "external", cls: "small" }) : c.parent === undefined ? h("span", { class: "faint small" }, `Not in ${cmp.newTitle}.`) : null + ); +} +