From 1ef7304bb5176db39246be66ed189af8dcf3572a Mon Sep 17 00:00:00 2001 From: Joe Isaacs Date: Tue, 25 Aug 2026 10:30:05 +0000 Subject: [PATCH 1/2] Add compression diff viewer to Vortex Web Signed-off-by: Joe Isaacs --- vortex-web/README.md | 38 +++ vortex-web/package-lock.json | 64 ++++- vortex-web/package.json | 7 +- vortex-web/src/App.tsx | 252 +++++++++++++++--- .../src/components/compare/CompareView.tsx | 212 +++++++++++++++ .../src/components/compare/diff.test.ts | 106 ++++++++ vortex-web/src/components/compare/diff.ts | 236 ++++++++++++++++ .../components/explorer/FileDropScreen.tsx | 8 +- .../src/components/explorer/FileHeader.tsx | 31 ++- .../src/components/explorer/MainArea.tsx | 81 ++++-- vortex-web/src/main.tsx | 9 +- vortex-web/src/remoteFile.ts | 18 ++ vortex-web/src/routes.test.ts | 57 ++++ vortex-web/src/routes.ts | 37 +++ 14 files changed, 1074 insertions(+), 82 deletions(-) create mode 100644 vortex-web/src/components/compare/CompareView.tsx create mode 100644 vortex-web/src/components/compare/diff.test.ts create mode 100644 vortex-web/src/components/compare/diff.ts create mode 100644 vortex-web/src/remoteFile.ts create mode 100644 vortex-web/src/routes.test.ts create mode 100644 vortex-web/src/routes.ts diff --git a/vortex-web/README.md b/vortex-web/README.md index bbcb54c3f74..fdcc944e6fc 100644 --- a/vortex-web/README.md +++ b/vortex-web/README.md @@ -14,6 +14,44 @@ A web UI for exploring Vortex data files, built with React, TypeScript, Tailwind npm install ``` +## Comparing compression output + +Open the candidate `.vortex` file, select **Compare…** in the header, and choose the previous +version of the same file. The Compare view shows whole-file, data, and metadata byte deltas and +then aligns the layout and array-encoding trees to explain where those bytes changed. + +The tree comparison is semantic rather than a diff of rendered labels. Layout siblings are +matched by field name, chunk row range, or named transparent/auxiliary role. Array children use +their encoding-provided child names, with their stable child position as a fallback for serialized +trees that do not carry names. This makes an inserted field an addition instead of making every +following field look modified. + +To open a comparison directly, use the compare hash route with URL-encoded remote file URLs: + +```text +https://explorer.example/#/compare?baseline=https%3A%2F%2Fdata.example%2Fbefore.vortex&candidate=https%3A%2F%2Fdata.example%2Fafter.vortex +``` + +An individual file can also be opened directly: + +```text +https://explorer.example/#/file?url=https%3A%2F%2Fdata.example%2Foutput.vortex +``` + +URLs may be absolute HTTP(S) URLs or paths relative to the Explorer deployment. From the Compare +view, either file can be opened in the regular Details and Swimlane views or replaced with another +local file to recalculate the diff. + +The hash route does not require server-side routing, so the Explorer remains a static application: +the browser fetches both files and opens them in the existing Web Workers. Each file host must +permit browser access with CORS. Local files still need to be selected manually because browsers +do not allow a page to read arbitrary local paths. + +Use `compress-bench --ingest-jsonl ` for repeatable encode/decode timing and file-size +measurements. The Explorer comparison complements those aggregate measurements: it compares the +actual output files and attributes size changes to layout and encoding nodes. Compare files made +from identical logical input; the UI warns when row counts or schemas differ. + ### Full App (requires Rust + wasm-pack) ```bash diff --git a/vortex-web/package-lock.json b/vortex-web/package-lock.json index d6fc32aa2bb..4ba5714c30f 100644 --- a/vortex-web/package-lock.json +++ b/vortex-web/package-lock.json @@ -13,7 +13,8 @@ "apache-arrow": "^21.1.0", "d3-hierarchy": "^3.1.2", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-router-dom": "^6.30.6" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -33,6 +34,7 @@ "prettier": "^3.8.3", "storybook": "^10.3.5", "tailwindcss": "^4.2.4", + "tsx": "^4.21.0", "typescript": "~6.0.0", "typescript-eslint": "^8.59.0", "vite": "^8.0.10" @@ -1780,6 +1782,15 @@ "win32" ] }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", @@ -5194,6 +5205,38 @@ "dev": true, "license": "MIT" }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/recast": { "version": "0.23.21", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", @@ -5582,6 +5625,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/vortex-web/package.json b/vortex-web/package.json index 9f21562641d..150a9479092 100644 --- a/vortex-web/package.json +++ b/vortex-web/package.json @@ -8,12 +8,13 @@ "wasm:release": "wasm-pack build crate --release --target web --out-dir ../src/wasm/pkg", "dev": "npm run wasm && vite", "build": "npm run wasm:release && tsc -b && vite build", - "check": "npm run wasm && npm run lint && npm run typecheck", + "check": "npm run wasm && npm run lint && npm run typecheck && npm test", "preview": "vite preview", "format": "prettier --write 'src/**/*.{ts,tsx,css}' '.storybook/**/*.ts'", "format:check": "prettier --check 'src/**/*.{ts,tsx,css}' '.storybook/**/*.ts'", "lint": "eslint .", "lint:fix": "eslint . --fix", + "test": "node --import tsx --test src/routes.test.ts src/components/compare/diff.test.ts", "typecheck": "tsc -b --noEmit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" @@ -24,7 +25,8 @@ "apache-arrow": "^21.1.0", "d3-hierarchy": "^3.1.2", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-router-dom": "^6.30.6" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -46,6 +48,7 @@ "tailwindcss": "^4.2.4", "typescript": "~6.0.0", "typescript-eslint": "^8.59.0", + "tsx": "^4.21.0", "vite": "^8.0.10" } } diff --git a/vortex-web/src/App.tsx b/vortex-web/src/App.tsx index 8fd119fa8e0..ecd0fe83ba8 100644 --- a/vortex-web/src/App.tsx +++ b/vortex-web/src/App.tsx @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import type { VortexFileState, VortexFileContextValue } from './contexts/VortexFileContext'; import { VortexFileProvider } from './contexts/VortexFileContext'; import { SelectionProvider } from './contexts/SelectionContext'; @@ -12,52 +13,189 @@ import { FileHeader } from './components/explorer/FileHeader'; import { MainArea, type MainView } from './components/explorer/MainArea'; import { StatusBar } from './components/explorer/StatusBar'; import { VortexWorker } from './workers/VortexWorker'; +import { fetchRemoteFile } from './remoteFile'; +import { resolveDeepLink, viewForPathname } from './routes'; function App() { + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [fileState, setFileState] = useState(null); + const [baselineState, setBaselineState] = useState(null); + const [activeSide, setActiveSide] = useState<'baseline' | 'candidate'>('candidate'); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [isDragging, setIsDragging] = useState(false); - const [view, setView] = useState('details'); const dragCounter = useRef(0); const workerRef = useRef(null); + const baselineWorkerRef = useRef(null); + const view: MainView = viewForPathname(location.pathname); useEffect(() => { workerRef.current = new VortexWorker(); - return () => workerRef.current?.terminate(); + baselineWorkerRef.current = new VortexWorker(); + return () => { + workerRef.current?.terminate(); + baselineWorkerRef.current?.terminate(); + }; }, []); - const openFile = useCallback(async (file: File) => { - setError(null); - setLoading(true); - try { - const result = await workerRef.current!.openFile(file); - setFileState({ - fileName: file.name, - fileSize: file.size, - rowCount: result.rowCount, - version: result.fileStructure.version, - dtype: result.dtype, - layoutTree: result.layoutTree, - segments: result.segments, - fileStructure: result.fileStructure, - }); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setFileState(null); - } finally { - setLoading(false); + const stateFromResult = useCallback( + (file: File, result: Awaited>): VortexFileState => ({ + fileName: file.name, + fileSize: file.size, + rowCount: result.rowCount, + version: result.fileStructure.version, + dtype: result.dtype, + layoutTree: result.layoutTree, + segments: result.segments, + fileStructure: result.fileStructure, + }), + [], + ); + + const openFile = useCallback( + async (file: File) => { + setError(null); + setLoading(true); + try { + const result = await workerRef.current!.openFile(file); + setFileState(stateFromResult(file, result)); + setBaselineState(null); + setActiveSide('candidate'); + navigate('/'); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setFileState(null); + } finally { + setLoading(false); + } + }, + [navigate, stateFromResult], + ); + + const openBaseline = useCallback( + async (file: File) => { + setError(null); + setLoading(true); + try { + const result = await baselineWorkerRef.current!.openFile(file); + setBaselineState(stateFromResult(file, result)); + setActiveSide('candidate'); + navigate('/compare'); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setBaselineState(null); + } finally { + setLoading(false); + } + }, + [navigate, stateFromResult], + ); + + const openCandidate = useCallback( + async (file: File) => { + setError(null); + setLoading(true); + try { + const result = await workerRef.current!.openFile(file); + setFileState(stateFromResult(file, result)); + setActiveSide('candidate'); + navigate('/compare'); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, + [navigate, stateFromResult], + ); + + useEffect(() => { + const deepLink = resolveDeepLink(location.pathname, searchParams); + if (!deepLink) return; + if (deepLink.kind === 'error') { + setError(deepLink.message); + return; } - }, []); + + if (deepLink.kind === 'file') { + const controller = new AbortController(); + let active = true; + async function openDeepLinkedFile(fileSource: string) { + setError(null); + setLoading(true); + try { + const file = await fetchRemoteFile(fileSource, controller.signal); + if (!active) return; + const result = await workerRef.current!.openFile(file); + if (!active) return; + setFileState(stateFromResult(file, result)); + setBaselineState(null); + setActiveSide('candidate'); + } catch (e) { + if (!active || (e instanceof DOMException && e.name === 'AbortError')) return; + setError(e instanceof Error ? e.message : String(e)); + setFileState(null); + } finally { + if (active) setLoading(false); + } + } + void openDeepLinkedFile(deepLink.source); + return () => { + active = false; + controller.abort(); + }; + } + + const controller = new AbortController(); + let active = true; + async function openDeepLink(baselineSource: string, candidateSource: string) { + setError(null); + setLoading(true); + try { + const [baselineFile, candidateFile] = await Promise.all([ + fetchRemoteFile(baselineSource, controller.signal), + fetchRemoteFile(candidateSource, controller.signal), + ]); + if (!active) return; + const [baselineResult, candidateResult] = await Promise.all([ + baselineWorkerRef.current!.openFile(baselineFile), + workerRef.current!.openFile(candidateFile), + ]); + if (!active) return; + setBaselineState(stateFromResult(baselineFile, baselineResult)); + setFileState(stateFromResult(candidateFile, candidateResult)); + setActiveSide('candidate'); + } catch (e) { + if (!active || (e instanceof DOMException && e.name === 'AbortError')) return; + setError(e instanceof Error ? e.message : String(e)); + setBaselineState(null); + setFileState(null); + } finally { + if (active) setLoading(false); + } + } + void openDeepLink(deepLink.baselineSource, deepLink.candidateSource); + return () => { + active = false; + controller.abort(); + }; + }, [location.pathname, searchParams, stateFromResult]); + + const activeState = + activeSide === 'baseline' && baselineState !== null ? baselineState : fileState; + const activeWorkerRef = activeSide === 'baseline' ? baselineWorkerRef : workerRef; + const setActiveState = activeSide === 'baseline' ? setBaselineState : setFileState; const fetchEncodingTree = useCallback( - (nodeId: string) => workerRef.current!.fetchEncodingTree(nodeId), - [], + (nodeId: string) => activeWorkerRef.current!.fetchEncodingTree(nodeId), + [activeWorkerRef], ); const previewData = useCallback( - (nodeId: string, rowLimit: number) => workerRef.current!.previewData(nodeId, rowLimit), - [], + (nodeId: string, rowLimit: number) => activeWorkerRef.current!.previewData(nodeId, rowLimit), + [activeWorkerRef], ); /** Clone a tree, replacing the node at targetId with a modified version. */ @@ -80,10 +218,10 @@ function App() { const expandArrayTree = useCallback( async (nodeId: string) => { // Fetch the encoding tree (may be async). - const arrayTree = await workerRef.current!.fetchEncodingTree(nodeId); + const arrayTree = await activeWorkerRef.current!.fetchEncodingTree(nodeId); if (!arrayTree) return; - setFileState((prev) => { + setActiveState((prev) => { if (!prev) return prev; const node = findNodeById(prev.layoutTree, nodeId); if (!node || node.encoding !== 'vortex.flat') return prev; @@ -98,26 +236,26 @@ function App() { return { ...prev, layoutTree: newTree }; }); }, - [cloneTreeWithUpdate], + [activeWorkerRef, cloneTreeWithUpdate, setActiveState], ); const fetchArrayBuffer = useCallback( (layoutNodeId: string, arrayPath: string[], bufferIndex: number) => - workerRef.current!.fetchArrayBuffer(layoutNodeId, arrayPath, bufferIndex), - [], + activeWorkerRef.current!.fetchArrayBuffer(layoutNodeId, arrayPath, bufferIndex), + [activeWorkerRef], ); const previewArrayData = useCallback( (layoutNodeId: string, arrayPath: string[], rowLimit: number) => - workerRef.current!.previewArrayData(layoutNodeId, arrayPath, rowLimit), - [], + activeWorkerRef.current!.previewArrayData(layoutNodeId, arrayPath, rowLimit), + [activeWorkerRef], ); const fileContextValue = useMemo( () => - fileState + activeState ? { - ...fileState, + ...activeState, fetchEncodingTree, previewData, expandArrayTree, @@ -126,7 +264,7 @@ function App() { } : null, [ - fileState, + activeState, fetchEncodingTree, previewData, expandArrayTree, @@ -135,7 +273,26 @@ function App() { ], ); - const closeFile = useCallback(() => setFileState(null), []); + const closeFile = useCallback(() => { + setFileState(null); + setBaselineState(null); + navigate('/'); + }, [navigate]); + + const changeView = useCallback( + (nextView: MainView) => { + navigate(nextView === 'details' ? '/' : `/${nextView}`); + }, + [navigate], + ); + + const viewComparisonFile = useCallback( + (side: 'baseline' | 'candidate') => { + setActiveSide(side); + navigate('/'); + }, + [navigate], + ); const handleDragEnter = useCallback((e: DragEvent) => { e.preventDefault(); @@ -178,8 +335,23 @@ function App() { onDragLeave={handleDragLeave} onDrop={handleDrop} > - - + + {isDragging && (
diff --git a/vortex-web/src/components/compare/CompareView.tsx b/vortex-web/src/components/compare/CompareView.tsx new file mode 100644 index 00000000000..69aed3470d1 --- /dev/null +++ b/vortex-web/src/components/compare/CompareView.tsx @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { useMemo, useState } from 'react'; +import type { VortexFileState } from '../../contexts/VortexFileContext'; +import { diffLayoutTrees, flattenDiff, type DiffStatus } from './diff'; + +function bytes(value: number): string { + if (Math.abs(value) < 1024) return `${value} B`; + if (Math.abs(value) < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`; + return `${(value / 1024 ** 2).toFixed(2)} MiB`; +} + +function delta(before: number, after: number): string { + const difference = after - before; + const percent = before === 0 ? null : (difference / before) * 100; + return `${difference > 0 ? '+' : ''}${bytes(difference)}${percent === null ? '' : ` (${percent > 0 ? '+' : ''}${percent.toFixed(1)}%)`}`; +} + +const STATUS_CLASS: Record = { + unchanged: 'text-vortex-grey-dark', + changed: 'text-vortex-orange', + added: 'text-vortex-green', + removed: 'text-vortex-red', +}; + +function Metric({ label, before, after }: { label: string; before: number; after: number }) { + const improved = after <= before; + return ( +
+
{label}
+
+ {bytes(after)} +
+
+ {delta(before, after)} +
+
was {bytes(before)}
+
+ ); +} + +export function CompareView({ + baseline, + candidate, + onViewBaseline, + onViewCandidate, + onReplaceBaseline, + onReplaceCandidate, +}: { + baseline: VortexFileState; + candidate: VortexFileState; + onViewBaseline?: () => void; + onViewCandidate?: () => void; + onReplaceBaseline?: (file: File) => void; + onReplaceCandidate?: (file: File) => void; +}) { + const [changesOnly, setChangesOnly] = useState(true); + const root = useMemo( + () => diffLayoutTrees(baseline.layoutTree, candidate.layoutTree), + [baseline.layoutTree, candidate.layoutTree], + ); + const rows = useMemo(() => flattenDiff(root, changesOnly), [root, changesOnly]); + + return ( +
+
+
+

Compression comparison

+

+ {baseline.fileName} → {candidate.fileName} +

+
+ +
+ +
+ + +
+ +
+ + + +
+ + {baseline.rowCount !== candidate.rowCount || baseline.dtype !== candidate.dtype ? ( +
+ Inputs differ: baseline has {baseline.rowCount.toLocaleString()} rows and candidate has{' '} + {candidate.rowCount.toLocaleString()} rows + {baseline.dtype !== candidate.dtype ? '; their schemas also differ' : ''}. Size deltas may + not isolate a compression change. +
+ ) : null} + +
+
+ Node + Previous + New + Metadata Δ + Buffers Δ +
+ {rows.map((row) => ( +
+ + {row.status} + {row.label} + + + {row.beforeEncoding ?? '—'} + + + {row.afterEncoding ?? '—'} + + + {delta(row.beforeMetadataBytes, row.afterMetadataBytes)} + + + {delta(row.beforeBufferBytes, row.afterBufferBytes)} + +
+ ))} + {rows.length === 0 ? ( +
No changes
+ ) : null} +
+
+ ); +} + +function FileControls({ + label, + fileName, + onView, + onReplace, +}: { + label: string; + fileName: string; + onView?: () => void; + onReplace?: (file: File) => void; +}) { + return ( +
+
+
{label}
+
+ {fileName} +
+
+ + +
+ ); +} diff --git a/vortex-web/src/components/compare/diff.test.ts b/vortex-web/src/components/compare/diff.test.ts new file mode 100644 index 00000000000..a6b9994cd83 --- /dev/null +++ b/vortex-web/src/components/compare/diff.test.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { LayoutChildKind, LayoutTreeNode } from '../swimlane/types'; +import { diffLayoutTrees, flattenDiff } from './diff'; + +function node({ + id, + childType, + children = [], + encoding = 'vortex.flat', + metadataBytes = 0, + isArrayNode, +}: { + id: string; + childType: LayoutChildKind; + children?: LayoutTreeNode[]; + encoding?: string; + metadataBytes?: number; + isArrayNode?: boolean; +}): LayoutTreeNode { + return { + id, + encoding, + dtype: 'utf8', + rowCount: 100, + rowOffset: 0, + metadataBytes, + segmentIds: [], + childType, + children, + isArrayNode, + }; +} + +function field(name: string, options: Partial[0]> = {}): LayoutTreeNode { + return node({ + id: `root.${name}`, + childType: { kind: 'field', fieldName: name }, + ...options, + }); +} + +function root(children: LayoutTreeNode[]): LayoutTreeNode { + return node({ id: 'root', childType: { kind: 'root' }, children, encoding: 'vortex.struct' }); +} + +describe('semantic layout diff', () => { + it('matches fields by name when a field is inserted', () => { + const result = diffLayoutTrees( + root([field('a'), field('b')]), + root([field('a'), field('x'), field('b')]), + ); + + assert.deepEqual( + result.children.map(({ label, status }) => [label, status]), + [ + ['a', 'unchanged'], + ['b', 'unchanged'], + ['x', 'added'], + ], + ); + assert.deepEqual( + flattenDiff(result, true).map(({ label, status }) => [label, status]), + [ + ['root', 'changed'], + ['x', 'added'], + ], + ); + }); + + it('reports encoding and metadata changes on the matching field', () => { + const before = root([field('value', { encoding: 'vortex.dict', metadataBytes: 12 })]); + const after = root([field('value', { encoding: 'vortex.on_pair', metadataBytes: 20 })]); + const changed = diffLayoutTrees(before, after).children[0]; + + assert.deepEqual( + { + label: changed?.label, + status: changed?.status, + beforeEncoding: changed?.beforeEncoding, + afterEncoding: changed?.afterEncoding, + beforeMetadataBytes: changed?.beforeMetadataBytes, + afterMetadataBytes: changed?.afterMetadataBytes, + }, + { + label: 'value', + status: 'changed', + beforeEncoding: 'vortex.dict', + afterEncoding: 'vortex.on_pair', + beforeMetadataBytes: 12, + afterMetadataBytes: 20, + }, + ); + }); + + it('ignores expanded array nodes that exist only in the rendered tree', () => { + const expandedArrayNode = field('array child', { isArrayNode: true }); + const before = root([field('value')]); + const after = root([field('value'), expandedArrayNode]); + + assert.equal(diffLayoutTrees(before, after).status, 'unchanged'); + }); +}); diff --git a/vortex-web/src/components/compare/diff.ts b/vortex-web/src/components/compare/diff.ts new file mode 100644 index 00000000000..7b856940073 --- /dev/null +++ b/vortex-web/src/components/compare/diff.ts @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { ArrayEncodingNode, LayoutTreeNode } from '../swimlane/types'; + +export type DiffStatus = 'unchanged' | 'changed' | 'added' | 'removed'; + +export interface TreeDiffNode { + key: string; + label: string; + depth: number; + status: DiffStatus; + beforeEncoding?: string; + afterEncoding?: string; + beforeMetadataBytes: number; + afterMetadataBytes: number; + beforeBufferBytes: number; + afterBufferBytes: number; + children: TreeDiffNode[]; +} + +function layoutChildKey(node: LayoutTreeNode): string { + const child = node.childType; + switch (child.kind) { + case 'root': + return 'root'; + case 'field': + return `field:${child.fieldName}`; + case 'chunk': + return `chunk:${child.rowOffset}:${node.rowCount}`; + case 'transparent': + return `transparent:${child.name}`; + case 'auxiliary': + return `auxiliary:${child.name}`; + } +} + +function layoutLabel(node: LayoutTreeNode): string { + const child = node.childType; + switch (child.kind) { + case 'root': + return 'root'; + case 'field': + return child.fieldName; + case 'chunk': + return `chunk ${child.chunkIndex}`; + case 'transparent': + case 'auxiliary': + return child.name; + } +} + +function arrayBufferBytes(node: ArrayEncodingNode | undefined): number { + return node?.bufferLengths.reduce((sum, bytes) => sum + bytes, 0) ?? 0; +} + +function statusFor( + beforeEncoding: string | undefined, + afterEncoding: string | undefined, + beforeMetadataBytes: number, + afterMetadataBytes: number, + beforeBufferBytes: number, + afterBufferBytes: number, + children: TreeDiffNode[], +): DiffStatus { + if (!beforeEncoding) return 'added'; + if (!afterEncoding) return 'removed'; + if ( + beforeEncoding !== afterEncoding || + beforeMetadataBytes !== afterMetadataBytes || + beforeBufferBytes !== afterBufferBytes || + children.some((child) => child.status !== 'unchanged') + ) { + return 'changed'; + } + return 'unchanged'; +} + +/** + * Match sibling nodes by a domain key while retaining duplicate keys in source order. + * This is deliberately not positional: inserting a field must not make every later field + * appear changed. + */ +function matchChildren( + before: T[], + after: T[], + keyOf: (node: T, index: number) => string, +): Array<{ key: string; before?: T; after?: T }> { + const afterByKey = new Map(); + after.forEach((node, index) => { + const key = keyOf(node, index); + const matches = afterByKey.get(key) ?? []; + matches.push(node); + afterByKey.set(key, matches); + }); + + const pairs: Array<{ key: string; before?: T; after?: T }> = []; + before.forEach((node, index) => { + const key = keyOf(node, index); + const matches = afterByKey.get(key); + const matched = matches?.shift(); + pairs.push({ key, before: node, after: matched }); + if (matches?.length === 0) afterByKey.delete(key); + }); + after.forEach((node, index) => { + const key = keyOf(node, index); + const matches = afterByKey.get(key); + if (matches?.[0] === node) { + matches.shift(); + pairs.push({ key, after: node }); + if (matches.length === 0) afterByKey.delete(key); + } + }); + return pairs; +} + +function diffArrayNode( + before: ArrayEncodingNode | undefined, + after: ArrayEncodingNode | undefined, + key: string, + label: string, + depth: number, +): TreeDiffNode { + type NamedChild = { key: string; node: ArrayEncodingNode }; + const beforeChildren: NamedChild[] = + before?.children.map((node, index) => ({ + key: before.childNames[index] || `child:${index}`, + node, + })) ?? []; + const afterChildren: NamedChild[] = + after?.children.map((node, index) => ({ + key: after.childNames[index] || `child:${index}`, + node, + })) ?? []; + const pairs = matchChildren(beforeChildren, afterChildren, (child) => child.key); + const children = pairs.map((pair, index) => { + const childLabel = pair.key || `child ${index}`; + return diffArrayNode( + pair.before?.node, + pair.after?.node, + `${key}/array:${childLabel}`, + childLabel, + depth + 1, + ); + }); + const beforeMetadataBytes = before?.metadataBytes ?? 0; + const afterMetadataBytes = after?.metadataBytes ?? 0; + const beforeBufferBytes = arrayBufferBytes(before); + const afterBufferBytes = arrayBufferBytes(after); + const beforeEncoding = before?.encoding; + const afterEncoding = after?.encoding; + return { + key, + label, + depth, + status: statusFor( + beforeEncoding, + afterEncoding, + beforeMetadataBytes, + afterMetadataBytes, + beforeBufferBytes, + afterBufferBytes, + children, + ), + beforeEncoding, + afterEncoding, + beforeMetadataBytes, + afterMetadataBytes, + beforeBufferBytes, + afterBufferBytes, + children, + }; +} + +function diffLayoutNode( + before: LayoutTreeNode | undefined, + after: LayoutTreeNode | undefined, + key: string, + depth: number, +): TreeDiffNode { + // Expanded array nodes are a UI projection of `arrayEncodingTree`, not layout children. + // Excluding them prevents a file explored before comparison from appearing structurally + // different from an otherwise identical freshly opened file. + const beforeChildren = before?.children.filter((node) => !node.isArrayNode) ?? []; + const afterChildren = after?.children.filter((node) => !node.isArrayNode) ?? []; + const pairs = matchChildren(beforeChildren, afterChildren, (node) => layoutChildKey(node)); + const children = pairs.map((pair) => + diffLayoutNode(pair.before, pair.after, `${key}/${pair.key}`, depth + 1), + ); + + const beforeArray = before?.arrayEncodingTree; + const afterArray = after?.arrayEncodingTree; + if (beforeArray || afterArray) { + children.push(diffArrayNode(beforeArray, afterArray, `${key}/array`, 'array', depth + 1)); + } + + const beforeMetadataBytes = before?.metadataBytes ?? 0; + const afterMetadataBytes = after?.metadataBytes ?? 0; + const beforeEncoding = before?.encoding; + const afterEncoding = after?.encoding; + return { + key, + label: after ? layoutLabel(after) : before ? layoutLabel(before) : key, + depth, + status: statusFor( + beforeEncoding, + afterEncoding, + beforeMetadataBytes, + afterMetadataBytes, + 0, + 0, + children, + ), + beforeEncoding, + afterEncoding, + beforeMetadataBytes, + afterMetadataBytes, + beforeBufferBytes: 0, + afterBufferBytes: 0, + children, + }; +} + +export function diffLayoutTrees(before: LayoutTreeNode, after: LayoutTreeNode): TreeDiffNode { + return diffLayoutNode(before, after, 'root', 0); +} + +export function flattenDiff(root: TreeDiffNode, changesOnly: boolean): TreeDiffNode[] { + const rows: TreeDiffNode[] = []; + function visit(node: TreeDiffNode) { + if (!changesOnly || node.status !== 'unchanged') rows.push(node); + node.children.forEach(visit); + } + visit(root); + return rows; +} diff --git a/vortex-web/src/components/explorer/FileDropScreen.tsx b/vortex-web/src/components/explorer/FileDropScreen.tsx index 14db31e0c03..9bca0ee097d 100644 --- a/vortex-web/src/components/explorer/FileDropScreen.tsx +++ b/vortex-web/src/components/explorer/FileDropScreen.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, type DragEvent, type FormEvent } from 'react'; import { ThemePicker } from '../ThemePicker'; +import { fetchRemoteFile } from '../../remoteFile'; interface FileDropScreenProps { onFileLoaded: (file: File) => void; @@ -56,12 +57,7 @@ export function FileDropScreen({ onFileLoaded, loading, error }: FileDropScreenP setFetchingUrl(true); setUrlError(null); try { - const resp = await fetch(trimmed); - if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); - const blob = await resp.blob(); - const name = trimmed.split('/').pop() ?? 'remote.vortex'; - const file = new File([blob], name, { type: blob.type }); - onFileLoaded(file); + onFileLoaded(await fetchRemoteFile(trimmed)); } catch (err) { setUrlError(err instanceof Error ? err.message : String(err)); } finally { diff --git a/vortex-web/src/components/explorer/FileHeader.tsx b/vortex-web/src/components/explorer/FileHeader.tsx index 2a523dee315..ea2952e3d80 100644 --- a/vortex-web/src/components/explorer/FileHeader.tsx +++ b/vortex-web/src/components/explorer/FileHeader.tsx @@ -9,10 +9,21 @@ interface FileHeaderProps { onClose: () => void; view: MainView; onViewChange: (view: MainView) => void; + onCompareFile?: (file: File) => void; + comparisonName?: string; } -export function FileHeader({ onClose, view, onViewChange }: FileHeaderProps) { +export function FileHeader({ + onClose, + view, + onViewChange, + onCompareFile, + comparisonName, +}: FileHeaderProps) { const file = useVortexFile(); + const views: MainView[] = comparisonName + ? ['details', 'swimlane', 'compare'] + : ['details', 'swimlane']; return (
@@ -28,7 +39,7 @@ export function FileHeader({ onClose, view, onViewChange }: FileHeaderProps) {
{/* Primary view switch — sits with the global controls in the header. */}
- {(['details', 'swimlane'] as const).map((v) => ( + {views.map((v) => ( ))}
+ +