From bbe4aae9453b1c45be3fd6dee22daa8536617b28 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:41:07 -0400 Subject: [PATCH 1/2] Add readonly CSV table comparison (#67, #86) --- README.md | 11 ++ media/table-comparison.js | 89 ++++++++++ package.json | 10 ++ src/commands.ts | 2 + src/csv-table-diff.ts | 234 ++++++++++++++++++++++++++ src/table-comparison.ts | 204 +++++++++++++++++++++++ src/test/csv-table-diff.test.ts | 97 +++++++++++ src/test/edit-integrity-harness.ts | 27 +++ src/test/table-comparison.test.ts | 255 +++++++++++++++++++++++++++++ 9 files changed, 929 insertions(+) create mode 100644 media/table-comparison.js create mode 100644 src/csv-table-diff.ts create mode 100644 src/table-comparison.ts create mode 100644 src/test/csv-table-diff.test.ts create mode 100644 src/test/table-comparison.test.ts diff --git a/README.md b/README.md index ee0683c..293276d 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Open the Command Palette and search for: - `CSV: Toggle Extension On/Off` (`csv.toggleExtension`) - `CSV: Open Large File Preview (Read-only)` (`csv.openLargeFilePreview`) +- `CSV: Compare Files as Table` (`csv.compareFilesAsTable`) - `CSV: Toggle First Row as Header` (`csv.toggleHeader`) - `CSV: Toggle Serial Index Column` (`csv.toggleSerialIndex`) - `CSV: Change CSV Separator` (`csv.changeSeparator`) @@ -145,6 +146,16 @@ The preview reads the saved bytes in bounded chunks and displays one page at a t This preview currently supports **valid UTF-8 and host-local `file:` URIs**. It decodes saved bytes directly in the extension host instead of requesting VS Code's decoded TextDocument. A UTF-8 BOM is accepted; other encodings must use the normal editor's encoding workflow. The extension host can be remote; arbitrary remote filesystem providers are unsupported. Separators follow the per-file override or extension/default settings. Automatic separator detection requires an explicit override or a change to `csv.separatorMode`; otherwise the preview shows an actionable error. Normal editable CSV views retain their existing full-document behavior. +## Compare Files as Table + +Run **CSV: Compare Files as Table** from an active CSV/text editor and choose the modified file, or select two files in Explorer and use that context-menu command. With no active source file, the command asks for an original and a modified file. Both panes are read-only; edit a source in its normal editor and the comparison refreshes from VS Code's current document text, including unsaved edits and its selected encoding. + +The comparison aligns unchanged records around inserted/deleted records, highlights changed original cells in red and modified cells in green, and leaves a placeholder where the other side has no record or field. Both scroll axes stay synchronized, and paired rows share their height so quoted multiline values remain aligned. Use **Previous/Next Change** to find differences and **Previous/Next Page** to browse. The first record is displayed as data. It compares parsed values and field presence; quoting and line-ending formatting are not compared. + +There are no new settings. The existing `csv.maxFileSizeMB` limit applies. Independent hard limits are 10 MiB of saved bytes before opening a new input and 5 MiB of decoded text, plus 10,000 records, 100,000 cells, and 1,000 columns per input; individual values are limited to 64 Ki characters and records to 256 Ki characters. Providers that cannot report source size must already have the document open in VS Code; its decoded text is still checked. Larger or malformed inputs show an error without truncating data. Each page contains at most 200 aligned records, 20,000 body cells, and 512 Ki characters. Unique-row anchors and a fixed alignment-work budget keep computation bounded; if a large changed section cannot be aligned within that budget, a visible notice explains that it is paired by position. + +**Native diff limitation:** this command opens CSV's own table comparison. It does not replace VS Code's native Git/PR comparison. Stable custom-editor APIs do not expose a reliable original/modified URI pair for native comparisons made of two custom webviews, so the native highlighting/scrolling requests in [#67](https://github.com/jonaraphael/csv/issues/67) and [#86](https://github.com/jonaraphael/csv/issues/86) remain limited by that API. The command provides table comparison when the two input URIs are known explicitly. CSV's source stays in the extension-host/webview environment; the comparison does not upload data, write files, or persist cell values in global/workspace state. + ## Editing Modes and Shortcuts - Quick edit: diff --git a/media/table-comparison.js b/media/table-comparison.js new file mode 100644 index 0000000..9107e58 --- /dev/null +++ b/media/table-comparison.js @@ -0,0 +1,89 @@ +const vscode = acquireVsCodeApi(); +const original = document.getElementById('original'); +const modified = document.getElementById('modified'); +let page = 0; +let pendingFocus; +let alignFrame; +let synchronizing = false; +const expectedScroll = new WeakMap(); + +const alignRows = () => { + const left = Array.from(original.querySelectorAll('tbody tr')); + const right = Array.from(modified.querySelectorAll('tbody tr')); + [...left, ...right].forEach(row => { row.style.height = ''; }); + left.forEach((row, index) => { + if (!right[index]) return; + const height = Math.ceil(Math.max(row.getBoundingClientRect().height, right[index].getBoundingClientRect().height)); + row.style.height = right[index].style.height = `${height}px`; + }); + if (Number.isInteger(pendingFocus)) { + [original, modified].forEach(pane => { + const row = pane.querySelector(`[data-align="${pendingFocus}"]`); + if (row) { + row.classList.add('focused'); + const header = pane.querySelector('thead'); + pane.scrollTop = Math.max(0, row.offsetTop - (header?.getBoundingClientRect().height || 0)); + } + }); + pendingFocus = undefined; + } +}; +const queueAlignment = () => { + if (alignFrame) cancelAnimationFrame(alignFrame); + alignFrame = requestAnimationFrame(alignRows); +}; + +[[original, modified], [modified, original]].forEach(([source, target]) => { + source.addEventListener('scroll', () => { + if (synchronizing) return; + const expected = expectedScroll.get(source); + if (expected && Math.abs(source.scrollTop - expected.top) < 1 && Math.abs(source.scrollLeft - expected.left) < 1) { + expectedScroll.delete(source); + return; + } + expectedScroll.set(target, { top: source.scrollTop, left: source.scrollLeft }); + synchronizing = true; + target.scrollTop = source.scrollTop; + target.scrollLeft = source.scrollLeft; + synchronizing = false; + }, { passive: true }); +}); + +const buttons = ['previousPage', 'nextPage', 'previousChange', 'nextChange'].map(id => document.getElementById(id)); +const request = message => { + buttons.forEach(button => { button.disabled = true; }); + vscode.postMessage(message); +}; +buttons[0].addEventListener('click', () => request({ type: 'page', index: page - 1 })); +buttons[1].addEventListener('click', () => request({ type: 'page', index: page + 1 })); +buttons[2].addEventListener('click', () => request({ type: 'change', direction: -1 })); +buttons[3].addEventListener('click', () => request({ type: 'change', direction: 1 })); +window.addEventListener('message', event => { + const message = event.data; + if (message.type !== 'comparison') return; + original.innerHTML = message.originalHtml; + modified.innerHTML = message.modifiedHtml; + [original, modified].forEach(pane => { + const table = pane.querySelector('table'); + if (table) table.style.width = `${60 + Number(table.dataset.columns) * 180}px`; + pane.scrollTop = pane.scrollLeft = 0; + expectedScroll.delete(pane); + }); + page = message.page; + document.getElementById('status').textContent = message.status; + document.getElementById('error').textContent = message.error || ''; + document.getElementById('notice').textContent = message.notice || ''; + buttons[0].disabled = page <= 0; + buttons[1].disabled = page >= message.pageCount - 1; + buttons[2].disabled = !message.canPreviousChange; + buttons[3].disabled = !message.canNextChange; + pendingFocus = message.focusRecord; + queueAlignment(); +}); +if (typeof ResizeObserver === 'function') { + const observer = new ResizeObserver(queueAlignment); + observer.observe(original); + observer.observe(modified); +} +window.addEventListener('resize', queueAlignment); +request({ type: 'ready' }); diff --git a/package.json b/package.json index c11e0ee..337bac9 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "onLanguage:tsv", "onCommand:csv.toggleExtension", "onCommand:csv.openLargeFilePreview", + "onCommand:csv.compareFilesAsTable", "onCustomEditor:csv.largeFilePreview", "onCommand:csv.toggleHeader", "onCommand:csv.toggleSerialIndex", @@ -70,6 +71,10 @@ "command": "csv.openLargeFilePreview", "title": "CSV: Open Large File Preview (Read-only)" }, + { + "command": "csv.compareFilesAsTable", + "title": "CSV: Compare Files as Table" + }, { "command": "csv.toggleExtension", "title": "CSV: Toggle Extension On/Off" @@ -226,6 +231,11 @@ }, "menus": { "explorer/context": [ + { + "command": "csv.compareFilesAsTable", + "when": "resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i && config.csv.enabled", + "group": "navigation" + }, { "command": "csv.openLargeFilePreview", "when": "resourceScheme == file && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i && config.csv.enabled", diff --git a/src/commands.ts b/src/commands.ts index 2a25700..81bd723 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,6 +2,7 @@ import { getFonts } from 'font-list'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; import { openLargeFilePreview } from './large-file-preview'; +import { compareFilesAsTable } from './table-comparison'; function displaySeparator(separator: string): string { return separator === '\t' ? '\\t' : separator; @@ -23,6 +24,7 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref export function registerCsvCommands(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.commands.registerCommand('csv.openLargeFilePreview', openLargeFilePreview), + vscode.commands.registerCommand('csv.compareFilesAsTable', (resource?: vscode.Uri, selected?: vscode.Uri[]) => compareFilesAsTable(context, resource, selected)), vscode.commands.registerCommand('csv.toggleExtension', () => toggleBooleanConfig('enabled', true, 'CSV extension') ), diff --git a/src/csv-table-diff.ts b/src/csv-table-diff.ts new file mode 100644 index 0000000..641b1e5 --- /dev/null +++ b/src/csv-table-diff.ts @@ -0,0 +1,234 @@ +import Papa from 'papaparse'; + +export const comparisonLimits = { + sourceBytes: 10 * 1024 * 1024, + textBytes: 5 * 1024 * 1024, + rows: 10000, + cells: 100000, + columns: 1000, + cellChars: 64 * 1024, + recordChars: 256 * 1024, + alignmentCells: 1000000, + replacementCells: 1000000, + alignmentDepth: 20, + pageRows: 200, + pageCells: 20000, + pageChars: 512 * 1024 +}; + +export type ComparedRecord = { + original?: number; + modified?: number; + kind: 'equal' | 'changed' | 'added' | 'removed'; + changedColumns: number[]; +}; +export type CsvTableDiff = { + originalRows: string[][]; + modifiedRows: string[][]; + records: ComparedRecord[]; + approximate: boolean; + added: number; + removed: number; + changed: number; +}; + +export function parseComparisonRows(text: string, delimiter: string): string[][] { + if (Buffer.byteLength(text, 'utf8') > comparisonLimits.textBytes) { + throw new Error('Table comparison supports at most 5 MiB of decoded text per file. Use a text diff for larger files.'); + } + const parsed = Papa.parse(text, { delimiter, dynamicTyping: false }); + if (parsed.errors.some(error => error.type === 'Quotes')) { + throw new Error('The input contains malformed quoted CSV. Correct it before comparing as a table.'); + } + const rows = parsed.data; + if (/[\r\n]$/.test(text) && rows.length && rows[rows.length - 1].length === 1 && rows[rows.length - 1][0] === '') { + rows.pop(); // Papa's EOF marker is not an additional logical record. + } + if (rows.length > comparisonLimits.rows) { + throw new Error('Table comparison supports at most 10,000 records per file. Use a text diff for larger files.'); + } + let cells = 0; + for (const row of rows) { + cells += row.length; + if (row.length > comparisonLimits.columns || cells > comparisonLimits.cells) { + throw new Error('Table comparison supports at most 1,000 columns and 100,000 cells per file. No fields were truncated.'); + } + if (row.some(value => value.length > comparisonLimits.cellChars) || row.reduce((sum, value) => sum + value.length, 0) > comparisonLimits.recordChars) { + throw new Error('A value or record exceeds the table comparison display limit. No content was truncated.'); + } + } + return rows; +} + +export function compareCsvRows(originalRows: string[][], modifiedRows: string[][]): CsvTableDiff { + const original = originalRows.map(row => JSON.stringify(row)); + const modified = modifiedRows.map(row => JSON.stringify(row)); + const matches: Array<[number, number]> = []; + let budget = comparisonLimits.alignmentCells; + let approximate = false; + + const uniqueAnchors = (a0: number, a1: number, b0: number, b1: number): Array<[number, number]> => { + const indices = (values: string[], start: number, end: number) => { + const map = new Map(); + for (let index = start; index < end; index++) { + map.set(values[index], map.has(values[index]) ? -1 : index); + } + return map; + }; + const left = indices(original, a0, a1); + const right = indices(modified, b0, b1); + const candidates: Array<[number, number]> = []; + for (let index = a0; index < a1; index++) { + const other = right.get(original[index]); + if (left.get(original[index]) === index && other !== undefined && other >= 0) { + candidates.push([index, other]); + } + } + const tails: number[] = []; + const predecessors = Array(candidates.length).fill(-1); + for (let index = 0; index < candidates.length; index++) { + let low = 0, high = tails.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (candidates[tails[middle]][1] < candidates[index][1]) { low = middle + 1; } else { high = middle; } + } + predecessors[index] = low > 0 ? tails[low - 1] : -1; + tails[low] = index; + } + const anchors: Array<[number, number]> = []; + for (let index = tails.length ? tails[tails.length - 1] : -1; index >= 0; index = predecessors[index]) { + anchors.push(candidates[index]); + } + return anchors.reverse(); + }; + + const align = (a0: number, a1: number, b0: number, b1: number, depth: number): void => { + while (a0 < a1 && b0 < b1 && original[a0] === modified[b0]) { + matches.push([a0++, b0++]); + } + const suffix: Array<[number, number]> = []; + while (a0 < a1 && b0 < b1 && original[a1 - 1] === modified[b1 - 1]) { + suffix.push([--a1, --b1]); + } + if (a0 < a1 && b0 < b1) { + const height = a1 - a0, width = b1 - b0; + const cost = (height + 1) * (width + 1); + if (cost <= budget) { + budget -= cost; + const stride = width + 1; + const lengths = new Uint16Array(cost); + for (let a = height - 1; a >= 0; a--) { + for (let b = width - 1; b >= 0; b--) { + lengths[a * stride + b] = original[a0 + a] === modified[b0 + b] + ? lengths[(a + 1) * stride + b + 1] + 1 + : Math.max(lengths[(a + 1) * stride + b], lengths[a * stride + b + 1]); + } + } + let a = 0, b = 0; + while (a < height && b < width) { + if (original[a0 + a] === modified[b0 + b]) { matches.push([a0 + a++, b0 + b++]); } + else if (lengths[(a + 1) * stride + b] >= lengths[a * stride + b + 1]) { a++; } + else { b++; } + } + } else if (depth < comparisonLimits.alignmentDepth) { + const anchors = uniqueAnchors(a0, a1, b0, b1); + if (!anchors.length) { + approximate = true; + } else { + for (const [a, b] of anchors) { + align(a0, a, b0, b, depth + 1); + matches.push([a, b]); + a0 = a + 1; b0 = b + 1; + } + align(a0, a1, b0, b1, depth + 1); + } + } else { + approximate = true; + } + } + matches.push(...suffix.reverse()); + }; + align(0, original.length, 0, modified.length, 0); + + const result: CsvTableDiff = { originalRows, modifiedRows, records: [], approximate, added: 0, removed: 0, changed: 0 }; + const append = (a: number | undefined, b: number | undefined) => { + const before = a === undefined ? undefined : originalRows[a]; + const after = b === undefined ? undefined : modifiedRows[b]; + const changedColumns: number[] = []; + for (let col = 0; col < Math.max(before?.length ?? 0, after?.length ?? 0); col++) { + if (!before || !after || col >= before.length || col >= after.length || before[col] !== after[col]) { + changedColumns.push(col); + } + } + const kind = a === undefined ? 'added' : b === undefined ? 'removed' : changedColumns.length ? 'changed' : 'equal'; + result.records.push({ original: a, modified: b, kind, changedColumns }); + if (kind !== 'equal') { result[kind]++; } + }; + let replacementBudget = comparisonLimits.replacementCells; + const appendGap = (a0: number, a1: number, b0: number, b1: number) => { + const height = a1 - a0, width = b1 - b0; + const columns = Math.max(1, ...originalRows.slice(a0, a1).map(row => row.length), ...modifiedRows.slice(b0, b1).map(row => row.length)); + const work = height * width * columns; + if (height && width && work <= replacementBudget && (height + 1) * (width + 1) <= comparisonLimits.replacementCells) { + replacementBudget -= work; + const stride = width + 1; + const costs = new Float64Array((height + 1) * stride); + const choices = new Uint8Array(height * width); + for (let a = 0; a <= height; a++) { costs[a * stride + width] = height - a; } + for (let b = 0; b <= width; b++) { costs[height * stride + b] = width - b; } + for (let a = height - 1; a >= 0; a--) { + for (let b = width - 1; b >= 0; b--) { + const before = originalRows[a0 + a], after = modifiedRows[b0 + b]; + let equal = 0; + for (let col = 0; col < Math.min(before.length, after.length); col++) { + if (before[col] === after[col]) { equal++; } + } + // Shared fields favor pairing a modified record over an adjacent insertion/deletion. + const replace = 2 * (1 - equal / Math.max(1, before.length, after.length)) + costs[(a + 1) * stride + b + 1]; + const remove = 1 + costs[(a + 1) * stride + b]; + const add = 1 + costs[a * stride + b + 1]; + const choice = replace <= remove && replace <= add ? 0 : remove <= add ? 1 : 2; + choices[a * width + b] = choice; + costs[a * stride + b] = choice === 0 ? replace : choice === 1 ? remove : add; + } + } + let a = 0, b = 0; + while (a < height || b < width) { + const choice = a === height ? 2 : b === width ? 1 : choices[a * width + b]; + if (choice === 0) { append(a0 + a++, b0 + b++); } + else if (choice === 1) { append(a0 + a++, undefined); } + else { append(undefined, b0 + b++); } + } + } else { + if (height && width) { result.approximate = true; } + for (let index = 0; index < Math.max(height, width); index++) { + append(a0 + index < a1 ? a0 + index : undefined, b0 + index < b1 ? b0 + index : undefined); + } + } + }; + let a = 0, b = 0; + for (const [nextA, nextB] of [...matches, [original.length, modified.length]]) { + appendGap(a, nextA, b, nextB); + if (nextA < original.length && nextB < modified.length) { append(nextA, nextB); } + a = nextA + 1; b = nextB + 1; + } + return result; +} + +export function comparisonPages(diff: CsvTableDiff): Array<{ start: number; end: number }> { + const pages: Array<{ start: number; end: number }> = []; + let start = 0, cells = 0, chars = 0; + for (let index = 0; index < diff.records.length; index++) { + const record = diff.records[index]; + const before = record.original === undefined ? [] : diff.originalRows[record.original]; + const after = record.modified === undefined ? [] : diff.modifiedRows[record.modified]; + const nextCells = Math.max(before.length, after.length) * 2 + 2; + const nextChars = [...before, ...after].reduce((sum, value) => sum + value.length, 0); + if (index > start && (index - start >= comparisonLimits.pageRows || cells + nextCells > comparisonLimits.pageCells || chars + nextChars > comparisonLimits.pageChars)) { + pages.push({ start, end: index }); start = index; cells = 0; chars = 0; + } + cells += nextCells; chars += nextChars; + } + pages.push({ start, end: diff.records.length }); + return pages; +} diff --git a/src/table-comparison.ts b/src/table-comparison.ts new file mode 100644 index 0000000..b92b815 --- /dev/null +++ b/src/table-comparison.ts @@ -0,0 +1,204 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { randomBytes } from 'crypto'; +import { CsvEditorProvider } from './CsvEditorProvider'; +import { compareCsvRows, comparisonLimits, comparisonPages, CsvTableDiff, parseComparisonRows } from './csv-table-diff'; + +const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char] as string)); + +export function renderComparisonSide(diff: CsvTableDiff, side: 'original' | 'modified', start: number, end: number): string { + const columns = Math.max(1, ...diff.originalRows.map(row => row.length), ...diff.modifiedRows.map(row => row.length)); + const headings = Array.from({ length: columns }, (_, col) => `${col + 1}`).join(''); + const rows = diff.records.slice(start, end).map((record, index) => { + const source = record[side]; + const values = source === undefined ? undefined : (side === 'original' ? diff.originalRows : diff.modifiedRows)[source]; + const otherIndex = record[side === 'original' ? 'modified' : 'original']; + const other = otherIndex === undefined ? undefined : (side === 'original' ? diff.modifiedRows : diff.originalRows)[otherIndex]; + const rowLabel = record.kind === 'added' ? 'Added' : record.kind === 'removed' ? 'Removed' : record.kind === 'changed' ? 'Changed' : 'Unchanged'; + const cells = values === undefined ? `No record` + : Array.from({ length: Math.max(values.length, other?.length ?? 0) }, (_, col) => { + if (col >= values.length) { return '—'; } + const changed = record.changedColumns.includes(col); + const kind = changed ? (side === 'original' ? 'removed' : 'added') : ''; + return `${escapeHtml(values[col])}`; + }).join(''); + return `${source === undefined ? '—' : source + 1}${cells}`; + }).join(''); + return `${''.repeat(columns)}${headings}${rows}
Record
`; +} + +export class TableComparison { + private diff: CsvTableDiff | undefined; + private pages = [{ start: 0, end: 0 }]; + private currentPage = 0; + private focusedRecord: number | undefined; + private error = ''; + private disposed = false; + private timer: ReturnType | undefined; + private readonly changeSubscription: vscode.Disposable; + + constructor( + private readonly context: vscode.ExtensionContext, + private original: vscode.TextDocument, + private modified: vscode.TextDocument, + private readonly panel: vscode.WebviewPanel + ) { + panel.webview.options = { enableScripts: true, localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath, 'media'))] }; + panel.webview.html = this.html(); + panel.webview.onDidReceiveMessage(message => { + if (this.disposed) { return; } + if (message.type === 'ready') { this.showPage(); } + else if (message.type === 'page' && Number.isInteger(message.index)) { + this.currentPage = Math.max(0, Math.min(this.pages.length - 1, message.index)); + this.focusedRecord = undefined; + this.showPage(); + } else if (message.type === 'change' && (message.direction === 1 || message.direction === -1)) { + const changes = this.diff?.records.map((record, index) => record.kind === 'equal' ? -1 : index).filter(index => index >= 0) ?? []; + const boundary = this.focusedRecord ?? (this.pages[this.currentPage].start - (message.direction === 1 ? 1 : 0)); + const target = message.direction === 1 ? changes.find(index => index > boundary) : changes.reverse().find(index => index < boundary); + if (target !== undefined) { + this.focusedRecord = target; + this.currentPage = this.pages.findIndex(page => target >= page.start && target < page.end); + } + this.showPage(); + } + }); + this.changeSubscription = vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.uri.toString() !== original.uri.toString() && event.document.uri.toString() !== modified.uri.toString()) { return; } + if (event.document.uri.toString() === original.uri.toString()) { this.original = event.document; } + if (event.document.uri.toString() === modified.uri.toString()) { this.modified = event.document; } + if (this.timer) { clearTimeout(this.timer); } + this.timer = setTimeout(() => this.refresh(), 150); + }); + panel.onDidDispose(() => this.dispose()); + this.refresh(); + } + + dispose(): void { + this.disposed = true; + if (this.timer) { clearTimeout(this.timer); } + this.changeSubscription.dispose(); + } + + refresh(): void { + if (this.disposed) { return; } + try { + const rows = (document: vscode.TextDocument) => { + const text = document.getText(); + const config = vscode.workspace.getConfiguration('csv', document.uri); + if (!config.get('enabled', true)) { throw new Error('CSV is disabled.'); } + const limit = config.get('maxFileSizeMB', 10); + if (Number.isFinite(limit) && limit > 0 && Buffer.byteLength(text, 'utf8') > limit * 1024 * 1024) { + throw new Error('An input exceeds csv.maxFileSizeMB. Use a text diff or adjust the existing file-size limit.'); + } + const separator = CsvEditorProvider.getSeparatorForUri(this.context, document.uri) + ?? CsvEditorProvider.resolveInheritedSeparator(document.uri.fsPath || document.uri.path, text, CsvEditorProvider.getSeparatorSettings(document.uri)); + return parseComparisonRows(text, separator); + }; + this.diff = compareCsvRows(rows(this.original), rows(this.modified)); + this.pages = comparisonPages(this.diff); + this.currentPage = Math.min(this.currentPage, this.pages.length - 1); + this.focusedRecord = undefined; + this.error = ''; + } catch (error) { + this.diff = undefined; + this.pages = [{ start: 0, end: 0 }]; + this.currentPage = 0; + this.error = error instanceof Error ? error.message : 'Unable to compare these files.'; + } + this.showPage(); + } + + private showPage(): void { + if (this.disposed) { return; } + const { start, end } = this.pages[this.currentPage]; + const changes = this.diff?.records.map((record, index) => record.kind === 'equal' ? -1 : index).filter(index => index >= 0) ?? []; + const boundary = this.focusedRecord ?? start; + void this.panel.webview.postMessage({ + type: 'comparison', + originalHtml: this.diff ? renderComparisonSide(this.diff, 'original', start, end) : '', + modifiedHtml: this.diff ? renderComparisonSide(this.diff, 'modified', start, end) : '', + page: this.currentPage, + pageCount: this.pages.length, + canPreviousChange: changes.some(index => index < boundary), + canNextChange: changes.some(index => index > (this.focusedRecord ?? start - 1)), + focusRecord: this.focusedRecord, + status: this.diff ? `Page ${this.currentPage + 1}/${this.pages.length} · Added ${this.diff.added} · Removed ${this.diff.removed} · Changed ${this.diff.changed} records` : '', + notice: this.diff?.approximate ? 'Some large changed sections are aligned by position after the alignment work limit. Cell differences are exact for the displayed pairs.' : '', + error: this.error + }); + } + + private html(): string { + const nonce = randomBytes(16).toString('hex'); + const script = this.panel.webview.asWebviewUri(vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'table-comparison.js'))); + return ` + + + +

Read-only cell comparison · Red: original differences · Green: modified differences · Scrolling stays synchronized. Quoting and line-ending style are not compared.

+
+

Original: ${escapeHtml(path.basename(this.original.uri.path))}

+

Modified: ${escapeHtml(path.basename(this.modified.uri.path))}

+`; + } +} + +export async function compareFilesAsTable(context: vscode.ExtensionContext, resource?: vscode.Uri, selected?: vscode.Uri[]): Promise { + if (!vscode.workspace.getConfiguration('csv').get('enabled', true)) { + void vscode.window.showInformationMessage('Enable CSV before comparing files as a table.'); return; + } + let original: vscode.Uri | undefined; + let modified: vscode.Uri | undefined; + if (Array.isArray(selected) && selected.length === 2 && selected.every(uri => uri instanceof vscode.Uri)) { + [original, modified] = selected; + } else { + const active = CsvEditorProvider.getActiveProvider(); + original = resource instanceof vscode.Uri ? resource : vscode.window.activeTextEditor?.document.uri ?? (active?.isActive() ? active.getDocumentUri() : undefined); + if (!original) { + original = (await vscode.window.showOpenDialog({ canSelectMany: false, openLabel: 'Select Original CSV File' }))?.[0]; + } + if (!original) { return; } + modified = (await vscode.window.showOpenDialog({ canSelectMany: false, openLabel: 'Select Modified CSV File' }))?.[0]; + } + if (!original || !modified) { return; } + try { + const documents: vscode.TextDocument[] = []; + for (const uri of [original, modified]) { + const limit = vscode.workspace.getConfiguration('csv', uri).get('maxFileSizeMB', 10); + const existing = vscode.workspace.textDocuments.find(document => !document.isClosed && document.uri.toString() === uri.toString()); + let size: number | undefined; + try { size = (await vscode.workspace.fs.stat(uri)).size; } catch { /* Existing documents can still be compared within decoded-size limits. */ } + if (size === undefined && !existing) { + throw new Error('Cannot verify this source size. Open it in a normal editor first, then compare the already-open document.'); + } + if (size !== undefined && size > comparisonLimits.sourceBytes) { + throw new Error('Table comparison supports at most 10 MiB of saved bytes per input, regardless of csv.maxFileSizeMB. Use a text diff for larger files.'); + } + if (size !== undefined && Number.isFinite(limit) && limit > 0 && size > limit * 1024 * 1024) { + throw new Error('An input exceeds csv.maxFileSizeMB. Use a text diff or adjust the existing file-size limit.'); + } + const document = existing ?? await vscode.workspace.openTextDocument(uri); + if (Buffer.byteLength(document.getText(), 'utf8') > comparisonLimits.textBytes) { + throw new Error('Table comparison supports at most 5 MiB of decoded text per input. Use a text diff for larger files.'); + } + documents.push(document); + } + const [before, after] = documents; + const panel = vscode.window.createWebviewPanel('csv.tableComparison', `CSV Compare: ${path.basename(original.path)} ↔ ${path.basename(modified.path)}`, vscode.ViewColumn.Active, {}); + new TableComparison(context, before, after, panel); + } catch (error) { + void vscode.window.showErrorMessage(error instanceof Error ? error.message : 'Unable to open the CSV comparison.'); + } +} diff --git a/src/test/csv-table-diff.test.ts b/src/test/csv-table-diff.test.ts new file mode 100644 index 0000000..9a7c93c --- /dev/null +++ b/src/test/csv-table-diff.test.ts @@ -0,0 +1,97 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { compareCsvRows, comparisonLimits, comparisonPages, parseComparisonRows } from '../csv-table-diff'; + +describe('CSV table difference model', () => { + it('aligns inserted/deleted records and isolates changed fields around multiline values', () => { + const original = parseComparisonRows('id,value\r\n1,"one\r\ntwo"\r\n2,old\r\n3,remove\r\n4,same\r\n', ','); + const modified = parseComparisonRows('id,value\n0,added\n1,"one\r\ntwo"\n2,new\n4,same\n', ','); + const diff = compareCsvRows(original, modified); + assert.deepStrictEqual(diff.records.map(record => [record.original, record.modified, record.kind]), [ + [0, 0, 'equal'], [undefined, 1, 'added'], [1, 2, 'equal'], [2, 3, 'changed'], [3, undefined, 'removed'], [4, 4, 'equal'] + ]); + assert.deepStrictEqual(diff.records[3].changedColumns, [1]); + assert.deepStrictEqual([diff.added, diff.removed, diff.changed, diff.approximate], [1, 1, 1, false]); + }); + + it('aligns a modified record with shared fields after an adjacent deletion', () => { + const diff = compareCsvRows([['gone', 'old'], ['id', 'before']], [['id', 'after']]); + assert.deepStrictEqual(diff.records.map(record => [record.original, record.modified, record.kind]), [ + [0, undefined, 'removed'], [1, 0, 'changed'] + ]); + assert.deepStrictEqual(diff.records[1].changedColumns, [1]); + }); + + it('distinguishes a missing field from an empty field, without counting quote/terminator style', () => { + const diff = compareCsvRows(parseComparisonRows('"a",b\r\nx\r\n', ','), parseComparisonRows('a,b\nx,\n', ',')); + assert.strictEqual(diff.records[0].kind, 'equal'); + assert.deepStrictEqual(diff.records[1].changedColumns, [1]); + assert.strictEqual(diff.changed, 1); + assert.deepStrictEqual(parseComparisonRows('a\n\n', ','), [['a'], ['']]); + }); + + for (const delimiter of [',', '\t', '|']) { + it(`parses ${JSON.stringify(delimiter)} fields with doubled quotes, BOM and multiline Unicode`, () => { + const rows = parseComparisonRows(`\ufeffname${delimiter}value\r\n"é\n🙂"${delimiter}"a""b"\r\n`, delimiter); + assert.deepStrictEqual(rows, [['name', 'value'], ['é\n🙂', 'a"b']]); + }); + } + + it('preserves every source record in order with duplicates and empty sides', () => { + const inputs = [[], [['']], [['a'], ['a'], ['b']], [['a'], ['b'], ['a']], [['x', ''], ['y']]]; + for (const original of inputs) { + for (const modified of inputs) { + const diff = compareCsvRows(original, modified); + assert.deepStrictEqual(diff.records.flatMap(record => record.original === undefined ? [] : [record.original]), original.map((_, index) => index)); + assert.deepStrictEqual(diff.records.flatMap(record => record.modified === undefined ? [] : [record.modified]), modified.map((_, index) => index)); + } + } + }); + + it('bounds large alignment work and explicitly reports positional sections', () => { + const original = Array.from({ length: comparisonLimits.rows }, (_, index) => [`old-${index}`]); + const modified = Array.from({ length: comparisonLimits.rows }, (_, index) => [`new-${index}`]); + const diff = compareCsvRows(original, modified); + assert.strictEqual(diff.approximate, true); + assert.strictEqual(diff.records.length, comparisonLimits.rows); + assert.strictEqual(diff.changed, comparisonLimits.rows); + assert.deepStrictEqual(diff.records.at(-1)?.changedColumns, [0]); + }); + + it('uses unique unchanged anchors on large inputs with additions', () => { + const original = Array.from({ length: 1500 }, (_, index) => [String(index)]); + const modified = [['first'], ...original.map(row => [...row]), ['last']]; + modified[750] = ['changed']; + const diff = compareCsvRows(original, modified); + assert.strictEqual(diff.added, 2); + assert.strictEqual(diff.changed, 1); + assert.strictEqual(diff.removed, 0); + assert.strictEqual(diff.records.find(record => record.original === 1499)?.modified, 1500); + }); + + it('rejects malformed and oversized values, records, rows, cells and decoded input without truncation', () => { + assert.throws(() => parseComparisonRows('"unfinished', ','), /malformed/); + assert.throws(() => parseComparisonRows('a'.repeat(comparisonLimits.cellChars + 1), ','), /display limit/); + assert.throws(() => parseComparisonRows(Array(6).fill('x'.repeat(50000)).join(','), ','), /display limit/); + assert.throws(() => parseComparisonRows(Array(comparisonLimits.rows + 1).fill('x').join('\n'), ','), /10,000/); + assert.throws(() => parseComparisonRows(Array(1001).fill('x').join(','), ','), /1,000/); + assert.throws(() => parseComparisonRows(Array(101).fill(Array(1000).fill('x').join(',')).join('\n'), ','), /100,000/); + assert.throws(() => parseComparisonRows('é'.repeat(comparisonLimits.textBytes / 2 + 1), ','), /5 MiB/); + }); + + it('pages within row, rendered body-cell and text bounds', () => { + for (const rows of [Array.from({ length: 501 }, () => ['a']), Array.from({ length: 30 }, () => Array(1000).fill('x')), Array.from({ length: 30 }, () => ['x'.repeat(60000)])]) { + const diff = compareCsvRows(rows, rows); + const pages = comparisonPages(diff); + assert.ok(pages.length > 1); + assert.strictEqual(pages[0].start, 0); + assert.strictEqual(pages.at(-1)?.end, rows.length); + for (const page of pages) { + const slice = rows.slice(page.start, page.end); + assert.ok(slice.length <= comparisonLimits.pageRows); + assert.ok(slice.reduce((sum, row) => sum + row.length * 2 + 2, 0) <= comparisonLimits.pageCells); + assert.ok(slice.reduce((sum, row) => sum + row.join('').length * 2, 0) <= comparisonLimits.pageChars); + } + } + }); +}); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index c05184c..bcae4e1 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -13,6 +13,7 @@ class TestUri { let activeHarness: any; const vscodeStub = { Uri: TestUri, + ViewColumn: { Active: -1 }, Range: class {}, WorkspaceEdit: class { text = ''; @@ -26,6 +27,10 @@ const vscodeStub = { return activeHarness.warningChoice; }, showInformationMessage() {}, + showErrorMessage: async (message: string) => { activeHarness.errors.push(message); }, + showOpenDialog: async () => activeHarness.dialogResults.shift(), + get activeTextEditor() { return activeHarness.activeTextDocument ? { document: activeHarness.activeTextDocument } : undefined; }, + createWebviewPanel: (...args: unknown[]) => activeHarness.createPanel(...args), tabGroups: { get all() { return activeHarness.tabGroups ?? [{ tabs: [{ input: { uri: activeHarness.document.uri, viewType: 'csv.editor' } }] }]; }, close: async () => activeHarness.closeResult @@ -51,6 +56,19 @@ const vscodeStub = { } }, workspace: { + fs: { + stat: async (uri: TestUri) => { + if (activeHarness.statError) { throw activeHarness.statError; } + return { size: activeHarness.statSize ?? Buffer.byteLength(activeHarness.sourceDocuments.get(uri.toString())?.getText() ?? '') }; + } + }, + get textDocuments() { return activeHarness.existingDocuments; }, + openTextDocument: async (uri: TestUri) => { + activeHarness.openedDocuments.push(uri.toString()); + const document = activeHarness.sourceDocuments.get(uri.toString()); + if (!document) { throw new Error('Unknown test document'); } + return document; + }, getConfiguration: () => ({ get: (key: string, fallback: unknown) => key in activeHarness.configuration ? activeHarness.configuration[key] : key === 'maxFileSizeMB' ? 0 : fallback }), applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), @@ -144,6 +162,15 @@ export async function createEditorHarness(options: { configurationListeners: [] as Array<(event: any) => void>, providers: [] as string[], tabGroups: undefined as any[] | undefined, + errors: [] as string[], + dialogResults: [] as Array, + activeTextDocument: undefined as any, + createPanel: undefined as any, + openedDocuments: [] as string[], + sourceDocuments: new Map(), + existingDocuments: [] as any[], + statError: undefined as Error | undefined, + statSize: undefined as number | undefined, warnings: [] as string[], warningChoice: options.warningChoice, commandError: undefined as Error | undefined, diff --git a/src/test/table-comparison.test.ts b/src/test/table-comparison.test.ts new file mode 100644 index 0000000..543fb9c --- /dev/null +++ b/src/test/table-comparison.test.ts @@ -0,0 +1,255 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness } from './edit-integrity-harness'; +import { compareFilesAsTable, TableComparison } from '../table-comparison'; +import { comparisonLimits } from '../csv-table-diff'; +const { JSDOM } = require('jsdom'); + +async function comparisonFixture(before = 'id,value\n1,old\n2,same', after = 'id,value\n1,new\n2,same') { + const host = await createEditorHarness(); + const Uri = host.document.uri.constructor as any; + const original = { uri: new Uri('/test/original .csv'), isClosed: false, getText: () => before }; + const modified = { uri: new Uri('/test/modified &.csv', 'vscode-remote'), isClosed: false, getText: () => after }; + const messages: any[] = []; + let receive!: (message: unknown) => void; + let dispose!: () => void; + const panel = { + webview: { + html: '', options: {}, asWebviewUri: (uri: unknown) => String(uri), + postMessage: (message: unknown) => { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage: (listener: typeof receive) => { receive = listener; } + }, + onDidDispose: (listener: () => void) => { dispose = listener; } + }; + const context = { extensionPath: process.cwd(), workspaceState: { get: (_key: string, fallback: unknown) => fallback } }; + const comparison = new TableComparison(context as any, original as any, modified as any, panel as any); + host.sourceDocuments.set(original.uri.toString(), original); + host.sourceDocuments.set(modified.uri.toString(), modified); + return { host, original, modified, messages, panel, context, comparison, receive: (message: unknown) => receive(message), dispose: () => dispose() }; +} + +describe('Readonly table comparison provider and command', () => { + it('renders actual cell changes, escapes names/values and never exposes editable controls', async () => { + const value = ' & text'; + const fixture = await comparisonFixture('id,value\n1,old', `id,value\n1,${value}`); + try { + const dom = new JSDOM(fixture.panel.webview.html); + const payload = fixture.messages.at(-1); + dom.window.document.getElementById('original').innerHTML = payload.originalHtml; + dom.window.document.getElementById('modified').innerHTML = payload.modifiedHtml; + assert.strictEqual(dom.window.document.querySelectorAll('img, [contenteditable], input, textarea').length, 0); + assert.ok(dom.window.document.querySelector('#modified td.added').textContent.includes(value)); + assert.strictEqual(dom.window.document.querySelectorAll('#original td.removed').length, 1); + assert.ok(dom.window.document.querySelector('h2').textContent.includes('original .csv')); + assert.strictEqual(dom.window.document.querySelector('h2').title, 'original .csv'); + for (const heading of dom.window.document.querySelectorAll('h2')) { + const style = dom.window.getComputedStyle(heading); + assert.strictEqual(style.whiteSpace, 'nowrap'); + assert.strictEqual(style.overflow, 'hidden'); + assert.strictEqual(style.textOverflow, 'ellipsis'); + } + assert.ok(!fixture.panel.webview.html.includes('https://example.test/secret')); + assert.deepStrictEqual(Object.keys(fixture.context.workspaceState), ['get']); + assert.strictEqual(fixture.host.openedDocuments.length, 0); + dom.window.close(); + } finally { fixture.dispose(); } + }); + + it('refreshes unsaved source documents including a reopened document object, and clears errors', async () => { + const fixture = await comparisonFixture(); + try { + const reopened = { ...fixture.modified, getText: () => 'id,value\n1,old\n2,same' }; + fixture.host.changeListeners.forEach(listener => listener({ document: reopened })); + await new Promise(resolve => setTimeout(resolve, 190)); + assert.match(fixture.messages.at(-1).status, /Changed 0/); + reopened.getText = () => '"unfinished'; + fixture.comparison.refresh(); + assert.match(fixture.messages.at(-1).error, /malformed/); + assert.strictEqual(fixture.messages.at(-1).originalHtml, ''); + fixture.receive({ type: 'change', direction: 1 }); + assert.match(fixture.messages.at(-1).error, /malformed/); + reopened.getText = () => 'id,value\n1,recovered'; + fixture.comparison.refresh(); + assert.strictEqual(fixture.messages.at(-1).error, ''); + assert.ok(fixture.messages.at(-1).modifiedHtml.includes('recovered')); + assert.strictEqual(fixture.host.executedCommands.length, 0); + } finally { fixture.dispose(); } + }); + + it('navigates pages and differences, and responds when a queued change disappears', async () => { + const original = Array.from({ length: 450 }, (_, index) => `${index},same`).join('\n'); + const modified = original.replace('410,same', '410,changed'); + const fixture = await comparisonFixture(original, modified); + try { + fixture.receive({ type: 'change', direction: 1 }); + assert.strictEqual(fixture.messages.at(-1).page, 2); + assert.strictEqual(fixture.messages.at(-1).focusRecord, 410); + const count = fixture.messages.length; + fixture.receive({ type: 'change', direction: 1 }); + assert.strictEqual(fixture.messages.length, count + 1); + assert.strictEqual(fixture.messages.at(-1).canNextChange, false); + fixture.modified.getText = () => original; + fixture.comparison.refresh(); + const refreshed = fixture.messages.length; + fixture.receive({ type: 'change', direction: 1 }); + assert.strictEqual(fixture.messages.length, refreshed + 1); + assert.strictEqual(fixture.messages.at(-1).canPreviousChange, false); + fixture.receive({ type: 'page', index: -100 }); + assert.strictEqual(fixture.messages.at(-1).page, 0); + fixture.receive({ type: 'page', index: 100 }); + assert.strictEqual(fixture.messages.at(-1).page, 2); + } finally { fixture.dispose(); } + }); + + it('stops delayed refreshes and messages after disposal', async () => { + const fixture = await comparisonFixture(); + fixture.host.changeListeners.forEach(listener => listener({ document: fixture.modified })); + const count = fixture.messages.length; + fixture.dispose(); + fixture.receive({ type: 'ready' }); + await new Promise(resolve => setTimeout(resolve, 190)); + assert.strictEqual(fixture.messages.length, count); + }); + + it('uses TSV separators and reports disabled or decoded-size-limited documents', async () => { + const fixture = await comparisonFixture(); + try { + const Uri = fixture.original.uri.constructor as any; + fixture.original.uri = new Uri('/test/original.tsv'); + fixture.modified.uri = new Uri('/test/modified.tsv'); + fixture.original.getText = () => 'id\tvalue\n1\told'; + fixture.modified.getText = () => 'id\tvalue\n1\tnew'; + fixture.comparison.refresh(); + assert.ok(fixture.messages.at(-1).modifiedHtml.includes('data-columns="2"')); + fixture.host.configuration.enabled = false; + fixture.comparison.refresh(); + assert.match(fixture.messages.at(-1).error, /disabled/); + fixture.host.configuration.enabled = true; + fixture.modified.getText = () => 'é'.repeat(comparisonLimits.textBytes / 2 + 1); + fixture.comparison.refresh(); + assert.match(fixture.messages.at(-1).error, /5 MiB/); + assert.strictEqual(fixture.messages.at(-1).modifiedHtml, ''); + } finally { fixture.dispose(); } + }); + + it('opens a known Explorer pair, respecting dirty already-open text without mutation', async () => { + const fixture = await comparisonFixture(); + try { + let panels = 0; + fixture.host.existingDocuments = [fixture.original, fixture.modified]; + fixture.host.createPanel = () => { panels++; return fixture.panel; }; + await compareFilesAsTable(fixture.context as any, fixture.modified.uri as any, [fixture.original.uri, fixture.modified.uri] as any); + assert.strictEqual(panels, 1); + assert.deepStrictEqual(fixture.host.openedDocuments, []); + assert.deepStrictEqual(fixture.host.executedCommands, []); + assert.deepStrictEqual(fixture.host.errors, []); + } finally { fixture.dispose(); } + }); + + it('rejects huge saved sources before openTextDocument even with maxFileSizeMB=0', async () => { + const fixture = await comparisonFixture(); + try { + fixture.host.configuration.maxFileSizeMB = 0; + fixture.host.statSize = comparisonLimits.sourceBytes + 1; + await compareFilesAsTable(fixture.context as any, undefined, [fixture.original.uri, fixture.modified.uri] as any); + assert.match(fixture.host.errors.at(-1)!, /10 MiB of saved bytes/); + assert.deepStrictEqual(fixture.host.openedDocuments, []); + } finally { fixture.dispose(); } + }); + + it('refuses stat-less unopened sources, but permits bounded already-open remote documents', async () => { + const fixture = await comparisonFixture(); + try { + fixture.host.statError = new Error('No stat provider'); + await compareFilesAsTable(fixture.context as any, undefined, [fixture.original.uri, fixture.modified.uri] as any); + assert.match(fixture.host.errors.at(-1)!, /Open it in a normal editor first/); + assert.deepStrictEqual(fixture.host.openedDocuments, []); + fixture.host.existingDocuments = [fixture.original, fixture.modified]; + let panels = 0; + fixture.host.createPanel = () => { panels++; return fixture.panel; }; + await compareFilesAsTable(fixture.context as any, undefined, [fixture.original.uri, fixture.modified.uri] as any); + assert.strictEqual(panels, 1); + assert.deepStrictEqual(fixture.host.openedDocuments, []); + fixture.modified.getText = () => 'é'.repeat(comparisonLimits.textBytes / 2 + 1); + await compareFilesAsTable(fixture.context as any, undefined, [fixture.original.uri, fixture.modified.uri] as any); + assert.match(fixture.host.errors.at(-1)!, /5 MiB/); + assert.strictEqual(panels, 1); + } finally { fixture.dispose(); } + }); + + it('opens bounded file documents through VS Code, honors soft limit and supports picker cancellation', async () => { + const fixture = await comparisonFixture(); + try { + fixture.host.activeTextDocument = fixture.original; + fixture.host.dialogResults.push([fixture.modified.uri]); + fixture.host.createPanel = () => fixture.panel; + await compareFilesAsTable(fixture.context as any); + assert.deepStrictEqual(fixture.host.openedDocuments, [fixture.original.uri.toString(), fixture.modified.uri.toString()]); + fixture.host.openedDocuments.length = 0; + fixture.host.configuration.maxFileSizeMB = 1; + fixture.host.statSize = 2 * 1024 * 1024; + await compareFilesAsTable(fixture.context as any, undefined, [fixture.original.uri, fixture.modified.uri] as any); + assert.match(fixture.host.errors.at(-1)!, /maxFileSizeMB/); + assert.deepStrictEqual(fixture.host.openedDocuments, []); + fixture.host.dialogResults.push(undefined); + await compareFilesAsTable(fixture.context as any); + assert.deepStrictEqual(fixture.host.openedDocuments, []); + } finally { fixture.dispose(); } + }); +}); + +describe('Table comparison frontend', () => { + it('synchronizes both scroll axes, aligns multiline row heights, replaces pages and recovers controls', async () => { + const fixture = await comparisonFixture('id,value\n1,"one\ntwo"\n2,old', 'id,value\n1,short\n2,new'); + const dom = new JSDOM(fixture.panel.webview.html, { runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const requests: any[] = []; + let writes = 0; + const frames = new Map void>(); + let frame = 0; + window.requestAnimationFrame = (callback: () => void) => { frames.set(++frame, callback); return frame; }; + window.cancelAnimationFrame = (id: number) => frames.delete(id); + window.acquireVsCodeApi = () => ({ postMessage: (message: unknown) => requests.push(message) }); + window.HTMLElement.prototype.getBoundingClientRect = function () { + return { height: this.closest('#original') ? 50 : 25 }; + }; + const left = window.document.getElementById('original'); + const right = window.document.getElementById('modified'); + for (const pane of [left, right]) { + let top = 0, horizontal = 0; + Object.defineProperty(pane, 'scrollTop', { get: () => top, set: (value: number) => { top = value; writes++; pane.dispatchEvent(new window.Event('scroll')); } }); + Object.defineProperty(pane, 'scrollLeft', { get: () => horizontal, set: (value: number) => { horizontal = value; writes++; pane.dispatchEvent(new window.Event('scroll')); } }); + } + try { + window.eval(fs.readFileSync(path.join(process.cwd(), 'media', 'table-comparison.js'), 'utf8')); + assert.strictEqual(requests[0].type, 'ready'); + const send = () => window.dispatchEvent(new window.MessageEvent('message', { data: fixture.messages.at(-1) })); + for (let page = 0; page < 30; page++) { send(); } + for (const callback of frames.values()) { callback(); } + assert.strictEqual(left.querySelectorAll('tbody tr').length, 3); + assert.strictEqual(left.querySelector('tbody tr').style.height, '50px'); + assert.strictEqual(right.querySelector('tbody tr').style.height, '50px'); + const before = writes; + left.scrollTop = 140; + left.scrollLeft = 180; + assert.strictEqual(right.scrollTop, 140); + assert.strictEqual(right.scrollLeft, 180); + right.scrollTop = 20; + right.scrollLeft = 30; + assert.strictEqual(left.scrollTop, 20); + assert.strictEqual(left.scrollLeft, 30); + assert.ok(writes - before < 30, 'scrolling must not recursively bounce between panes'); + window.document.getElementById('nextChange').click(); + assert.strictEqual(requests.at(-1).type, 'change'); + assert.ok(Array.from(window.document.querySelectorAll('button')).every((button: any) => button.disabled)); + fixture.receive(requests.at(-1)); send(); + fixture.receive({ type: 'change', direction: 1 }); send(); + assert.ok(!window.document.getElementById('previousChange').disabled); + assert.ok(window.document.getElementById('nextChange').disabled); + assert.strictEqual(window.document.querySelectorAll('[contenteditable]').length, 0); + assert.strictEqual(window.document.querySelectorAll('tbody tr').length, 6); + } finally { dom.window.close(); fixture.dispose(); } + }); +}); From 2ceb421e3c882e352fe9ba4d5da3047caf8a761f Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:45:06 -0400 Subject: [PATCH 2/2] Keep focused comparison rows below sticky headers --- media/table-comparison.js | 2 +- src/test/table-comparison.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/media/table-comparison.js b/media/table-comparison.js index 9107e58..bbd0607 100644 --- a/media/table-comparison.js +++ b/media/table-comparison.js @@ -22,7 +22,7 @@ const alignRows = () => { if (row) { row.classList.add('focused'); const header = pane.querySelector('thead'); - pane.scrollTop = Math.max(0, row.offsetTop - (header?.getBoundingClientRect().height || 0)); + pane.scrollTop = Math.max(0, pane.scrollTop + row.getBoundingClientRect().top - pane.getBoundingClientRect().top - pane.clientTop - (header?.getBoundingClientRect().height || 0)); } }); pendingFocus = undefined; diff --git a/src/test/table-comparison.test.ts b/src/test/table-comparison.test.ts index 543fb9c..28c4255 100644 --- a/src/test/table-comparison.test.ts +++ b/src/test/table-comparison.test.ts @@ -201,6 +201,36 @@ describe('Readonly table comparison provider and command', () => { }); describe('Table comparison frontend', () => { + it('positions a focused change below the sticky header using actual pane geometry', async () => { + const fixture = await comparisonFixture(); + const dom = new JSDOM(fixture.panel.webview.html, { runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + let align!: () => void; + window.requestAnimationFrame = (callback: () => void) => { align = callback; return 1; }; + window.cancelAnimationFrame = () => {}; + window.acquireVsCodeApi = () => ({ postMessage() {} }); + try { + window.eval(fs.readFileSync(path.join(process.cwd(), 'media', 'table-comparison.js'), 'utf8')); + window.dispatchEvent(new window.MessageEvent('message', { data: { ...fixture.messages.at(-1), focusRecord: 1 } })); + const panes = ['original', 'modified'].map(id => window.document.getElementById(id)); + for (const pane of panes) { + pane.scrollTop = 140; + pane.getBoundingClientRect = () => ({ top: 115.5, height: 600 }); + Object.defineProperty(pane, 'clientTop', { value: 1 }); + pane.querySelector('thead').getBoundingClientRect = () => ({ height: 47 }); + const row = pane.querySelector('[data-align="1"]'); + Object.defineProperty(row, 'offsetTop', { value: 179 }); + row.getBoundingClientRect = () => ({ top: 350.5 - pane.scrollTop, height: 50 }); + } + align(); + for (const pane of panes) { + assert.strictEqual(pane.scrollTop, 187); + assert.strictEqual(pane.querySelector('[data-align="1"]').getBoundingClientRect().top, 163.5); + assert.ok(pane.querySelector('[data-align="1"]').classList.contains('focused')); + } + } finally { dom.window.close(); fixture.dispose(); } + }); + it('synchronizes both scroll axes, aligns multiline row heights, replaces pages and recovers controls', async () => { const fixture = await comparisonFixture('id,value\n1,"one\ntwo"\n2,old', 'id,value\n1,short\n2,new'); const dom = new JSDOM(fixture.panel.webview.html, { runScripts: 'outside-only', pretendToBeVisual: true });