diff --git a/README.md b/README.md index b3ed2c2..74b27cf 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. --- diff --git a/media/main.js b/media/main.js index 42c0ea2..408ab75 100644 --- a/media/main.js +++ b/media/main.js @@ -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); diff --git a/package.json b/package.json index e787037..b31b643 100644 --- a/package.json +++ b/package.json @@ -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." } } }, diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 53f53bb..e1429be 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -151,6 +151,9 @@ class CsvEditorController { case 'save': await this.handleSave(); break; + case 'hiddenRowsRefreshReady': + this.updateWebviewContent(); + break; case 'freezePanesChanged': await this.setFrozenPanes({ rows: e.rows, columns: e.columns }); break; @@ -422,6 +425,14 @@ class CsvEditorController { ); } + 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); } @@ -2643,18 +2654,28 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { return CsvEditorProvider.currentActive || CsvEditorProvider.editors.find(ed => ed.isActive()); } + public static hasHiddenRowsOverride(context: vscode.ExtensionContext, uri: vscode.Uri): boolean { + const map = context.workspaceState.get>(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>(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('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 { - const map = { ...(context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {})) }; - if (!Number.isFinite(n) || n <= 0) { + public static async setHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri, n: number | undefined): Promise { + const map = { ...context.workspaceState.get>(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); } diff --git a/src/commands.ts b/src/commands.ts index ae6f429..9867976 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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(); diff --git a/src/extension.ts b/src/extension.ts index 50ec42b..c163cf5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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', diff --git a/src/test/default-ignore-rows-commands.test.ts b/src/test/default-ignore-rows-commands.test.ts new file mode 100644 index 0000000..00ef684 --- /dev/null +++ b/src/test/default-ignore-rows-commands.test.ts @@ -0,0 +1,54 @@ +import assert from 'assert'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +const commands = new Map Promise>(); +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) => { 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 = {}; + 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 = []; + }); +}); diff --git a/src/test/default-ignore-rows.test.ts b/src/test/default-ignore-rows.test.ts new file mode 100644 index 0000000..44ce8c2 --- /dev/null +++ b/src/test/default-ignore-rows.test.ts @@ -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`')); + }); +}); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 7d3e171..d0e5894 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -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); @@ -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 }); @@ -108,6 +112,9 @@ export async function createEditorHarness(options: { }; const harness = { document, panel, messages, changeListeners, saveListeners, context, + defaultIgnoreRows: options.defaultIgnoreRows ?? 0, + resourceDefaults: {} as Record, + configurationRequests: [] as Array, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, diff --git a/src/test/provider-state.test.ts b/src/test/provider-state.test.ts index 42cd074..82af308 100644 --- a/src/test/provider-state.test.ts +++ b/src/test/provider-state.test.ts @@ -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);