Skip to content
Closed
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Open the Command Palette and search for:
Global (Settings UI or `settings.json`):

- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor.
- `csv.defaultIgnoreRows` (integer, default `0`): Default number of leading metadata rows to hide. Set it globally or for a workspace/folder. `CSV: Hide First N Rows` overrides it per file; enter `0` to show all rows or leave the input blank to reset that file to the configured default. Hidden rows remain in the saved file.
- `csv.fontFamily` (string, default empty): Override font family; falls back to `editor.fontFamily`.
- `csv.fontSize` (number, default `0`): Override font size in px; set to `0` to inherit `editor.fontSize`.
- `csv.mouseWheelZoom` (boolean, default `true`): Enable `Ctrl/Cmd + Mouse Wheel` zooming in the CSV editor.
Expand All @@ -122,7 +123,7 @@ Per-file (stored by the extension; set via commands):
- First row as header (default `true`) — `CSV: Toggle First Row as Header`
- Serial index column (default `true`) — `CSV: Toggle Serial Index Column`
- CSV separator override — `CSV: Change CSV Separator` (or clear it with `CSV: Reset CSV Separator (Inherit)`)
- Hide first N rows (default `0`) — `CSV: Hide First N Rows`
- Hide first N rows (inherits `csv.defaultIgnoreRows`) — `CSV: Hide First N Rows`. An explicit `0` overrides a nonzero default; blank input clears the per-file override.
- Frozen leading visible rows/columns — `CSV: Freeze Leading Rows` / `CSV: Freeze Leading Columns`. Enter `0` to unfreeze that axis. The default keeps the first header row frozen; with headers off, no rows freeze until requested. Hidden metadata rows do not count; the virtual append row is never frozen as a leading row.

---
Expand Down
6 changes: 5 additions & 1 deletion media/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2137,7 +2137,11 @@ const copySelectionToClipboard = (cut = false) => {

window.addEventListener('message', event => {
const message = event.data;
if (message.type === 'frozenPanes') {
if (message.type === 'prepareHiddenRowsRefresh') {
if (editingCell) editingCell.blur();
persistState();
vscode.postMessage({ type: 'hiddenRowsRefreshReady' });
} else if (message.type === 'frozenPanes') {
frozenRowCount = Number(message.rows);
frozenColumnCount = Number(message.columns);
window.csvFrozenPanes?.update(frozenRowCount, frozenColumnCount);
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@
"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.defaultIgnoreRows": {
"type": "integer",
"default": 0,
"minimum": 0,
"scope": "resource",
"description": "Default number of initial metadata rows to hide without deleting them. Per-file Hide First N Rows values override this default; leave that command blank to inherit again."
}
}
},
Expand Down
33 changes: 27 additions & 6 deletions src/CsvEditorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@
case 'save':
await this.handleSave();
break;
case 'hiddenRowsRefreshReady':
this.updateWebviewContent();
break;
case 'freezePanesChanged':
await this.setFrozenPanes({ rows: e.rows, columns: e.columns });
break;
Expand Down Expand Up @@ -341,7 +344,7 @@
}

private forceReload() {
if (!this.currentWebviewPanel) return;

Check warning on line 347 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
const panel = this.currentWebviewPanel;
// First, blank the DOM to ensure a full script/style reinit on next set
panel.webview.html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body></body></html>';
Expand Down Expand Up @@ -422,6 +425,14 @@
);
}

public refreshHiddenRows(): void {
if (this.currentWebviewPanel?.visible) {
this.currentWebviewPanel.webview.postMessage({ type: 'prepareHiddenRowsRefresh' });
} else {
this.refresh();
}
}

public getCurrentFrozenPanes(): FrozenPanes {
return CsvEditorProvider.getFrozenPanesForUri(this.context, this.document.uri, this.currentHeader);
}
Expand Down Expand Up @@ -868,7 +879,7 @@
}

private buildFindRegex(query: string, options: { regex: boolean; wholeWord: boolean; matchCase: boolean }): RegExp | undefined {
if (!query) return undefined;

Check warning on line 882 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
const useRegex = !!options.regex;
const wholeWord = !!options.wholeWord;
const matchCase = !!options.matchCase;
Expand Down Expand Up @@ -960,17 +971,17 @@
}
data[row][col] = '';
} else {
while (data.length <= row) data.push([]);

Check warning on line 974 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
while (data[row].length <= col) data[row].push('');

Check warning on line 975 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
data[row][col] = value;
}

let trimmed = false;
if (wasEditingLastRow) {
const isRowEmpty = (arr: string[] | undefined) => {
if (!arr || arr.length === 0) return true;

Check warning on line 982 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
for (let i = 0; i < arr.length; i++) {
if ((arr[i] ?? '') !== '') return false;

Check warning on line 984 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
}
return true;
};
Expand Down Expand Up @@ -1008,7 +1019,7 @@
const data = result.data as string[][];
for (const row of data) {
if (index > row.length) {
while (row.length < index) row.push('');

Check warning on line 1022 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
}
row.splice(index, 0, '');
}
Expand All @@ -1026,7 +1037,7 @@
}

private async insertColumns(index: number, count: number) {
if (count <= 0) return;

Check warning on line 1040 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
this.isUpdatingDocument = true;
const separator = this.getSeparator();
const text = this.document.getText();
Expand All @@ -1035,7 +1046,7 @@
for (let k = 0; k < count; k++) {
for (const row of data) {
if (index > row.length) {
while (row.length < index) row.push('');

Check warning on line 1049 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
}
row.splice(index, 0, '');
}
Expand Down Expand Up @@ -1078,7 +1089,7 @@
}

private async deleteColumns(indices: number[]) {
if (!indices || !indices.length) return;

Check warning on line 1092 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
this.isUpdatingDocument = true;
const separator = this.getSeparator();
const text = this.document.getText();
Expand Down Expand Up @@ -2643,18 +2654,28 @@
return CsvEditorProvider.currentActive || CsvEditorProvider.editors.find(ed => ed.isActive());
}

public static hasHiddenRowsOverride(context: vscode.ExtensionContext, uri: vscode.Uri): boolean {
const map = context.workspaceState.get<Record<string, number>>(CsvEditorProvider.hiddenRowsKey, {});
const saved = map[uri.toString()];
return Object.prototype.hasOwnProperty.call(map, uri.toString()) && typeof saved === 'number' && Number.isFinite(saved);
}

public static getHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri): number {
const map = context.workspaceState.get<Record<string, number>>(CsvEditorProvider.hiddenRowsKey, {});
const n = map[uri.toString()] ?? 0;
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
const saved = map[uri.toString()];
if (Object.prototype.hasOwnProperty.call(map, uri.toString()) && typeof saved === 'number' && Number.isFinite(saved)) {
return Math.max(0, Math.floor(saved));
}
const configured = vscode.workspace.getConfiguration('csv', uri).get<number>('defaultIgnoreRows', 0);
return typeof configured === 'number' && Number.isFinite(configured) ? Math.max(0, Math.floor(configured)) : 0;
}

public static async setHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri, n: number): Promise<void> {
const map = { ...(context.workspaceState.get<Record<string, number>>(CsvEditorProvider.hiddenRowsKey, {})) };
if (!Number.isFinite(n) || n <= 0) {
public static async setHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri, n: number | undefined): Promise<void> {
const map = { ...context.workspaceState.get<Record<string, number>>(CsvEditorProvider.hiddenRowsKey, {}) };
if (n === undefined || !Number.isFinite(n)) {
delete map[uri.toString()];
} else {
map[uri.toString()] = Math.floor(n);
map[uri.toString()] = Math.max(0, Math.floor(n));
}
await context.workspaceState.update(CsvEditorProvider.hiddenRowsKey, map);
}
Expand Down
11 changes: 6 additions & 5 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,19 @@ export function registerCsvCommands(context: vscode.ExtensionContext) {
const uri = active.getDocumentUri();
const current = CsvEditorProvider.getHiddenRowsForUri(context, uri);
const input = await vscode.window.showInputBox({
prompt: 'Hide first N rows (per file)',
prompt: 'Hide first N rows for this file (blank inherits csv.defaultIgnoreRows)',
value: String(current),
validateInput: (val: string) => (/^\d+$/.test(val) ? undefined : 'Enter a non-negative integer')
validateInput: (val: string) => (val.trim() === '' || (/^\d+$/.test(val) && Number.isSafeInteger(Number(val))) ? undefined : 'Enter a non-negative integer or leave blank to inherit')
});
if (input === undefined) { return; }
const n = parseInt(input, 10);
if (input.trim() !== '' && (!/^\d+$/.test(input) || !Number.isSafeInteger(Number(input)))) { return; }
const n = input.trim() === '' ? undefined : Number(input);
await CsvEditorProvider.setHiddenRowsForUri(context, uri, n);
// Refresh only editors showing this URI
CsvEditorProvider.editors
.filter(ed => ed.getDocumentUri().toString() === uri.toString())
.forEach(ed => ed.refresh());
vscode.window.showInformationMessage(`CSV: Hiding first ${n} row(s) for this file.`);
.forEach(ed => ed.refreshHiddenRows());
vscode.window.showInformationMessage(n === undefined ? 'CSV: Hidden rows now inherit csv.defaultIgnoreRows for this file.' : `CSV: Hiding first ${n} row(s) for this file.`);
}),
vscode.commands.registerCommand('csv.changeEncoding', async () => {
const active = CsvEditorProvider.getActiveProvider();
Expand Down
6 changes: 6 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ export function activate(context: vscode.ExtensionContext) {
}
}

if (e.affectsConfiguration('csv.defaultIgnoreRows')) {
CsvEditorProvider.editors
.filter(editor => !CsvEditorProvider.hasHiddenRowsOverride(context, editor.getDocumentUri()) && e.affectsConfiguration('csv.defaultIgnoreRows', editor.getDocumentUri()))
.forEach(editor => editor.refreshHiddenRows());
}

const keys = [
'csv.fontFamily',
'csv.cellPadding',
Expand Down
54 changes: 54 additions & 0 deletions src/test/default-ignore-rows-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from 'assert';
import Module from 'module';
import { describe, it } from 'node:test';

const commands = new Map<string, () => Promise<void>>();
let input: string | undefined;
let prompt: any;
let changedConfiguration: (event: any) => void;
const originalRequire = Module.prototype.require;
Module.prototype.require = function (id: string) {
if (id === 'vscode') {
return {
commands: { registerCommand: (name: string, callback: () => Promise<void>) => { commands.set(name, callback); return { dispose() {} }; } },
window: { showInputBox: async (options: any) => { prompt = options; return input; }, showInformationMessage() {}, registerCustomEditorProvider: () => ({ dispose() {} }) },
workspace: { getConfiguration: () => ({ get: (key: string, fallback: any) => key === 'defaultIgnoreRows' ? 7 : fallback }), onDidChangeConfiguration: (listener: any) => { changedConfiguration = listener; return { dispose() {} }; } }
};
}
return originalRequire.apply(this, arguments as any);
};
import { activate } from '../extension';
import { CsvEditorProvider } from '../CsvEditorProvider';
Module.prototype.require = originalRequire;

describe('Hidden-row override controls', () => {
it('uses zero as an explicit override and blank input to inherit again; cancellation keeps the override', async () => {
const values: Record<string, any> = {};
const context = { subscriptions: [], workspaceState: { get: (key: string, fallback: any) => values[key] ?? fallback, update: async (key: string, value: any) => { values[key] = value; } } } as any;
activate(context);
const uri = { toString: () => 'file:///sample.csv' } as any;
let refreshed = 0;
const active = { getDocumentUri: () => uri, refreshHiddenRows: () => refreshed++ } as any;
CsvEditorProvider.currentActive = active;
CsvEditorProvider.editors = [active];
input = '0';
await commands.get('csv.changeIgnoreRows')!();
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 0);
changedConfiguration!({ affectsConfiguration: (key: string) => key === 'csv.defaultIgnoreRows' });
assert.strictEqual(refreshed, 1, 'an explicit override must not interrupt editing when defaults change');
assert.strictEqual(prompt.validateInput(''), undefined);
assert.ok(prompt.validateInput('-1'));
assert.ok(prompt.validateInput('1.5'));
input = undefined;
await commands.get('csv.changeIgnoreRows')!();
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 0);
input = '';
await commands.get('csv.changeIgnoreRows')!();
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 7);
assert.strictEqual(refreshed, 2);
changedConfiguration!({ affectsConfiguration: (key: string) => key === 'csv.defaultIgnoreRows' });
assert.strictEqual(refreshed, 3, 'default setting updates use the edit-safe refresh path');
CsvEditorProvider.currentActive = undefined;
CsvEditorProvider.editors = [];
});
});
76 changes: 76 additions & 0 deletions src/test/default-ignore-rows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from 'assert';
import fs from 'fs';
import path from 'path';
import { describe, it } from 'node:test';
import { createEditorHarness, createWebviewDom } from './edit-integrity-harness';
const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider');

describe('Default hidden rows', () => {
it('inherits resource-specific defaults while explicit per-file zero wins until reset', async () => {
const host = await createEditorHarness({ defaultIgnoreRows: 1 });
const uri = host.document.uri as any;
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 1);
assert.ok(host.configurationRequests.includes(uri));
const other = { toString: () => 'file:///other.csv' } as any;
host.resourceDefaults[other.toString()] = 3;
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, other), 3);
await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, 0);
host.defaultIgnoreRows = 2;
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 0);
await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, 4);
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 4);
await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, undefined);
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 2);
assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, other), 3);
});

it('hides inherited metadata from the view while preserving source rows and frozen-row identities', async () => {
const rows = [['metadata', 'preserve'], ['Name', 'Value'], ['one', 'two']];
const host = await createEditorHarness({ rows, defaultIgnoreRows: 1, frozenRows: 2 });
const view = createWebviewDom(host.panel.webview.html);
try {
assert.strictEqual(view.cell(0, 0), null);
assert.strictEqual(view.cell(1, 0).tagName, 'TH');
assert.strictEqual(view.cell(2, 0).textContent, 'one');
view.window.csvFrozenPanes.layout();
assert.ok(view.cell(1, 0).classList.contains('csv-frozen-row'));
assert.ok(view.cell(2, 0).classList.contains('csv-frozen-row'));
assert.deepStrictEqual(host.rows(), rows);
} finally { view.close(); }
});

it('commits an active multiline draft before refreshing an inherited default', async () => {
const host = await createEditorHarness({ rows: [['metadata', 'keep'], ['Name', 'Value'], ['one', 'two'], ['three', 'four']], defaultIgnoreRows: 1 });
const view = createWebviewDom(host.panel.webview.html);
try {
const cell = view.cell(2, 0);
view.start(cell);
cell.textContent = 'unsaved\nmultiline draft';
host.defaultIgnoreRows = 2;
CsvEditorProvider.currentActive!.refreshHiddenRows();
const prepare = host.messages.slice().reverse().find((message: any) => message.type === 'prepareHiddenRowsRefresh');
assert.ok(prepare);
view.window.dispatchEvent(new view.window.MessageEvent('message', { data: prepare }));
const messages = view.messages.filter((message: any) => ['editCell', 'hiddenRowsRefreshReady'].includes(message.type));
assert.deepStrictEqual(messages.map((message: any) => message.type), ['editCell', 'hiddenRowsRefreshReady']);
for (const message of messages) { await host.receive(message); }
assert.strictEqual(host.rows()[2][0], 'unsaved\nmultiline draft');
assert.deepStrictEqual(host.rows()[0], ['metadata', 'keep']);
assert.ok(host.panel.webview.html.includes('data-firstrow="2"'));
assert.ok(host.panel.webview.html.includes('unsaved'));
} finally { view.close(); }
});

it('declares one resource-scoped integer setting and documents explicit zero/inherit reset', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
const setting = manifest.contributes.configuration.properties['csv.defaultIgnoreRows'];
assert.strictEqual(setting.type, 'integer');
assert.strictEqual(setting.default, 0);
assert.strictEqual(setting.minimum, 0);
assert.strictEqual(setting.scope, 'resource');
const readme = fs.readFileSync(path.join(process.cwd(), 'README.md'), 'utf8');
assert.ok(readme.includes('csv.defaultIgnoreRows'));
assert.ok(readme.includes('blank'));
assert.ok(readme.includes('explicit `0`'));
});
});
9 changes: 8 additions & 1 deletion src/test/edit-integrity-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ const vscodeStub = {
ColorThemeKind: { Dark: 1 },
window: { activeColorTheme: { kind: 1 } },
workspace: {
getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }),
getConfiguration: (_section: string, resource?: TestUri) => {
if (activeHarness) { activeHarness.configurationRequests.push(resource); }
return { get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : (key === 'defaultIgnoreRows' ? (activeHarness.resourceDefaults[resource?.toString() ?? ''] ?? activeHarness.defaultIgnoreRows) : fallback) };
},
applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text),
onDidChangeTextDocument: (listener: (event: unknown) => void) => {
activeHarness.changeListeners.push(listener);
Expand Down Expand Up @@ -52,6 +55,7 @@ export async function createEditorHarness(options: {
frozenRows?: number;
frozenColumns?: number;
hiddenRows?: number;
defaultIgnoreRows?: number;
} = {}) {
const separator = options.separator ?? ',';
let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator });
Expand Down Expand Up @@ -108,6 +112,9 @@ export async function createEditorHarness(options: {
};
const harness = {
document, panel, messages, changeListeners, saveListeners, context,
defaultIgnoreRows: options.defaultIgnoreRows ?? 0,
resourceDefaults: {} as Record<string, number>,
configurationRequests: [] as Array<TestUri | undefined>,
get diskText() { return diskText; },
get pendingEdits() { return delayedEdits.length; },
deferEdits() { deferEdits = true; },
Expand Down
3 changes: 2 additions & 1 deletion src/test/provider-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Module.prototype.require = function (id: string) {
if (id === 'vscode') {
return {
window: { activeColorTheme: { kind: 1 } },
ColorThemeKind: { Dark: 1 }
ColorThemeKind: { Dark: 1 },
workspace: { getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }) }
} as any;
}
return originalRequire.apply(this, arguments as any);
Expand Down
Loading