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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get:
- **Clickable Links:** URLs in cells are automatically detected and displayed as clickable links. Ctrl/Cmd+click to open them in your browser.
- **Preserved CSV Integrity:** All modifications respect CSV formatting—no unwanted extra characters or formatting issues.
- **Optimized for Performance:** Designed for medium-sized datasets, ensuring a smooth editing experience without compromising on functionality.
- **Large File Support:** Loads big CSVs in chunks so even large datasets open quickly.
- **Large File Support:** The editable view chunks table rendering; an optional read-only preview bounds source reading and displays one page at a time.
- **CSV/TSV/TAB/PSV Support:** `.csv`, `.tsv`, `.tab`, and `.psv` files are recognized automatically. Defaults: comma for CSV, tab for TSV/TAB, pipe for PSV.

---
Expand Down Expand Up @@ -83,6 +83,7 @@ Cursor (built on VS Code 1.99) and the latest VS Code releases (1.102).
Open the Command Palette and search for:

- `CSV: Toggle Extension On/Off` (`csv.toggleExtension`)
- `CSV: Open Large File Preview (Read-only)` (`csv.openLargeFilePreview`)
- `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 @@ -112,6 +113,7 @@ Global (Settings UI or `settings.json`):
- `csv.defaultSeparator` (string, default `,`): Fallback separator. Use `\\t` in `settings.json` for tabs.
- `csv.separatorByExtension` (object): Extension-to-separator mapping (defaults include `.csv`→`,`, `.tsv`/`.tab`→tab, `.psv`→`|`).
- `csv.maxFileSizeMB` (number, default `10`): Soft limit for opening files in CSV view. If exceeded, CSV prompts: `Cancel`, `Continue This Time`, or `Ignore Forever` (sets this setting to `0`).
- `csv.largeFilePreviewRows` (integer, default `1000`, range `1`–`10000`): Maximum records per read-only preview page; byte and cell limits can make a page smaller.
- Per-file encoding: `CSV: Change File Encoding` opens VS Code's encoding picker. Use **Reopen with Encoding** and select the source file's encoding to correct incorrectly displayed characters before editing or saving.

Per-file (stored by the extension; set via commands):
Expand All @@ -133,6 +135,16 @@ For example, the original attachment in [issue #76](https://github.com/jonarapha

---

## Large File Preview

To inspect a very large saved file without creating a full VS Code text document, right-click a CSV/TSV/TAB/PSV file in Explorer and choose **CSV: Open Large File Preview (Read-only)**. The same command in the Command Palette opens a file picker. Use this direct path before opening the file in an editable tab; switching an already-open large text document to the preview does not undo memory already allocated by VS Code.

The preview reads the saved bytes in bounded chunks and displays one page at a time. **Next**, **Previous**, and **Back to Start** navigate complete CSV records, including quoted multiline cells. The first record is displayed as data. It has no editing, global search, sorting, or filtering controls, and never writes to the source. Unsaved edits in another editor are not part of the saved-byte preview.

`csv.largeFilePreviewRows` limits a page to 1–10,000 records (default 1,000). Independent limits cap a page at 512 KiB of source records and 20,000 cells, one record at 256 KiB, and a record at 1,000 columns. Pages may contain fewer records. Oversized or malformed records produce a visible error instead of being truncated. Only the current page and at most 32 page positions are retained; Previous reaches the retained history, and Back to Start always restarts the file. If the file changes between reads, Back to Start reloads it.

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.

## Editing Modes and Shortcuts

- Quick edit:
Expand Down
30 changes: 30 additions & 0 deletions media/large-preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const vscode = acquireVsCodeApi();
const status = document.getElementById('status');
const records = document.getElementById('records');
const error = document.getElementById('error');
const buttons = ['start', 'previous', 'next'].map(id => document.getElementById(id));
const busy = () => {
buttons.forEach(button => { button.disabled = true; });
status.textContent = 'Loading saved file…';
};
buttons.forEach(button => {
button.addEventListener('click', () => {
busy();
vscode.postMessage({ type: button.id });
});
});
window.addEventListener('message', event => {
if (event.data.type !== 'page') return;
const message = event.data;
// Markup is built by the extension from escaped cell values. Replacing the
// page bounds DOM growth regardless of how many pages have been visited.
records.innerHTML = message.html;
records.scrollTop = records.scrollLeft = 0;
status.textContent = message.status;
error.textContent = message.error || '';
buttons[0].disabled = false;
buttons[1].disabled = !message.canBack;
buttons[2].disabled = !message.canNext;
});
busy();
vscode.postMessage({ type: 'ready' });
34 changes: 34 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"onLanguage:csv",
"onLanguage:tsv",
"onCommand:csv.toggleExtension",
"onCommand:csv.openLargeFilePreview",
"onCustomEditor:csv.largeFilePreview",
"onCommand:csv.toggleHeader",
"onCommand:csv.toggleSerialIndex",
"onCommand:csv.changeSeparator",
Expand Down Expand Up @@ -64,6 +66,10 @@
}
],
"commands": [
{
"command": "csv.openLargeFilePreview",
"title": "CSV: Open Large File Preview (Read-only)"
},
{
"command": "csv.toggleExtension",
"title": "CSV: Toggle Extension On/Off"
Expand Down Expand Up @@ -207,10 +213,38 @@
"default": 10,
"minimum": 0,
"description": "Soft max size in MB for opening files in CSV view. 0 disables the limit. When exceeded, CSV prompts to cancel, continue this time, or disable the limit."
},
"csv.largeFilePreviewRows": {
"type": "integer",
"default": 1000,
"minimum": 1,
"maximum": 10000,
"scope": "resource",
"description": "Maximum records per read-only Large File Preview page. Byte/cell safety limits can produce smaller pages."
}
}
},
"menus": {
"explorer/context": [
{
"command": "csv.openLargeFilePreview",
"when": "resourceScheme == file && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i && config.csv.enabled",
"group": "navigation"
}
]
},
"customEditors": [
{
"viewType": "csv.largeFilePreview",
"displayName": "CSV Large File Preview (Read-only)",
"priority": "option",
"selector": [
{ "filenamePattern": "*.csv" },
{ "filenamePattern": "*.tsv" },
{ "filenamePattern": "*.tab" },
{ "filenamePattern": "*.psv" }
]
},
{
"viewType": "csv.editor",
"displayName": "CSV",
Expand Down
220 changes: 220 additions & 0 deletions src/bounded-csv-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { promises as fs } from 'fs';
import { TextDecoder } from 'util';
import Papa from 'papaparse';

export const previewLimits = {
readBytes: 64 * 1024,
pageBytes: 512 * 1024,
recordBytes: 256 * 1024,
pageCells: 20000,
columns: 1000,
history: 32
};

export type CsvPreviewPage = {
rows: string[][];
offset: number;
nextOffset: number;
done: boolean;
version: string;
bytesRead: number;
};

export type CsvPreviewReadOptions = {
delimiter: string;
offset?: number;
maxRows?: number;
expectedVersion?: string;
cancelled?: () => boolean;
// Smaller read buffers make boundary handling deterministic in unit tests.
readBytes?: number;
};

function version(stat: { dev: number; ino: number; size: number; mtimeMs: number }): string {
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
}

export async function readCsvPreviewPage(filePath: string, options: CsvPreviewReadOptions): Promise<CsvPreviewPage> {
if (options.delimiter.length !== 1 || options.delimiter.charCodeAt(0) > 127 || /[\r\n"\0]/.test(options.delimiter)) {
throw new Error('Large File Preview requires a single ASCII separator. Set a separator override or use the editable CSV view.');
}
const checkCancelled = () => {
if (options.cancelled?.()) {
throw new Error('Large File Preview was cancelled.');
}
};
checkCancelled();
const handle = await fs.open(filePath, 'r');
try {
const stat = await handle.stat();
if (!stat.isFile()) {
throw new Error('Large File Preview can only read regular files.');
}
const fileVersion = version(stat);
if (options.expectedVersion && options.expectedVersion !== fileVersion) {
throw new Error('The file changed. Choose Back to Start to reload it.');
}
const offset = options.offset ?? 0;
if (!Number.isSafeInteger(offset) || offset < 0 || offset > stat.size) {
throw new Error('Invalid preview position. Choose Back to Start.');
}
const requestedRows = Math.trunc(Number(options.maxRows ?? 1000));
const maxRows = Number.isFinite(requestedRows) ? Math.min(10000, Math.max(1, requestedRows)) : 1000;
const readSize = Math.min(previewLimits.readBytes, Math.max(1, Math.trunc(options.readBytes ?? previewLimits.readBytes)));
const readBuffer = Buffer.alloc(readSize);
const recordBuffer = Buffer.alloc(previewLimits.recordBytes);
const rows: string[][] = [];
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
const delimiter = options.delimiter.charCodeAt(0);
let position = offset;
let recordStart = offset;
let recordLength = 0;
let recordFields = 1;
let usedBytes = 0;
let usedCells = 0;
let bytesRead = 0;
let state: 'start' | 'plain' | 'quoted' | 'closed' = 'start';
let pendingCr = false;
let stopped = false;
let nextOffset = offset;

if (offset === 0) {
const signature = Buffer.alloc(3);
const first = await handle.read(signature, 0, 3, 0);
bytesRead += first.bytesRead;
if ((signature[0] === 0xFF && signature[1] === 0xFE) || (signature[0] === 0xFE && signature[1] === 0xFF)) {
throw new Error('Large File Preview supports UTF-8 files only. Use the normal editor and Reopen with Encoding for this file.');
}
if (first.bytesRead === 3 && signature.equals(Buffer.from([0xEF, 0xBB, 0xBF]))) {
position = recordStart = 3;
}
}

const finishRecord = (end: number, terminatorBytes: number) => {
if (usedBytes + recordLength > previewLimits.pageBytes && rows.length > 0) {
stopped = true;
nextOffset = recordStart;
return;
}
let text: string;
try {
text = decoder.decode(recordBuffer.subarray(0, recordLength - terminatorBytes));
} catch {
throw new Error('Large File Preview supports valid UTF-8 only. Reopen the original file with its source encoding in the normal editor.');
}
if (text.includes('\0')) {
throw new Error('The file contains NUL bytes. Large File Preview supports UTF-8 text files only.');
}
// Prefix an empty field so Papa does not strip an intentional U+FEFF at
// the start of a later record when parsing records independently.
const parsed = Papa.parse<string[]>(`${options.delimiter}${text}`, { delimiter: options.delimiter, dynamicTyping: false });
if (parsed.errors.some(error => error.type === 'Quotes') || parsed.data.length !== 1) {
throw new Error(`Malformed CSV record at byte ${recordStart}. No partial record was displayed.`);
}
const row = parsed.data[0].slice(1);
if (row.length > previewLimits.columns) {
throw new Error(`A record exceeds the ${previewLimits.columns}-column preview limit. No fields were truncated.`);
}
if (usedCells + row.length > previewLimits.pageCells && rows.length > 0) {
stopped = true;
nextOffset = recordStart;
return;
}
rows.push(row);
usedBytes += recordLength;
usedCells += row.length;
nextOffset = recordStart = end;
recordLength = 0;
recordFields = 1;
state = 'start';
pendingCr = false;
stopped = rows.length >= maxRows || usedBytes >= previewLimits.pageBytes || usedCells >= previewLimits.pageCells;
};

while (!stopped && position < stat.size) {
checkCancelled();
const read = await handle.read(readBuffer, 0, readBuffer.length, position);
bytesRead += read.bytesRead;
if (read.bytesRead === 0) {
break;
}
for (let index = 0; index < read.bytesRead && !stopped; index++) {
const byte = readBuffer[index];
if (pendingCr && byte !== 10) {
finishRecord(position, 1);
if (stopped) {
break;
}
}
if (recordLength >= recordBuffer.length) {
throw new Error(`A record exceeds the ${previewLimits.recordBytes / 1024} KiB preview limit. No partial record was displayed.`);
}
recordBuffer[recordLength++] = byte;
position++;
if (pendingCr) {
finishRecord(position, 2);
} else if (state === 'quoted') {
if (byte === 34) {
state = 'closed';
}
} else if (state === 'closed' && byte === 34) {
state = 'quoted';
} else if (byte === 13) {
pendingCr = true;
} else if (byte === 10) {
finishRecord(position, 1);
} else if (byte === delimiter) {
recordFields++;
if (recordFields > previewLimits.columns) {
throw new Error(`A record exceeds the ${previewLimits.columns}-column preview limit. No fields were truncated.`);
}
state = 'start';
} else if (state === 'start' && byte === 34) {
state = 'quoted';
} else if (state === 'closed') {
if (byte !== 32 && byte !== 9) {
throw new Error(`Malformed quoted field at byte ${position - 1}. No partial record was displayed.`);
}
} else {
state = 'plain';
}
}
}
if (!stopped && recordLength > 0) {
if (state === 'quoted') {
throw new Error(`Unterminated quoted record at byte ${recordStart}. No partial record was displayed.`);
}
finishRecord(position, pendingCr ? 1 : 0);
}
if (rows.length === 0) {
nextOffset = position;
}
checkCancelled();
if (version(await fs.stat(filePath)) !== fileVersion || version(await handle.stat()) !== fileVersion) {
throw new Error('The file changed while reading. Choose Back to Start to reload it.');
}
return { rows, offset, nextOffset, done: nextOffset >= stat.size, version: fileVersion, bytesRead };
} finally {
await handle.close();
}
}

export type PreviewPosition = { offset: number; record: number };

export class PreviewHistory {
private positions: PreviewPosition[] = [{ offset: 0, record: 0 }];
private index = 0;
get current(): PreviewPosition { return this.positions[this.index]; }
get canGoBack(): boolean { return this.index > 0; }
get size(): number { return this.positions.length; }
reset(): void { this.positions = [{ offset: 0, record: 0 }]; this.index = 0; }
previous(): void { if (this.canGoBack) { this.index--; } }
next(position: PreviewPosition): void {
this.positions.splice(this.index + 1);
this.positions.push(position);
if (this.positions.length > previewLimits.history) {
this.positions.shift();
}
this.index = this.positions.length - 1;
}
}
2 changes: 2 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getFonts } from 'font-list';
import * as vscode from 'vscode';
import { CsvEditorProvider } from './CsvEditorProvider';
import { openLargeFilePreview } from './large-file-preview';

function displaySeparator(separator: string): string {
return separator === '\t' ? '\\t' : separator;
Expand All @@ -21,6 +22,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.toggleExtension', () =>
toggleBooleanConfig('enabled', true, 'CSV extension')
),
Expand Down
Loading
Loading