Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions media/table-comparison.js
Original file line number Diff line number Diff line change
@@ -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, pane.scrollTop + row.getBoundingClientRect().top - pane.getBoundingClientRect().top - pane.clientTop - (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' });
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"onLanguage:tsv",
"onCommand:csv.toggleExtension",
"onCommand:csv.openLargeFilePreview",
"onCommand:csv.compareFilesAsTable",
"onCustomEditor:csv.largeFilePreview",
"onCommand:csv.toggleHeader",
"onCommand:csv.toggleSerialIndex",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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')
),
Expand Down
234 changes: 234 additions & 0 deletions src/csv-table-diff.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>(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<string, number>();
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;
}
Loading
Loading