From b74f4885ee20e9ed2ebe1bbabec61194156b9262 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:09:32 -0400 Subject: [PATCH 01/11] fix: preserve source formatting during paste and reorder (#83) --- README.md | 2 +- src/CsvEditorProvider.ts | 114 ++++++++++++++++++++++--- src/test/edit-integrity-harness.ts | 3 +- src/test/structural-formatting.test.ts | 72 ++++++++++++++++ 4 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 src/test/structural-formatting.test.ts diff --git a/README.md b/README.md index fd300ce..36decc4 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Find & Replace Overlay:** Built-in find/replace bar with match options (case, whole-word, regex), keyboard navigation, and single/all replace actions across the full file (including chunked rows). - **Multiline Cell Display:** Cells with embedded newlines render as wrapped multi-line content (with preserved line breaks and matching row height). - **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. +- **Preserved CSV Integrity:** Cell edits, expanding grid pastes, and row/column moves preserve existing field quoting and line endings. Appended values follow the existing column quote style when the column uses one consistently. - **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. - **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. diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 2f1babf..539be2a 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -450,6 +450,8 @@ class CsvEditorController { separator, [{ row, col, value: String(value ?? '') }] ); + } else { + newCsvText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(oldText, separator, nextData); } if (newCsvText === undefined) { newCsvText = Papa.unparse(nextData, { delimiter: separator }); @@ -732,7 +734,7 @@ class CsvEditorController { const separator = this.getSeparator(); const oldText = this.document.getText(); const result = Papa.parse(oldText, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); const pasteResult = CsvEditorController.applyPasteMatrixToData(data, matrix, anchorRow, anchorCol, selection); if (!pasteResult.changed) { @@ -742,6 +744,8 @@ class CsvEditorController { let newCsvText: string | undefined; if (!pasteResult.structuralChange) { newCsvText = CsvEditorProvider.applyFieldUpdatesPreservingFormat(oldText, separator, pasteResult.updates); + } else { + newCsvText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(oldText, separator, this.trimTrailingEmptyRows(data)); } if (newCsvText === undefined) { newCsvText = Papa.unparse(data, { delimiter: separator }); @@ -1319,7 +1323,7 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); const numColumns = data.reduce((max, row) => Math.max(max, row.length), 0); if (numColumns <= 0) return; @@ -1330,13 +1334,13 @@ class CsvEditorController { const reorderedData = data.map(row => { const normalized = Array.from({ length: numColumns }, (_, i) => row[i] ?? ''); const next = columnOrder.map(colIdx => normalized[colIdx] ?? ''); - while (next.length > 0 && next[next.length - 1] === '') { - next.pop(); - } - return next; + // Keep explicit empty fields; omit only positions absent from the source row. + const lastPresent = columnOrder.reduce((last, colIdx, index) => colIdx < row.length ? index : last, -1); + return next.slice(0, lastPresent + 1); }); - const newText = Papa.unparse(reorderedData, { delimiter: separator }); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, reorderedData, undefined, columnOrder) + ?? Papa.unparse(reorderedData, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -1357,13 +1361,15 @@ class CsvEditorController { const separator = this.getSeparator(); const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); - const data = result.data as string[][]; + const data = this.trimTrailingEmptyRows(result.data as string[][]); if (!data.length) return; - const { reordered, changed } = this.reorderByIndices(data, indices, beforeIndex); + const { reordered: rowOrder, changed } = this.reorderByIndices(data.map((_, index) => index), indices, beforeIndex); if (!changed) return; - const newText = Papa.unparse(reordered, { delimiter: separator }); + const reordered = rowOrder.map(index => data[index]); + const newText = CsvEditorProvider.applyStructuralUpdatesPreservingFormat(text, separator, reordered, rowOrder) + ?? Papa.unparse(reordered, { delimiter: separator }); const fullRange = new vscode.Range( 0, 0, this.document.lineCount, @@ -2409,8 +2415,8 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { const sep = delimiter && delimiter.length ? delimiter : CsvEditorProvider.DEFAULT_SEPARATOR; const rows: CsvFieldSpan[][] = []; let row: CsvFieldSpan[] = []; - let fieldStart = 0; - let i = 0; + let fieldStart = text.startsWith('\uFEFF') ? 1 : 0; + let i = fieldStart; let inQuotes = false; let quoted = false; @@ -2530,6 +2536,90 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { return output; } + public static applyStructuralUpdatesPreservingFormat( + text: string, + delimiter: string, + data: string[][], + rowOrder?: number[], + columnOrder?: number[] + ): string | undefined { + const parsed = Papa.parse(text, { delimiter, dynamicTyping: false }); + if (parsed.errors.some(error => error.type === 'Quotes')) { + return undefined; + } + const original = parsed.data; + let contentRowCount = original.length; + while (contentRowCount > 0 && original[contentRowCount - 1].every(value => value === '')) { + contentRowCount--; + } + const spans = CsvEditorProvider.parseCsvFieldSpans(text, delimiter); + // Pasting into the displayed append row can fill an existing empty source + // record. Consume that record rather than retaining it again as a suffix. + for (let row = 0; row < data.length; row++) { + const sourceRow = rowOrder?.[row] ?? row; + const sourceEnd = spans[sourceRow]?.slice(-1)[0]?.end; + const sourceStart = spans[sourceRow]?.[0]?.start; + if (sourceEnd !== undefined && (sourceEnd < text.length || (sourceStart ?? sourceEnd) < sourceEnd) && data[row].some(value => value !== '')) { + contentRowCount = Math.max(contentRowCount, sourceRow + 1); + } + } + const bom = text.startsWith('\uFEFF') ? '\uFEFF' : ''; + const lastSpan = contentRowCount > 0 ? spans[contentRowCount - 1]?.slice(-1)[0] : undefined; + const suffix = text.slice(lastSpan?.end ?? bom.length); + const rowBreak = (row: number): string | undefined => { + const end = spans[row]?.slice(-1)[0]?.end; + if (end === undefined) { + return undefined; + } + return /^(\r\n|\n|\r)/.exec(text.slice(end))?.[0]; + }; + const defaultBreak = parsed.meta.linebreak || '\n'; + const columnPrefersQuotes = (col: number): boolean => { + // The body supplies the convention when a header uses a different style. + const sample = spans.slice(contentRowCount > 1 ? 1 : 0, contentRowCount) + .map(row => row[col]).filter((span): span is CsvFieldSpan => !!span); + return sample.length > 0 && sample.every(span => span.quoted); + }; + const quotedColumns = new Map(); + let output = bom; + for (let row = 0; row < data.length; row++) { + const sourceRow = rowOrder?.[row] ?? row; + const fields: string[] = []; + for (let col = 0; col < data[row].length; col++) { + const sourceCol = columnOrder?.[col] ?? col; + const span = sourceRow < contentRowCount ? spans[sourceRow]?.[sourceCol] : undefined; + const value = data[row][col] ?? ''; + if (span && original[sourceRow]?.[sourceCol] === value) { + fields.push(text.slice(span.start, span.end)); + } else { + if (!quotedColumns.has(sourceCol)) { + quotedColumns.set(sourceCol, columnPrefersQuotes(sourceCol)); + } + fields.push(CsvEditorProvider.encodeCsvField(value, delimiter, span?.quoted ?? quotedColumns.get(sourceCol) ?? false)); + } + } + output += fields.join(delimiter); + if (row < data.length - 1) { + output += rowBreak(sourceRow) ?? defaultBreak; + } + } + output += suffix; + // Papa Parse remains authoritative: never reuse source tokens if their + // reconstruction would change the requested values or field boundaries. + const actual = Papa.parse(output, { delimiter, dynamicTyping: false }); + const trimEmpty = (rows: string[][]) => { + const result = [...rows]; + while (result.length > 0 && result[result.length - 1].every(value => value === '')) { + result.pop(); + } + return result; + }; + if (actual.errors.some(error => error.type === 'Quotes') || JSON.stringify(trimEmpty(actual.data)) !== JSON.stringify(trimEmpty(data))) { + return undefined; + } + return output; + } + constructor(private readonly context: vscode.ExtensionContext) {} public async resolveCustomTextEditor( diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..33858d5 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -45,13 +45,14 @@ const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import(' const { JSDOM } = require('jsdom'); export async function createEditorHarness(options: { + text?: string; rows?: string[][]; separator?: string; header?: boolean; remote?: boolean; } = {}) { const separator = options.separator ?? ','; - let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); + let text = options.text ?? Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); let diskText = text; let messageListener: (message: any) => Promise; const changeListeners: Array<(event: unknown) => void> = []; diff --git a/src/test/structural-formatting.test.ts b/src/test/structural-formatting.test.ts new file mode 100644 index 0000000..0a41811 --- /dev/null +++ b/src/test/structural-formatting.test.ts @@ -0,0 +1,72 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import Papa from 'papaparse'; +import { createEditorHarness } from './edit-integrity-harness'; + +describe('Structural edits preserve CSV source conventions', () => { + it('appends a pasted row using the body column quote convention without rewriting prior rows', async () => { + const text = 'id,label,note\r\n1,"alpha",""\r\n2,"beta",""\r\n'; + const host = await createEditorHarness({ text }); + await host.receive({ type: 'pasteCells', text: '3,gamma,plain', anchorRow: 3, anchorCol: 0 }); + assert.strictEqual(host.document.getText(), `${text}3,"gamma","plain"\r\n`); + }); + + it('extends a row with pasted columns while keeping untouched tokens and CRLF records', async () => { + const text = 'a,"b"\r\n"left","right"\r\nend,last'; + const host = await createEditorHarness({ text }); + await host.receive({ type: 'pasteCells', text: 'extra', anchorRow: 1, anchorCol: 2 }); + assert.strictEqual(host.document.getText(), 'a,"b"\r\n"left","right",extra\r\nend,last'); + }); + + it('moves raw column fields including explicitly quoted and unquoted empty fields', async () => { + const host = await createEditorHarness({ text: '"id",name,"tail"\r\n"1","alpha",""\r\n"2",beta,\r\n' }); + await host.receive({ type: 'reorderColumns', indices: [1], beforeIndex: 0 }); + assert.strictEqual(host.document.getText(), 'name,"id","tail"\r\n"alpha","1",""\r\nbeta,"2",\r\n'); + }); + + it('moves rows without normalizing their raw quoting', async () => { + const host = await createEditorHarness({ text: 'h,v\n"1",a\n2,"b"\n' }); + await host.receive({ type: 'reorderRows', indices: [2], beforeIndex: 1 }); + assert.strictEqual(host.document.getText(), 'h,v\n2,"b"\n"1",a\n'); + }); + + it('preserves a UTF-8 BOM when editing a quoted first field or appending data', async () => { + const host = await createEditorHarness({ text: '\uFEFF"a",b\n"one",two\n' }); + await host.receive({ type: 'editCell', row: 0, col: 0, value: 'header' }); + assert.strictEqual(host.document.getText(), '\uFEFF"header",b\n"one",two\n'); + await host.receive({ type: 'pasteCells', text: 'next,last', anchorRow: 2, anchorCol: 0 }); + assert.strictEqual(host.document.getText(), '\uFEFF"header",b\n"one",two\n"next",last\n'); + }); + + it('keeps trailing empty source records and escaped multiline values', async () => { + const text = 'id,"text"\n1,"line 1\nline ""2"""\n\n'; + const host = await createEditorHarness({ text }); + await host.receive({ type: 'pasteCells', text: '2,"another\nline"', anchorRow: 2, anchorCol: 0 }); + assert.strictEqual(host.document.getText(), 'id,"text"\n1,"line 1\nline ""2"""\n2,"another\nline"\n'); + }); + + it('appends a typed value in the virtual row without normalizing prior fields', async () => { + const text = 'id,"text"\n1,"alpha"\n'; + const host = await createEditorHarness({ text }); + await host.receive({ type: 'editCell', row: 2, col: 0, value: '2' }); + assert.strictEqual(host.document.getText(), `${text}2`); + }); + + it('fills an existing empty record once instead of duplicating it after the paste', async () => { + const host = await createEditorHarness({ text: 'id,"text"\n1,"alpha"\n,' }); + await host.receive({ type: 'pasteCells', text: '2,beta', anchorRow: 2, anchorCol: 0 }); + assert.deepStrictEqual(host.rows(), [['id', 'text'], ['1', 'alpha'], ['2', 'beta']]); + assert.ok(host.document.getText().startsWith('id,"text"\n1,"alpha"\n')); + }); + + for (const separator of [',', '\t']) { + it(`round-trips ragged rows with delimiters, quotes and newlines after reordering (${JSON.stringify(separator)})`, async () => { + const data = [['id', 'text', 'last'], ['1', `inside${separator}value`, ''], ['2', 'two\nlines', 'tail'], ['3', '"quoted"']]; + const text = Papa.unparse(data, { delimiter: separator, quotes: true, newline: '\n' }); + const host = await createEditorHarness({ text, separator }); + await host.receive({ type: 'reorderColumns', indices: [2], beforeIndex: 0 }); + assert.deepStrictEqual(host.rows(), [['last', 'id', 'text'], ['', '1', `inside${separator}value`], ['tail', '2', 'two\nlines'], ['', '3', '"quoted"']]); + assert.ok(host.document.getText().includes(`"tail"${separator}"2"${separator}"two\nlines"`)); + }); + } +}); From e8247c0f152986efa90ed86fb5371c55b35ff201 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:14:25 -0400 Subject: [PATCH 02/11] docs: explain privacy and omit cell values from edit logs (#82) --- PRIVACY.md | 31 +++++++++++++++++++++++++++++++ README.md | 6 ++++++ src/CsvEditorProvider.ts | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 PRIVACY.md diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..588aad1 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,31 @@ +# Privacy and data handling + +CSV does not implement an upload service, analytics client, or extension-specific telemetry collection. Viewing, parsing, searching, sorting, and editing a file use the VS Code document and the extension's bundled code. No account or external processing service is required by CSV. + +## Where processing happens + +VS Code supplies the file's decoded text to the extension. The extension parses that text with the bundled Papa Parse library, sends table content to its VS Code webview, and applies edits through VS Code's document APIs. Saving uses VS Code's normal file-saving behavior and the selected file encoding. + +For a normal local workspace, these operations run on your computer. In Remote SSH, WSL, Dev Containers, or another remote workspace, the extension host and files can be on that remote environment. File contents travel through your existing VS Code connection to display and edit them in the webview. “No external processing service” does not mean that a remote workspace's data stays on your local computer. + +The font picker uses the bundled `font-list` package to enumerate fonts available to the extension host through operating-system helpers. It does not use an online font service. + +## Actions that share information outside the table + +- **Clipboard:** Copying writes the selected contents to the system clipboard; pasting reads clipboard contents supplied by the editor. Other applications, clipboard managers, and operating-system clipboard synchronization may have access to that clipboard. +- **Links:** Ctrl/Cmd+click on a recognized link asks VS Code to open its URL using your configured browser or application. Supported schemes include HTTP, HTTPS, FTP, and email links. The destination receives the URL, including any information in its path or query, according to that application's behavior. CSV does not fetch a link merely to render its text. You can disable link interaction with `csv.clickableLinks`. +- **Support:** If you attach a file, screenshot, or log to a GitHub issue, you are sharing it with that issue's audience. CSV does not submit these materials automatically. + +VS Code's Marketplace connections, updates, Settings Sync, remote services, other extensions, and the browser or application used for links have their own data-handling behavior. They are separate from CSV's runtime processing. + +## Logs and stored state + +The extension writes diagnostic messages to the extension host's console. Routine messages include file paths/URIs, row counts, and the row/column coordinates of an edit; routine edit messages omit the cell value. Errors can still contain document or operation details, and a failed external-link operation can log its URL. Treat extension host logs as potentially containing sensitive information, especially before sharing them for support. The log location and retention are controlled by VS Code and may be in a remote environment. + +CSV's code does not send those logs or cell contents to a telemetry endpoint. This statement does not describe or override VS Code's own telemetry, logging, crash reporting, or another extension's behavior. Consult your editor's privacy policy and settings for those services. + +CSV stores per-file view preferences in VS Code workspace state, keyed by the file URI: header mode, serial-index visibility, separator overrides, and hidden leading rows. Its webview state remembers scroll/selection positions, column widths, row heights, and zoom. These preference records are not a separate copy of the CSV contents. VS Code independently manages the document, undo history, unsaved-file backups, autosave, and any platform synchronization you enable. + +## Scope of these statements + +These statements describe this repository's extension and webview implementation. The main data paths are in [`src/CsvEditorProvider.ts`](src/CsvEditorProvider.ts), [`media/main.js`](media/main.js), [`src/commands.ts`](src/commands.ts), and [`src/extension.ts`](src/extension.ts); runtime dependencies are declared in [`package.json`](package.json). They are not a promise about every VS Code component, remote host, dependency version, or installed extension. Review the version you use and your environment's policies when deciding whether it fits your data requirements. diff --git a/README.md b/README.md index 7dec9d6..696bf41 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,12 @@ npm run compile --- +## Privacy + +CSV processes files through VS Code and bundled libraries, with no extension-specific upload service or telemetry client. Remote workspaces, clipboard operations, links, diagnostic logs, and editor-managed backups have additional data-handling implications. See [Privacy and data handling](PRIVACY.md) for the verified behavior and scope. + +--- + ## Support Have questions, suggestions, or encountered an issue? diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 9135ca8..cb9d45e 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -452,7 +452,7 @@ class CsvEditorController { return; } - console.log(`CSV: Updated row ${row + 1}, column ${col + 1} to "${value}"`); + console.log(`CSV: Updated row ${row + 1}, column ${col + 1}`); const config = vscode.workspace.getConfiguration('csv', this.document.uri); const clickableLinks = config.get('clickableLinks', true); const rendered = this.formatCellContent(value ?? '', clickableLinks); From e906bba480c1a2fb4e43425bf8c175fba1da516f Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:16:42 -0400 Subject: [PATCH 03/11] docs: scope document handling to editable views --- PRIVACY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 588aad1..da590a5 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,7 +4,7 @@ CSV does not implement an upload service, analytics client, or extension-specifi ## Where processing happens -VS Code supplies the file's decoded text to the extension. The extension parses that text with the bundled Papa Parse library, sends table content to its VS Code webview, and applies edits through VS Code's document APIs. Saving uses VS Code's normal file-saving behavior and the selected file encoding. +Normal editable CSV views receive the file's decoded text from VS Code. The extension parses that text with the bundled Papa Parse library, sends table content to its VS Code webview, and applies edits through VS Code's document APIs. Saving in these views uses VS Code's normal file-saving behavior and the selected file encoding. For a normal local workspace, these operations run on your computer. In Remote SSH, WSL, Dev Containers, or another remote workspace, the extension host and files can be on that remote environment. File contents travel through your existing VS Code connection to display and edit them in the webview. “No external processing service” does not mean that a remote workspace's data stays on your local computer. @@ -24,7 +24,7 @@ The extension writes diagnostic messages to the extension host's console. Routin CSV's code does not send those logs or cell contents to a telemetry endpoint. This statement does not describe or override VS Code's own telemetry, logging, crash reporting, or another extension's behavior. Consult your editor's privacy policy and settings for those services. -CSV stores per-file view preferences in VS Code workspace state, keyed by the file URI: header mode, serial-index visibility, separator overrides, and hidden leading rows. Its webview state remembers scroll/selection positions, column widths, row heights, and zoom. These preference records are not a separate copy of the CSV contents. VS Code independently manages the document, undo history, unsaved-file backups, autosave, and any platform synchronization you enable. +CSV stores per-file view preferences in VS Code workspace state, keyed by the file URI: header mode, serial-index visibility, separator overrides, and hidden leading rows. Its webview state remembers scroll/selection positions, column widths, row heights, and zoom. View preferences and saved state can themselves contain sensitive metadata; they are not intended as full-document backups. VS Code independently manages the document, undo history, unsaved-file backups, autosave, and any platform synchronization you enable. ## Scope of these statements From c19fb7b3cdd1b925d6e8237eada4b3a5658ebfea Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:30:22 -0400 Subject: [PATCH 04/11] Implement full-source column filtering with source row identities --- media/frozen-panes.js | 10 +- media/main.js | 79 ++++--- src/CsvEditorProvider.ts | 205 +++++++++++++++--- src/column-filters.ts | 57 +++++ src/commands.ts | 2 +- src/extension.ts | 2 +- src/test/column-filter-view.test.ts | 195 +++++++++++++++++ src/test/column-filters.test.ts | 38 ++++ src/test/default-ignore-rows-commands.test.ts | 2 +- src/test/default-ignore-rows.test.ts | 8 +- src/test/edit-integrity-harness.ts | 11 +- src/test/provider-clipboard.test.ts | 8 +- src/test/webview-clipboard.test.ts | 2 +- 13 files changed, 538 insertions(+), 81 deletions(-) create mode 100644 src/column-filters.ts create mode 100644 src/test/column-filter-view.test.ts create mode 100644 src/test/column-filters.test.ts diff --git a/media/frozen-panes.js b/media/frozen-panes.js index 381ca5c..47a1a79 100644 --- a/media/frozen-panes.js +++ b/media/frozen-panes.js @@ -1,5 +1,5 @@ // Layout uses measured dimensions so multiline cells, manual sizes and zoom agree. -window.createFrozenPaneManager = ({ table, firstRow, realRowCount, onNeedRows }) => { +window.createFrozenPaneManager = ({ table, firstRow, realRowCount, rowOrder, topInset, onNeedRows }) => { let rows = 0; let columns = 0; let scheduled = false; @@ -42,10 +42,12 @@ window.createFrozenPaneManager = ({ table, firstRow, realRowCount, onNeedRows }) left += cell.getBoundingClientRect().width; } } - let top = 0; - const lastFrozenRow = Math.min(firstRow + rows, realRowCount) - 1; + let top = topInset?.() || 0; + const visibleRows = rowOrder?.length ? rowOrder.filter(row => row < realRowCount) : rendered.filter(row => row.index < realRowCount).map(row => row.index); + const frozenRows = new Set(visibleRows.slice(0, rows)); + const lastFrozenRow = visibleRows[Math.min(rows, visibleRows.length) - 1] ?? -1; for (const { element, index } of rendered) { - const frozenRow = index >= firstRow && index <= lastFrozenRow; + const frozenRow = frozenRows.has(index); const height = element.getBoundingClientRect().height || Math.max(0, ...Array.from(element.cells).map(cell => cell.getBoundingClientRect().height)); for (const cell of Array.from(element.cells)) { const attr = cell.getAttribute('data-col'); diff --git a/media/main.js b/media/main.js index d11a834..5bff633 100644 --- a/media/main.js +++ b/media/main.js @@ -6,6 +6,14 @@ try { document.body.focus({ preventScroll: true }); } catch { try { document.bod const vscode = acquireVsCodeApi(); const root = document.getElementById('csv-root'); +let visibleRowOrder = []; +try { visibleRowOrder = JSON.parse(root?.dataset?.roworder || '[]'); } catch {} +const headerSourceRow = Number(root?.dataset?.firstrow || '0'); +const adjacentVisibleRow = (row, direction) => { + if (!visibleRowOrder.length) return row + direction; + const index = visibleRowOrder.indexOf(row); + return visibleRowOrder[index + direction] ?? (row >= Number(root?.dataset?.realrowcount || '0') ? row + direction : row); +}; let frozenRowCount = Number(root?.dataset?.frozenrows || '0'); let frozenColumnCount = Number(root?.dataset?.frozencolumns || '0'); const parsePositiveNumber = value => { @@ -194,7 +202,7 @@ const restoreState = () => { } } if (typeof st.anchorRow === 'number' && typeof st.anchorCol === 'number') { - const tag = (hasHeader && st.anchorRow === 0 ? 'th' : 'td'); + const tag = (hasHeader && st.anchorRow === headerSourceRow ? 'th' : 'td'); let sel = table.querySelector(`${tag}[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`); // If not present yet (due to chunking), load chunks until available or exhausted if (!sel && typeof window.__csvLoadNextChunk === 'function') { @@ -416,6 +424,7 @@ const showContextMenu = (x, y, row, col) => { item('Freeze rows through this row', () => changeFrozenPanes({ rows: row - Number(root?.dataset?.firstrow || '0') + 1 })); } if (Number.isInteger(col) && col >= 0) { + item('Filter this column…', () => vscode.postMessage({ type: 'configureColumnFilter', column: col })); item('Freeze columns through this column', () => changeFrozenPanes({ columns: col + 1 })); } if (frozenRowCount > 0) item('Unfreeze rows', () => changeFrozenPanes({ rows: 0 })); @@ -981,9 +990,10 @@ const selectRange = (start, end) => { clearSelection(); const minRow = Math.min(start.row, end.row), maxRow = Math.max(start.row, end.row); const minCol = Math.min(start.col, end.col), maxCol = Math.max(start.col, end.col); - for(let r = minRow; r <= maxRow; r++){ + const selectedRows = visibleRowOrder.length ? visibleRowOrder.filter(row => row >= minRow && row <= maxRow) : Array.from({ length: maxRow - minRow + 1 }, (_, index) => minRow + index); + for (const r of selectedRows) { for(let c = minCol; c <= maxCol; c++){ - const selector = (hasHeader && r === 0 ? 'th' : 'td') + '[data-row="'+r+'"][data-col="'+c+'"]'; + const selector = (hasHeader && r === headerSourceRow ? 'th' : 'td') + '[data-row="'+r+'"][data-col="'+c+'"]'; const selCell = table.querySelector(selector); if(selCell){ selCell.classList.add('selected'); currentSelection.push(selCell); } } @@ -1036,7 +1046,8 @@ const getDataSelectionBounds = () => { const maxRow = Math.max(...rows); const minCol = Math.min(...cols); const maxCol = Math.max(...cols); - const expectedCount = (maxRow - minRow + 1) * (maxCol - minCol + 1); + const rowIds = [...new Set(rows)].sort((a, b) => a - b); + const expectedCount = rowIds.length * (maxCol - minCol + 1); const rectangular = keys.size === expectedCount; return { minRow, maxRow, minCol, maxCol, rectangular }; }; @@ -1496,13 +1507,13 @@ document.addEventListener('keydown', e => { if (ref) { const { row, col } = getCellCoords(ref); if (['ArrowLeft','Left','Home'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); + const tag = (hasHeader && row === headerSourceRow ? 'th' : 'td'); const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); const min = rowCells.reduce((acc, el) => Math.min(acc, parseInt(el.getAttribute('data-col'))), Infinity); target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === min) || ref; } else if (['ArrowRight','Right','End'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); + const tag = (hasHeader && row === headerSourceRow ? 'th' : 'td'); const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); const max = rowCells.reduce((acc, el) => Math.max(acc, parseInt(el.getAttribute('data-col'))), -1); @@ -1615,13 +1626,13 @@ document.addEventListener('keydown', e => { if (refCell) { const { row, col } = getCellCoords(refCell); if (['ArrowLeft','Left','Home'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); + const tag = (hasHeader && row === headerSourceRow ? 'th' : 'td'); const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); const min = rowCells.reduce((acc, el) => Math.min(acc, parseInt(el.getAttribute('data-col'))), Infinity); target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === min) || refCell; } else if (['ArrowRight','Right','End'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); + const tag = (hasHeader && row === headerSourceRow ? 'th' : 'td'); const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); const max = rowCells.reduce((acc, el) => Math.max(acc, parseInt(el.getAttribute('data-col'))), -1); @@ -1667,13 +1678,13 @@ document.addEventListener('keydown', e => { let targetRow = coords.row; let targetCol = coords.col + (isBackward ? -1 : 1); if (!isBackward && targetCol > maxCol) { - targetRow += 1; + targetRow = adjacentVisibleRow(targetRow, 1); targetCol = minCol; } else if (isBackward && targetCol < minCol) { if (targetRow <= firstDataRow) { return; } - targetRow -= 1; + targetRow = adjacentVisibleRow(targetRow, -1); targetCol = maxCol; } const nextCell = ensureRenderedCellByCoords(targetRow, targetCol); @@ -1714,14 +1725,14 @@ document.addEventListener('keydown', e => { const { row, col } = getCellCoords(rangeEndCell || anchorCell); let targetRow = row, targetCol = col; switch(e.key){ - case 'ArrowUp': targetRow = row - 1; break; - case 'ArrowDown': targetRow = row + 1; break; + case 'ArrowUp': targetRow = adjacentVisibleRow(row, -1); break; + case 'ArrowDown': targetRow = adjacentVisibleRow(row, 1); break; case 'ArrowLeft': targetCol = col - 1; break; case 'ArrowRight':targetCol = col + 1; break; } if(targetRow < 0 || targetCol < 0) return; - const tag = (hasHeader && targetRow === 0 ? 'th' : 'td'); - const nextCell = table.querySelector(`${tag}[data-row="${targetRow}"][data-col="${targetCol}"]`); + const tag = (hasHeader && targetRow === headerSourceRow ? 'th' : 'td'); + const nextCell = ensureRenderedCellByCoords(targetRow, targetCol); if(nextCell){ e.preventDefault(); rangeEndCell = nextCell; @@ -1737,14 +1748,14 @@ document.addEventListener('keydown', e => { const { row, col } = getCellCoords(anchorCell); let targetRow = row, targetCol = col; switch(e.key){ - case 'ArrowUp': targetRow = row - 1; break; - case 'ArrowDown': targetRow = row + 1; break; + case 'ArrowUp': targetRow = adjacentVisibleRow(row, -1); break; + case 'ArrowDown': targetRow = adjacentVisibleRow(row, 1); break; case 'ArrowLeft': targetCol = col - 1; break; case 'ArrowRight':targetCol = col + 1; break; } if(targetRow < 0 || targetCol < 0) return; - const tag = (hasHeader && targetRow === 0 ? 'th' : 'td'); - const nextCell = table.querySelector(`${tag}[data-row="${targetRow}"][data-col="${targetCol}"]`); + const tag = (hasHeader && targetRow === headerSourceRow ? 'th' : 'td'); + const nextCell = ensureRenderedCellByCoords(targetRow, targetCol); if(nextCell){ e.preventDefault(); clearSelection(); @@ -1765,14 +1776,14 @@ document.addEventListener('keydown', e => { const { row, col } = getCellCoords(editingCell); let targetRow = row, targetCol = col; switch(e.key){ - case 'ArrowUp': targetRow = row - 1; break; - case 'ArrowDown': targetRow = row + 1; break; + case 'ArrowUp': targetRow = adjacentVisibleRow(row, -1); break; + case 'ArrowDown': targetRow = adjacentVisibleRow(row, 1); break; case 'ArrowLeft': targetCol = col - 1; break; case 'ArrowRight':targetCol = col + 1; break; } if (targetRow >= 0 && targetCol >= 0) { - const tag = (hasHeader && targetRow === 0 ? 'th' : 'td'); - const nextCell = table.querySelector(`${tag}[data-row="${targetRow}"][data-col="${targetCol}"]`); + const tag = (hasHeader && targetRow === headerSourceRow ? 'th' : 'td'); + const nextCell = ensureRenderedCellByCoords(targetRow, targetCol); if (nextCell) { const commitAndMove = () => { clearSelection(); @@ -1821,7 +1832,7 @@ document.addEventListener('keydown', e => { } const { row, col } = getCellCoords(editingCell); editingCell.blur(); - const targetRow = row + 1; + const targetRow = adjacentVisibleRow(row, 1); // Editing Enter commits and moves selection down (no auto-edit). const nextCell = ensureRenderedCellByCoords(targetRow, col); if (nextCell) { @@ -1846,13 +1857,13 @@ document.addEventListener('keydown', e => { if (bounds) { targetCol = col + (isBackward ? -1 : 1); if (!isBackward && targetCol > bounds.maxCol) { - targetRow += 1; + targetRow = adjacentVisibleRow(targetRow, 1); targetCol = bounds.minCol; } else if (isBackward && targetCol < bounds.minCol) { if (targetRow <= firstDataRow) { canMove = false; } else { - targetRow -= 1; + targetRow = adjacentVisibleRow(targetRow, -1); targetCol = bounds.maxCol; } } @@ -2088,7 +2099,8 @@ const copySelectionToClipboard = (cut = false) => { if (!bounds) return false; const { minRow, maxRow, minCol, maxCol } = bounds; const matrix = []; - for (let row = minRow; row <= maxRow; row++) { + const rowIds = [...new Set(currentSelection.map(getDataCellCoords).filter(Boolean).map(coords => coords.row))].sort((a, b) => a - b); + for (const row of rowIds) { const values = []; for (let col = minCol; col <= maxCol; col++) { const cell = getRenderedCellByCoords(row, col); @@ -2096,16 +2108,16 @@ const copySelectionToClipboard = (cut = false) => { } matrix.push(values); } - vscode.postMessage({ type: 'copyCells', matrix, startRow: minRow, startCol: minCol, cut }); + vscode.postMessage({ type: 'copyCells', matrix, rowIds, startCol: minCol, cut }); return true; }; window.addEventListener('message', event => { const message = event.data; - if (message.type === 'prepareHiddenRowsRefresh') { + if (message.type === 'prepareViewRefresh') { if (editingCell) editingCell.blur(); persistState(); - vscode.postMessage({ type: 'hiddenRowsRefreshReady' }); + vscode.postMessage({ type: 'viewRefreshReady' }); } else if (message.type === 'frozenPanes') { frozenRowCount = Number(message.rows); frozenColumnCount = Number(message.columns); @@ -2234,7 +2246,7 @@ const maybeResumePendingEdit = () => { try { const st = vscode.getState() || {}; if (st && typeof st.anchorRow === 'number' && typeof st.anchorCol === 'number' && st.pendingEdit === 'detail') { - const tag = (hasHeader && st.anchorRow === 0 ? 'th' : 'td'); + const tag = (hasHeader && st.anchorRow === headerSourceRow ? 'th' : 'td'); const sel = table.querySelector(`${tag}[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`); if (sel) { editCell(sel, undefined, 'detail'); @@ -2276,6 +2288,8 @@ if (typeof window.createFrozenPaneManager === 'function') { table, firstRow: Number(root?.dataset?.firstrow || '0'), realRowCount: Number(root?.dataset?.realrowcount || '0'), + rowOrder: visibleRowOrder, + topInset: () => document.getElementById('filterStatus')?.getBoundingClientRect().height || 0, onNeedRows: () => loadNextChunk() }); window.csvFrozenPanes.update(frozenRowCount, frozenColumnCount); @@ -2283,3 +2297,8 @@ if (typeof window.createFrozenPaneManager === 'function') { window.addEventListener('resize', () => window.csvFrozenPanes.schedule()); window.addEventListener('pagehide', () => window.csvFrozenPanes.dispose(), { once: true }); } + +document.getElementById('clearAllFilters')?.addEventListener('click', () => vscode.postMessage({ type: 'clearAllFilters' })); +document.querySelectorAll('[data-clear-filter]').forEach(button => button.addEventListener('click', () => { + vscode.postMessage({ type: 'clearColumnFilter', column: Number(button.getAttribute('data-clear-filter')) }); +})); diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index f7b07cc..310595a 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -2,6 +2,7 @@ import Papa from 'papaparse'; import * as vscode from 'vscode'; import * as path from 'path'; import { parseClipboardMatrix, serializeClipboardMatrix } from './clipboard'; +import { ColumnFilter, normalizeColumnFilters, rowMatchesFilters, visibleSourceRows, remapColumnFilters, MAX_FILTER_VALUE_LENGTH, MAX_COLUMN_FILTERS } from './column-filters'; type FrozenPanes = { rows: number; columns: number }; @@ -37,6 +38,8 @@ type PasteApplyResult = { plan: PastePlan; }; type ChunkRenderState = { + rowIds: number[]; + virtualRow: number; startAbs: number; allRows: string[][]; allRowsCount: number; @@ -151,7 +154,16 @@ class CsvEditorController { case 'save': await this.handleSave(); break; - case 'hiddenRowsRefreshReady': + case 'configureColumnFilter': + await this.configureColumnFilter(e.column); + break; + case 'clearColumnFilter': + await this.setColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri).filter(filter => filter.column !== Number(e.column))); + break; + case 'clearAllFilters': + await this.setColumnFilters([]); + break; + case 'viewRefreshReady': this.updateWebviewContent(); break; case 'freezePanesChanged': @@ -161,7 +173,7 @@ class CsvEditorController { await CsvEditorProvider.setColumnSizesForUri(this.context, this.document.uri, e.columnSizes); break; case 'copyCells': - await this.copyCells(e.matrix, e.startRow, e.startCol, e.cut === true); + await this.copyCells(e.matrix, e.rowIds, e.startCol, e.cut === true); break; case 'pasteFromClipboard': await this.pasteCells(await vscode.env.clipboard.readText(), e.anchorRow, e.anchorCol, e.selection); @@ -425,9 +437,55 @@ class CsvEditorController { ); } - public refreshHiddenRows(): void { + private async setColumnFilters(filters: ColumnFilter[]): Promise { + await CsvEditorProvider.setColumnFiltersForUri(this.context, this.document.uri, filters); + this.refreshViewSafely(); + } + + private async configureColumnFilter(rawColumn: unknown): Promise { + const column = Number(rawColumn); + const data = this.trimTrailingEmptyRows(Papa.parse(this.document.getText(), { delimiter: this.getSeparator() }).data); + const columnCount = data.reduce((max, row) => Math.max(max, row.length), 0); + if (!Number.isInteger(column) || column < 0 || column >= columnCount) { + return; + } + const filters = CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri); + const existing = filters.find(filter => filter.column === column); + if (!existing && filters.length >= MAX_COLUMN_FILTERS) { + void vscode.window.showInformationMessage(`CSV: Clear a column filter before adding another (maximum ${MAX_COLUMN_FILTERS}).`); + return; + } + const choices: Array = [ + { label: 'Contains', operator: 'contains' }, { label: 'Equals', operator: 'equals' }, + { label: 'Is empty', operator: 'empty' }, { label: 'Is not empty', operator: 'notEmpty' } + ]; + const choice = await vscode.window.showQuickPick(choices, { placeHolder: `Filter column ${column + 1} (text matching ignores case)` }); + if (!choice) { + return; + } + let value = ''; + if (choice.operator === 'contains' || choice.operator === 'equals') { + const input = await vscode.window.showInputBox({ + prompt: `Column ${column + 1}: ${choice.label.toLowerCase()} (case-insensitive)`, + value: existing?.value ?? '', + validateInput: text => text.length > MAX_FILTER_VALUE_LENGTH ? `Use at most ${MAX_FILTER_VALUE_LENGTH} characters.` : undefined + }); + if (input === undefined || input.length > MAX_FILTER_VALUE_LENGTH) { + return; + } + value = input; + } + await this.setColumnFilters([...filters.filter(filter => filter.column !== column), { column, operator: choice.operator, value }]); + } + + private async remapFilters(columnOrder: number[]): Promise { + await CsvEditorProvider.setColumnFiltersForUri(this.context, this.document.uri, + remapColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), columnOrder)); + } + + public refreshViewSafely(): void { if (this.currentWebviewPanel?.visible) { - this.currentWebviewPanel.webview.postMessage({ type: 'prepareHiddenRowsRefresh' }); + this.currentWebviewPanel.webview.postMessage({ type: 'prepareViewRefresh' }); } else { this.refresh(); } @@ -455,6 +513,7 @@ class CsvEditorController { private async updateDocument(row: number, col: number, value: string) { this.isUpdatingDocument = true; let structuralChange = false; + let filterMembershipChanged = false; let applied = false; try { const separator = this.getSeparator(); @@ -468,7 +527,10 @@ class CsvEditorController { ? String(data[row][col] ?? '') : undefined; + const filters = CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri); + const previouslyMatched = rowMatchesFilters(data[row] ?? [], filters); const { data: nextData, trimmed, createdRow, createdCol } = this.mutateDataForEdit(data, row, col, value); + filterMembershipChanged = filters.length > 0 && previouslyMatched !== rowMatchesFilters(nextData[row] ?? [], filters); structuralChange = !!(trimmed || createdRow || createdCol || row >= hadRows || col >= hadColsAtRow); if (!structuralChange && previousValue === value) { @@ -514,7 +576,9 @@ class CsvEditorController { this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value, rendered }); // Trigger a full re-render if structure may have changed (new row/col created) - if (structuralChange) { + if (filterMembershipChanged) { + this.refreshViewSafely(); + } else if (structuralChange) { try { this.updateWebviewContent(); } catch (e) { console.error('CSV: refresh failed after structural edit', e); } } } @@ -583,16 +647,16 @@ class CsvEditorController { } } - private async copyCells(rawMatrix: unknown, rawStartRow: unknown, rawStartCol: unknown, cut: boolean): Promise { + private async copyCells(rawMatrix: unknown, rawRowIds: unknown, rawStartCol: unknown, cut: boolean): Promise { if (!Array.isArray(rawMatrix) || rawMatrix.length === 0 || !rawMatrix.every(row => Array.isArray(row) && row.length > 0 && row.every(value => typeof value === 'string') )) { return; } const matrix = rawMatrix as string[][]; - const startRow = Number(rawStartRow); + const rowIds = Array.isArray(rawRowIds) ? rawRowIds : []; const startCol = Number(rawStartCol); - if (!Number.isInteger(startRow) || startRow < 0 || !Number.isInteger(startCol) || startCol < 0) { + if (rowIds.length !== matrix.length || new Set(rowIds).size !== rowIds.length || !rowIds.every(row => Number.isInteger(row) && row >= 0) || !Number.isInteger(startCol) || startCol < 0) { return; } const documentVersion = this.document.version; @@ -604,7 +668,7 @@ class CsvEditorController { } if (cut && this.document.version === documentVersion) { await this.replaceCells(matrix.flatMap((row, rowOffset) => row.map((_value, colOffset) => ({ - row: startRow + rowOffset, + row: rowIds[rowOffset], col: startCol + colOffset, value: '' })))); @@ -675,7 +739,8 @@ class CsvEditorController { matrix: string[][], anchorRow: number, anchorCol: number, - selection: PasteSelectionBounds | undefined + selection: PasteSelectionBounds | undefined, + targetRows?: number[] ): PasteApplyResult { const plan = CsvEditorController.computePastePlan(matrix, anchorRow, anchorCol, selection); if (!plan) { @@ -693,6 +758,11 @@ class CsvEditorController { }; } + const rowsToWrite = targetRows ?? Array.from({ length: plan.endRow - plan.startRow + 1 }, (_, index) => plan.startRow + index); + if (rowsToWrite.length) { + plan.startRow = rowsToWrite[0]; + plan.endRow = rowsToWrite[rowsToWrite.length - 1]; + } const updates: Array<{ row: number; col: number; value: string }> = []; let changed = false; let structuralChange = false; @@ -720,7 +790,7 @@ class CsvEditorController { if (plan.fillSelection) { const value = String(matrix[0][0] ?? ''); - for (let row = plan.startRow; row <= plan.endRow; row++) { + for (const row of rowsToWrite) { for (let col = plan.startCol; col <= plan.endCol; col++) { setCellValue(row, col, value); } @@ -728,7 +798,7 @@ class CsvEditorController { } else { for (let r = 0; r < matrix.length; r++) { for (let c = 0; c < matrix[r].length; c++) { - setCellValue(plan.startRow + r, plan.startCol + c, String(matrix[r][c] ?? '')); + setCellValue(rowsToWrite[r], plan.startCol + c, String(matrix[r][c] ?? '')); } } } @@ -764,7 +834,19 @@ class CsvEditorController { const result = Papa.parse(oldText, { dynamicTyping: false, delimiter: separator }); const data = result.data as string[][]; - const pasteResult = CsvEditorController.applyPasteMatrixToData(data, matrix, anchorRow, anchorCol, selection); + const realData = this.trimTrailingEmptyRows(data); + const filters = normalizeColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), data.reduce((max, row) => Math.max(max, row.length), 0)); + const visibleRows = [...visibleSourceRows(realData, this.getHiddenRows(), this.getEffectiveHeader(realData, this.getHiddenRows()), filters), realData.length]; + const anchorIndex = visibleRows.indexOf(anchorRow); + if (anchorIndex < 0) { + void vscode.window.showInformationMessage('CSV: Select a visible cell and paste again.'); + return; + } + const plan = CsvEditorController.computePastePlan(matrix, anchorRow, anchorCol, selection); + const targetRows = plan?.fillSelection + ? visibleRows.filter(row => row >= plan.startRow && row <= plan.endRow) + : matrix.map((_row, index) => visibleRows[anchorIndex + index] ?? realData.length + anchorIndex + index - visibleRows.length + 1); + const pasteResult = CsvEditorController.applyPasteMatrixToData(data, matrix, anchorRow, anchorCol, selection, targetRows); if (!pasteResult.changed) { return; } @@ -810,7 +892,7 @@ class CsvEditorController { if (start < state.allRowsCount) { const end = Math.min(start + state.chunkRows, state.allRowsCount); const html = state.allRows.slice(start, end).map((row, localR) => { - const absRow = state.startAbs + start + localR; + const absRow = state.rowIds[start + localR]; const displayIdx = start + localR + 1; let cells = ''; for (let cIdx = 0; cIdx < state.numColumns; cIdx++) { @@ -835,7 +917,7 @@ class CsvEditorController { } if (start === state.allRowsCount && state.includeTrailingEmptyRow) { - const virtualAbs = state.startAbs + state.allRowsCount; + const virtualAbs = state.virtualRow; const displayIdx = state.allRowsCount + 1; const idxCell = state.addSerialIndex ? `${displayIdx}` @@ -935,7 +1017,8 @@ class CsvEditorController { const offset = Math.min(Math.max(0, hiddenRows), data.length); const matches: Array<{ row: number; col: number; value: string }> = []; - for (let row = offset; row < data.length; row++) { + const filters = normalizeColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), data.reduce((max, row) => Math.max(max, row.length), 0)); + for (const row of visibleSourceRows(data, offset, this.getEffectiveHeader(data, hiddenRows), filters)) { const current = data[row] || []; for (let col = 0; col < current.length; col++) { const value = String(current[col] ?? ''); @@ -1017,6 +1100,7 @@ class CsvEditorController { const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); const data = result.data as string[][]; + const order = Array.from({ length: data.reduce((max, row) => Math.max(max, row.length), 0) }, (_, column) => column); for (const row of data) { if (index > row.length) { while (row.length < index) row.push(''); @@ -1033,6 +1117,8 @@ class CsvEditorController { edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); this.isUpdatingDocument = false; + order.splice(index, 0, -1); + await this.remapFilters(order); this.updateWebviewContent(); } @@ -1043,6 +1129,7 @@ class CsvEditorController { const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); const data = result.data as string[][]; + const order = Array.from({ length: data.reduce((max, row) => Math.max(max, row.length), 0) }, (_, column) => column); for (let k = 0; k < count; k++) { for (const row of data) { if (index > row.length) { @@ -1061,6 +1148,8 @@ class CsvEditorController { edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); this.isUpdatingDocument = false; + order.splice(index, 0, ...Array(count).fill(-1)); + await this.remapFilters(order); this.updateWebviewContent(); } @@ -1070,6 +1159,7 @@ class CsvEditorController { const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); const data = result.data as string[][]; + const order = Array.from({ length: data.reduce((max, row) => Math.max(max, row.length), 0) }, (_, column) => column); for (const row of data) { if (index < row.length) { row.splice(index, 1); @@ -1085,6 +1175,8 @@ class CsvEditorController { edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); this.isUpdatingDocument = false; + order.splice(index, 1); + await this.remapFilters(order); this.updateWebviewContent(); } @@ -1095,6 +1187,7 @@ class CsvEditorController { const text = this.document.getText(); const result = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); const data = result.data as string[][]; + const order = Array.from({ length: data.reduce((max, row) => Math.max(max, row.length), 0) }, (_, column) => column); const sorted = [...indices].sort((a,b)=>b-a); for (const idx of sorted) { for (const row of data) { @@ -1113,6 +1206,8 @@ class CsvEditorController { edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); this.isUpdatingDocument = false; + for (const column of sorted) { order.splice(column, 1); } + await this.remapFilters(order); this.updateWebviewContent(); } @@ -1163,10 +1258,13 @@ class CsvEditorController { return sa.localeCompare(sb, undefined, { sensitivity: 'base' }); }; - body.sort((r1, r2) => { + const filters = normalizeColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), rows.reduce((max, row) => Math.max(max, row.length), 0)); + const matchingSlots = body.map((_row, slot) => slot).filter(slot => rowMatchesFilters(body[slot], filters)); + const sortedMatches = matchingSlots.map(slot => body[slot]).sort((r1, r2) => { const diff = cmp(r1[index] ?? '', r2[index] ?? ''); return ascending ? diff : -diff; }); + matchingSlots.forEach((slot, order) => { body[slot] = sortedMatches[order]; }); const prefix = rows.slice(0, offset); const combined = treatHeader ? [...prefix, header, ...body] : [...prefix, ...body]; @@ -1375,6 +1473,7 @@ class CsvEditorController { const edit = new vscode.WorkspaceEdit(); edit.replace(this.document.uri, fullRange, newText); await vscode.workspace.applyEdit(edit); + await this.remapFilters(columnOrder); this.updateWebviewContent(); } finally { this.isUpdatingDocument = false; @@ -1438,6 +1537,7 @@ class CsvEditorController { const cellPadding = config.get('cellPadding', 4); const data = this.trimTrailingEmptyRows((parsed.data || []) as string[][]); + const filters = normalizeColumnFilters(CsvEditorProvider.getColumnFiltersForUri(this.context, this.document.uri), data.reduce((max, row) => Math.max(max, row.length), 0)); const treatHeader = this.getEffectiveHeader(data, hiddenRows); this.currentHeader = treatHeader; const clickableLinks = config.get('clickableLinks', true); @@ -1463,7 +1563,8 @@ class CsvEditorController { columnColorMode, columnColorPalette, showTrailingEmptyRow, - /* maxSerializedChunks */ 0 + /* maxSerializedChunks */ 0, + filters ); this.chunkRenderState = chunkState; @@ -1486,7 +1587,12 @@ class CsvEditorController { columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri), frozenPanes: this.getCurrentFrozenPanes(), firstVisibleRow: hiddenRows, - realRowCount: data.length + realRowCount: data.length, + visibleRowIds: [...visibleSourceRows(data, hiddenRows, treatHeader, filters), data.length], + filters, + matchedRows: visibleSourceRows(data, hiddenRows, false, filters).filter(row => !(treatHeader && row === hiddenRows)).length, + totalFilterRows: Math.max(0, data.length - hiddenRows - (treatHeader ? 1 : 0)), + filterHeader: treatHeader ? data[hiddenRows] : undefined }); } @@ -1499,7 +1605,8 @@ class CsvEditorController { columnColorMode: string, columnColorPalette: string, showTrailingEmptyRow: boolean, - maxSerializedChunks: number = Number.MAX_SAFE_INTEGER + maxSerializedChunks: number = Number.MAX_SAFE_INTEGER, + filters: ColumnFilter[] = [] ): { tableHtml: string; chunksJson: string; @@ -1524,8 +1631,11 @@ class CsvEditorController { } else { bodyData = data.slice(offset); } + const bodyStart = headerFlag ? offset + 1 : offset; + const rowIds = bodyData.map((_row, index) => bodyStart + index).filter(row => rowMatchesFilters(data[row], filters)); + bodyData = rowIds.map(row => data[row]); const visibleForWidth = headerFlag ? [headerRow, ...bodyData] : bodyData; - let numColumns = visibleForWidth.reduce((max, row) => Math.max(max, row.length), 0); + let numColumns = data.reduce((max, row) => Math.max(max, row.length), 0); if (numColumns === 0) numColumns = 1; // ensure at least 1 column for the virtual row const columnData = Array.from({ length: numColumns }, (_, i) => bodyData.map(row => row[i] || '')); @@ -1542,7 +1652,7 @@ class CsvEditorController { const BASE_CHUNK_ROWS = 1000; const MAX_CELLS_PER_CHUNK = 20000; const MIN_CHUNK_ROWS = 10; - const allRows = headerFlag ? data.slice(offset + 1) : data.slice(offset); + const allRows = bodyData; const allRowsCount = allRows.length; // preserve total before any truncation const chunkRows = Math.max( MIN_CHUNK_ROWS, @@ -1569,7 +1679,7 @@ class CsvEditorController { } const htmlChunk = allRows.slice(i, i + chunkRows).map((row, localR) => { const startAbs = headerFlag ? offset + 1 : offset; - const absRow = startAbs + i + localR; + const absRow = rowIds[i + localR]; const displayIdx = i + localR + 1; // numbering relative to first visible data row let cells = ''; for (let cIdx = 0; cIdx < numColumns; cIdx++) { @@ -1602,26 +1712,26 @@ class CsvEditorController { }`; for (let i = 0; i < numColumns; i++) { const safe = this.formatCellContent(headerRow[i] || '', clickableLinks); - tableHtml += `${safe}`; + tableHtml += `${safe}`; } tableHtml += ``; const initialBodyRows = chunked ? allRows.slice(0, chunkRows) : allRows; initialBodyRows.forEach((row, r) => { tableHtml += `${ addSerialIndex - ? `${r + 1}` + ? `${r + 1}` : '' }`; for (let i = 0; i < numColumns; i++) { const rawValue = row[i] || ''; const safe = this.formatCellContent(rawValue, clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - tableHtml += `${safe}`; + tableHtml += `${safe}`; } tableHtml += ``; }); if (!chunked && includeTrailingEmptyRow) { - const virtualAbs = offset + 1 + initialBodyRows.length; + const virtualAbs = data.length; const idxCell = addSerialIndex ? `${initialBodyRows.length + 1}` : ''; const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); tableHtml += `${idxCell}${dataCells}`; @@ -1633,19 +1743,19 @@ class CsvEditorController { nonHeaderRows.forEach((row, r) => { tableHtml += `${ addSerialIndex - ? `${r + 1}` + ? `${r + 1}` : '' }`; for (let i = 0; i < numColumns; i++) { const rawValue = row[i] || ''; const safe = this.formatCellContent(rawValue, clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - tableHtml += `${safe}`; + tableHtml += `${safe}`; } tableHtml += ``; }); if (!chunked && includeTrailingEmptyRow) { - const virtualAbs = offset + nonHeaderRows.length; + const virtualAbs = data.length; const displayIdx = nonHeaderRows.length + 1; const idxCell = addSerialIndex ? `${displayIdx}` : ''; const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); @@ -1658,7 +1768,7 @@ class CsvEditorController { if (chunked && includeTrailingEmptyRow) { if (nextChunkStart === -1 && serializedChunkCount < safeMaxSerializedChunks) { const startAbs = headerFlag ? offset + 1 : offset; - const virtualAbs = startAbs + allRowsCount; + const virtualAbs = data.length; const displayIdx = allRowsCount + 1; const idxCell = addSerialIndex ? `${displayIdx}` : ''; const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); @@ -1672,6 +1782,8 @@ class CsvEditorController { const hasRemoteChunks = nextChunkStart >= 0; const chunkState: ChunkRenderState | undefined = chunked ? { + rowIds, + virtualRow: data.length, startAbs: headerFlag ? offset + 1 : offset, allRows, allRowsCount, @@ -1743,8 +1855,13 @@ class CsvEditorController { frozenPanes: FrozenPanes; firstVisibleRow: number; realRowCount: number; + visibleRowIds: number[]; + filters: ColumnFilter[]; + matchedRows: number; + totalFilterRows: number; + filterHeader: string[] | undefined; }): string { - const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes, frozenPanes, firstVisibleRow, realRowCount } = args; + const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes, frozenPanes, firstVisibleRow, realRowCount, visibleRowIds, filters, matchedRows, totalFilterRows, filterHeader } = args; const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; // Build script URI using file path for compatibility (older APIs may lack Uri.joinPath) const scriptUri = webview.asWebviewUri( @@ -1968,12 +2085,17 @@ class CsvEditorController { #csv-root .csv-frozen-row.active-match, #csv-root .csv-frozen-column.active-match { background-image: linear-gradient(var(--vscode-editor-findMatchBackground, transparent), var(--vscode-editor-findMatchBackground, transparent)); } + #filterStatus { position: sticky; top: 0; left: 0; z-index: 6; display: flex; gap: 8px; align-items: center; padding: 6px; background: var(--vscode-editorWidget-background, ${isDark ? '#252526' : '#ffffff'}); color: var(--vscode-editorWidget-foreground, inherit); border-bottom: 1px solid var(--vscode-editorWidget-border, ${isDark ? '#555' : '#ccc'}); } + #filterStatus button { color: inherit; background: transparent; border: 1px solid var(--vscode-button-border, currentColor); border-radius: 3px; cursor: pointer; } + th[data-filter-column="active"]::after { content: " ⏷"; } + #filterStatus button:focus-visible { outline: 2px solid var(--vscode-focusBorder); } /* Per-column computed colors */ ${extraColumnColorCss} -
+
+ ${filters.length ? `
Filtered: ${matchedRows} of ${totalFilterRows} rows${filters.map(filter => ``).join('')}
` : ''} ${tableHtml}
@@ -2286,6 +2408,7 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { public static readonly sepKey = 'csv.separatorByUri'; public static readonly columnSizesKey = 'csv.columnSizesByUri'; public static readonly frozenPanesKey = 'csv.frozenPanesByUri'; + public static readonly columnFiltersKey = 'csv.columnFiltersByUri'; private static readonly DEFAULT_SEPARATOR = ','; private static readonly DEFAULT_SEPARATOR_MODE: SeparatorMode = 'extension'; private static readonly BUILTIN_SEPARATORS_BY_EXTENSION: Record = { @@ -2666,6 +2789,22 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { await context.workspaceState.update(CsvEditorProvider.serialKey, map); } + public static getColumnFiltersForUri(context: vscode.ExtensionContext, uri: vscode.Uri): ColumnFilter[] { + const map = context.workspaceState.get>(CsvEditorProvider.columnFiltersKey, {}); + return normalizeColumnFilters(map[uri.toString()]); + } + + public static async setColumnFiltersForUri(context: vscode.ExtensionContext, uri: vscode.Uri, filters: unknown): Promise { + const map = { ...context.workspaceState.get>(CsvEditorProvider.columnFiltersKey, {}) }; + const normalized = normalizeColumnFilters(filters); + if (normalized.length) { + map[uri.toString()] = normalized; + } else { + delete map[uri.toString()]; + } + await context.workspaceState.update(CsvEditorProvider.columnFiltersKey, map); + } + public static getFrozenPanesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, header: boolean): FrozenPanes { const map = context.workspaceState.get>>(CsvEditorProvider.frozenPanesKey, {}); const saved = map[uri.toString()] ?? {}; diff --git a/src/column-filters.ts b/src/column-filters.ts new file mode 100644 index 0000000..444d0bc --- /dev/null +++ b/src/column-filters.ts @@ -0,0 +1,57 @@ +export type ColumnFilterOperator = 'contains' | 'equals' | 'empty' | 'notEmpty'; +export type ColumnFilter = { column: number; operator: ColumnFilterOperator; value: string }; +export const MAX_FILTER_VALUE_LENGTH = 4096; +export const MAX_COLUMN_FILTERS = 256; +const operators = new Set(['contains', 'equals', 'empty', 'notEmpty']); + +export function normalizeColumnFilters(raw: unknown, columnCount = Infinity): ColumnFilter[] { + if (!Array.isArray(raw)) { + return []; + } + const byColumn = new Map(); + for (const item of raw.slice(0, MAX_COLUMN_FILTERS)) { + if (!item || typeof item !== 'object') { + continue; + } + const { column, operator, value } = item as Partial; + if (typeof column !== 'number' || !Number.isSafeInteger(column) || column < 0 || column >= columnCount || !operators.has(operator as ColumnFilterOperator) || typeof value !== 'string' || value.length > MAX_FILTER_VALUE_LENGTH) { + continue; + } + byColumn.set(column, { column, operator: operator!, value }); + } + return [...byColumn.values()].sort((a, b) => a.column - b.column); +} + +export function rowMatchesFilters(row: string[], filters: ColumnFilter[]): boolean { + return filters.every(filter => { + const value = String(row[filter.column] ?? ''); + if (filter.operator === 'empty') { + return value === ''; + } + if (filter.operator === 'notEmpty') { + return value !== ''; + } + const text = value.toLowerCase(); + const query = filter.value.toLowerCase(); + return filter.operator === 'equals' ? text === query : text.includes(query); + }); +} + +export function visibleSourceRows(data: string[][], hiddenRows: number, header: boolean, filters: ColumnFilter[]): number[] { + const start = Math.min(Math.max(0, hiddenRows), data.length); + const rows: number[] = []; + for (let row = start; row < data.length; row++) { + if ((header && row === start) || rowMatchesFilters(data[row], filters)) { + rows.push(row); + } + } + return rows; +} + +// new column index -> original index; -1 denotes an inserted empty column. +export function remapColumnFilters(filters: ColumnFilter[], columnOrder: number[]): ColumnFilter[] { + return columnOrder.flatMap((oldColumn, column) => { + const filter = filters.find(item => item.column === oldColumn); + return filter ? [{ ...filter, column }] : []; + }); +} diff --git a/src/commands.ts b/src/commands.ts index 9867976..b92ab2e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -144,7 +144,7 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { // Refresh only editors showing this URI CsvEditorProvider.editors .filter(ed => ed.getDocumentUri().toString() === uri.toString()) - .forEach(ed => ed.refreshHiddenRows()); + .forEach(ed => ed.refreshViewSafely()); 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 () => { diff --git a/src/extension.ts b/src/extension.ts index c163cf5..4777710 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -89,7 +89,7 @@ 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()); + .forEach(editor => editor.refreshViewSafely()); } const keys = [ diff --git a/src/test/column-filter-view.test.ts b/src/test/column-filter-view.test.ts new file mode 100644 index 0000000..51c7f97 --- /dev/null +++ b/src/test/column-filter-view.test.ts @@ -0,0 +1,195 @@ +import assert from 'assert'; +import Papa from 'papaparse'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); +const filter = [{ column: 1, operator: 'equals', value: 'keep' }]; +const rows = [['Name', 'State'], ['one', 'keep'], ['hidden', 'drop'], ['three', 'KEEP']]; + +async function fixture(options: Parameters[0] = {}) { + const host = await createEditorHarness({ rows, filters: filter, ...options }); + const view = createWebviewDom(host.panel.webview.html); + return { host, view }; +} +async function refresh(host: Awaited>, view: ReturnType) { + const prepare = host.messages.slice().reverse().find((message: any) => message.type === 'prepareViewRefresh'); + assert.ok(prepare); + const start = view.messages.length; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: prepare })); + for (const message of view.messages.slice(start)) { await host.receive(message); } +} + +describe('Full-source filtered view operations', () => { + it('filters beyond the initial source chunk and shows conspicuous removable active predicates', async () => { + const source = [['Name', 'State'], ...Array.from({ length: 2004 }, (_, row) => [`row${row}`, row === 1500 || row === 1998 ? 'keep' : 'drop'])]; + const { host, view } = await fixture({ rows: source }); + try { + assert.strictEqual(view.cell(1501, 0).textContent, 'row1500'); + assert.strictEqual(view.cell(1999, 0).textContent, 'row1998'); + assert.strictEqual(view.cell(1, 0), null); + assert.ok(view.window.document.getElementById('filterStatus').textContent.includes('2 of 2004 rows')); + assert.ok(view.window.document.querySelector('[data-clear-filter="1"]')); + assert.ok(view.window.document.getElementById('clearAllFilters')); + assert.strictEqual(view.cell(0, 1).getAttribute('data-filter-column'), 'active'); + assert.deepStrictEqual(host.rows(), source); + } finally { view.close(); } + }); + + it('keeps original absolute IDs in remote filtered chunks', async () => { + const source = [['Name', 'State'], ...Array.from({ length: 3000 }, (_, row) => [`row${row}`, row % 2 === 0 ? 'keep' : 'drop'])]; + const { host, view } = await fixture({ rows: source }); + try { + assert.ok(view.cell(1999, 0)); + assert.strictEqual(view.cell(2001, 0), null); + view.window.__csvLoadNextChunk(); + const request = view.messages.slice().reverse().find((message: any) => message.type === 'requestChunk'); + await host.receive(request); + const response = host.messages.slice().reverse().find((message: any) => message.type === 'chunkData'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: response })); + view.window.__csvLoadNextChunk(); + assert.strictEqual(view.cell(2001, 0).textContent, 'row2000'); + assert.strictEqual(view.cell(2002, 0), null); + } finally { view.close(); } + }); + + it('copies and cuts only selected visible source rows across gaps', async () => { + const { host, view } = await fixture(); + try { + view.window.editingTestApi.selectRange({ row: 1, col: 0 }, { row: 3, col: 1 }); + view.cell(1, 0).dispatchEvent(new view.window.Event('copy', { bubbles: true, cancelable: true })); + const copy = view.messages.find((message: any) => message.type === 'copyCells'); + assert.deepStrictEqual(Array.from(copy.rowIds), [1, 3]); + await host.receive(copy); + assert.deepStrictEqual(Papa.parse(host.clipboardText).data, [['one', 'keep'], ['three', 'KEEP']]); + await host.receive({ ...copy, cut: true }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['', ''], ['hidden', 'drop'], ['', '']]); + } finally { view.close(); } + }); + + it('pastes a matrix across visible rows and appends at the physical end without touching hidden rows', async () => { + const { host, view } = await fixture(); + try { + await host.receive({ type: 'pasteCells', text: 'first\nsecond\nappended', anchorRow: 1, anchorCol: 0 }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['first', 'keep'], ['hidden', 'drop'], ['second', 'KEEP'], ['appended']]); + await host.document.save(); + assert.deepStrictEqual(Papa.parse(host.diskText).data, host.rows()); + } finally { view.close(); } + }); + + it('fills only visible cells in a selected rectangle, leaving hidden records intact', async () => { + const { host, view } = await fixture(); + try { + view.window.editingTestApi.selectRange({ row: 1, col: 0 }, { row: 3, col: 1 }); + const event = new view.window.Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { getData: () => 'filled' } }); + view.cell(1, 0).dispatchEvent(event); + const paste = view.messages.find((message: any) => message.type === 'pasteCells'); + assert.strictEqual(paste.selection.rectangular, true); + await host.receive(paste); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['filled', 'filled'], ['hidden', 'drop'], ['filled', 'filled']]); + } finally { view.close(); } + }); + + it('finds and replaces only matching rows while preserving preambles and hidden values', async () => { + const source = [['metadata', 'keep'], ['Name', 'State'], ['needle one', 'keep'], ['needle hidden', 'drop'], ['needle two', 'KEEP']]; + const { host, view } = await fixture({ rows: source, hiddenRows: 1 }); + try { + await host.receive({ type: 'findMatches', requestId: 1, query: 'needle', options: {} }); + const found = host.messages.slice().reverse().find((message: any) => message.type === 'findMatchesResult'); + assert.deepStrictEqual(found.matches.map((match: any) => match.row), [2, 4]); + await host.receive({ type: 'replaceCells', replacements: found.matches.map((match: any) => ({ row: match.row, col: match.col, value: 'replaced' })) }); + assert.deepStrictEqual(host.rows(), [['metadata', 'keep'], ['Name', 'State'], ['replaced', 'keep'], ['needle hidden', 'drop'], ['replaced', 'KEEP']]); + } finally { view.close(); } + }); + + it('sorts matching slots and reorders/deletes by absolute source indices', async () => { + const source = [['Name', 'State'], ['z', 'keep'], ['hidden', 'drop'], ['a', 'KEEP']]; + const { host, view } = await fixture({ rows: source }); + try { + await host.receive({ type: 'sortColumn', index: 0, ascending: true }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['a', 'KEEP'], ['hidden', 'drop'], ['z', 'keep']]); + await host.receive({ type: 'reorderRows', indices: [3], beforeIndex: 1 }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['z', 'keep'], ['a', 'KEEP'], ['hidden', 'drop']]); + await host.receive({ type: 'deleteRows', indices: [1, 2] }); + assert.deepStrictEqual(host.rows(), [['Name', 'State'], ['hidden', 'drop']]); + } finally { view.close(); } + }); + + it('moves filters with inserted/reordered columns and clears a deleted column predicate', async () => { + const { host, view } = await fixture(); + try { + const getFilters = () => CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any); + await host.receive({ type: 'insertColumn', index: 0 }); + assert.strictEqual(getFilters()[0].column, 2); + await host.receive({ type: 'reorderColumns', indices: [2], beforeIndex: 0 }); + assert.strictEqual(getFilters()[0].column, 0); + await host.receive({ type: 'deleteColumn', index: 0 }); + assert.deepStrictEqual(getFilters(), []); + assert.ok(host.rows().some(row => row.includes('hidden'))); + } finally { view.close(); } + }); + + it('navigates/selects visible rows and freezes visible leading rows with a toolbar inset', async () => { + const { view } = await fixture({ frozenRows: 3, frozenColumns: 1 }); + try { + view.window.editingTestApi.setSingleSelection(view.cell(1, 0)); + view.key('ArrowDown'); + assert.strictEqual(view.window.document.activeElement, view.cell(3, 0)); + view.key('ArrowUp', { shiftKey: true }); + assert.strictEqual(view.window.document.querySelectorAll('td.selected[data-col="0"]').length, 2); + const status = view.window.document.getElementById('filterStatus'); + status.getBoundingClientRect = () => ({ height: 20 }); + for (const row of Array.from(view.window.document.querySelectorAll('tr')) as any[]) { row.getBoundingClientRect = () => ({ height: 30 }); } + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(0, 0).style.top, '20px'); + assert.strictEqual(view.cell(1, 0).style.top, '50px'); + assert.strictEqual(view.cell(3, 0).style.top, '80px'); + assert.ok(!view.cell(4, 0).classList.contains('csv-frozen-row')); + } finally { view.close(); } + }); + + it('handles zero matches with headers off and keeps the append row at the full-source end', async () => { + const { host, view } = await fixture({ header: false, filters: [{ column: 1, operator: 'equals', value: 'absent' }], frozenRows: 10 }); + try { + assert.strictEqual(view.window.document.querySelectorAll('tbody tr').length, 1); + assert.ok(view.cell(4, 0)); + view.window.csvFrozenPanes.layout(); + assert.ok(!view.cell(4, 0).classList.contains('csv-frozen-row')); + await host.receive({ type: 'pasteCells', text: 'new', anchorRow: 4, anchorCol: 0 }); + assert.deepStrictEqual(host.rows().slice(0, 4), rows); + assert.strictEqual(host.rows()[4][0], 'new'); + } finally { view.close(); } + }); + + it('persists configured filters, escapes filter text, and clears all through the visible control', async () => { + const { host, view } = await fixture({ filters: [] }); + try { + host.filterChoice = { label: 'Equals', operator: 'equals' }; + host.filterInput = ''; + await host.receive({ type: 'configureColumnFilter', column: 0 }); + await refresh(host, view); + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any), [{ column: 0, operator: 'equals', value: host.filterInput }]); + assert.ok(host.panel.webview.html.includes('</button><script>')); + const next = createWebviewDom(host.panel.webview.html); + try { + next.window.document.getElementById('clearAllFilters').click(); + await host.receive(next.messages.find((message: any) => message.type === 'clearAllFilters')); + await refresh(host, next); + assert.deepStrictEqual(CsvEditorProvider.getColumnFiltersForUri(host.context as any, host.document.uri as any), []); + assert.ok(!host.panel.webview.html.includes('id="filterStatus"')); + assert.deepStrictEqual(host.rows(), rows); + } finally { next.close(); } + } finally { view.close(); } + }); + + it('re-evaluates an edited filter value using the safe commit-before-refresh path', async () => { + const { host, view } = await fixture(); + try { + await host.receive({ type: 'editCell', row: 1, col: 1, value: 'drop' }); + await refresh(host, view); + assert.ok(!host.panel.webview.html.includes('data-row="1"')); + assert.strictEqual(host.rows()[1][1], 'drop'); + assert.deepStrictEqual(host.rows()[2], ['hidden', 'drop']); + } finally { view.close(); } + }); +}); diff --git a/src/test/column-filters.test.ts b/src/test/column-filters.test.ts new file mode 100644 index 0000000..f7470d1 --- /dev/null +++ b/src/test/column-filters.test.ts @@ -0,0 +1,38 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { normalizeColumnFilters, rowMatchesFilters, visibleSourceRows, remapColumnFilters, MAX_COLUMN_FILTERS, MAX_FILTER_VALUE_LENGTH } from '../column-filters'; + +describe('Column filter predicates and identities', () => { + it('combines case-insensitive text predicates and exact empty-cell predicates', () => { + assert.ok(rowMatchesFilters(['Alpha Beta', 'YES', ''], [{ column: 0, operator: 'contains', value: 'BETA' }, { column: 1, operator: 'equals', value: 'yes' }, { column: 2, operator: 'empty', value: '' }])); + assert.ok(!rowMatchesFilters(['Alpha', 'yes', ''], [{ column: 0, operator: 'contains', value: 'beta' }])); + assert.ok(rowMatchesFilters([], [{ column: 2, operator: 'empty', value: '' }])); + assert.ok(!rowMatchesFilters([''], [{ column: 0, operator: 'notEmpty', value: '' }])); + assert.ok(rowMatchesFilters([' '], [{ column: 0, operator: 'notEmpty', value: '' }])); + }); + + it('keeps absolute row identities, a real header, and excludes hidden preambles', () => { + const data = [['metadata'], ['Header'], ['keep'], ['drop'], ['KEEP']]; + const filters = [{ column: 0, operator: 'equals' as const, value: 'keep' }]; + assert.deepStrictEqual(visibleSourceRows(data, 1, true, filters), [1, 2, 4]); + assert.deepStrictEqual(visibleSourceRows(data, 1, false, filters), [2, 4]); + assert.deepStrictEqual(visibleSourceRows(data, 100, true, filters), []); + }); + + it('normalizes bounded state without evaluating regex or accepting invalid predicates', () => { + const normalized = normalizeColumnFilters([ + { column: 1, operator: 'equals', value: 'before' }, { column: 1, operator: 'contains', value: '' }, + { column: -1, operator: 'empty', value: '' }, { column: 0, operator: 'regex', value: '(a+)+' }, + { column: 2, operator: 'contains', value: 'a'.repeat(MAX_FILTER_VALUE_LENGTH + 1) } + ], 3); + assert.deepStrictEqual(normalized, [{ column: 1, operator: 'contains', value: '' }]); + assert.strictEqual(normalizeColumnFilters(Array.from({ length: MAX_COLUMN_FILTERS + 10 }, (_, column) => ({ column, operator: 'empty', value: '' }))).length, MAX_COLUMN_FILTERS); + assert.deepStrictEqual(normalizeColumnFilters(null), []); + }); + + it('moves predicates with their column and removes predicates for deleted columns', () => { + const filters = [{ column: 0, operator: 'equals' as const, value: 'x' }, { column: 2, operator: 'empty' as const, value: '' }]; + assert.deepStrictEqual(remapColumnFilters(filters, [2, -1, 0, 1]), [{ column: 0, operator: 'empty', value: '' }, { column: 2, operator: 'equals', value: 'x' }]); + assert.deepStrictEqual(remapColumnFilters(filters, [1, 2]), [{ column: 1, operator: 'empty', value: '' }]); + }); +}); diff --git a/src/test/default-ignore-rows-commands.test.ts b/src/test/default-ignore-rows-commands.test.ts index 00ef684..c09e6a9 100644 --- a/src/test/default-ignore-rows-commands.test.ts +++ b/src/test/default-ignore-rows-commands.test.ts @@ -28,7 +28,7 @@ describe('Hidden-row override controls', () => { activate(context); const uri = { toString: () => 'file:///sample.csv' } as any; let refreshed = 0; - const active = { getDocumentUri: () => uri, refreshHiddenRows: () => refreshed++ } as any; + const active = { getDocumentUri: () => uri, refreshViewSafely: () => refreshed++ } as any; CsvEditorProvider.currentActive = active; CsvEditorProvider.editors = [active]; input = '0'; diff --git a/src/test/default-ignore-rows.test.ts b/src/test/default-ignore-rows.test.ts index 44ce8c2..4a0da63 100644 --- a/src/test/default-ignore-rows.test.ts +++ b/src/test/default-ignore-rows.test.ts @@ -47,12 +47,12 @@ describe('Default hidden rows', () => { 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'); + CsvEditorProvider.currentActive!.refreshViewSafely(); + const prepare = host.messages.slice().reverse().find((message: any) => message.type === 'prepareViewRefresh'); 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']); + const messages = view.messages.filter((message: any) => ['editCell', 'viewRefreshReady'].includes(message.type)); + assert.deepStrictEqual(messages.map((message: any) => message.type), ['editCell', 'viewRefreshReady']); 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']); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index d0e5894..5e8cd98 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -19,7 +19,8 @@ const vscodeStub = { replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } }, ColorThemeKind: { Dark: 1 }, - window: { activeColorTheme: { kind: 1 } }, + window: { activeColorTheme: { kind: 1 }, showQuickPick: async () => activeHarness.filterChoice, showInputBox: async () => activeHarness.filterInput, showInformationMessage: (text: string) => activeHarness.notifications.push(text), showErrorMessage: (text: string) => activeHarness.notifications.push(text) }, + env: { clipboard: { writeText: async (text: string) => { activeHarness.clipboardText = text; }, readText: async () => activeHarness.clipboardText } }, workspace: { getConfiguration: (_section: string, resource?: TestUri) => { if (activeHarness) { activeHarness.configurationRequests.push(resource); } @@ -56,6 +57,7 @@ export async function createEditorHarness(options: { frozenColumns?: number; hiddenRows?: number; defaultIgnoreRows?: number; + filters?: unknown; } = {}) { const separator = options.separator ?? ','; let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); @@ -91,6 +93,7 @@ export async function createEditorHarness(options: { } }; await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); + await CsvEditorProvider.setColumnFiltersForUri(context as any, uri as any, options.filters); if (options.hiddenRows !== undefined) { await CsvEditorProvider.setHiddenRowsForUri(context as any, uri as any, options.hiddenRows); } @@ -113,6 +116,10 @@ export async function createEditorHarness(options: { const harness = { document, panel, messages, changeListeners, saveListeners, context, defaultIgnoreRows: options.defaultIgnoreRows ?? 0, + clipboardText: '', + filterChoice: undefined as any, + filterInput: undefined as string | undefined, + notifications: [] as string[], resourceDefaults: {} as Record, configurationRequests: [] as Array, get diskText() { return diskText; }, @@ -163,7 +170,7 @@ export function createWebviewDom(html: string) { const source = fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'); const freezeSource = fs.readFileSync(path.join(process.cwd(), 'media', 'frozen-panes.js'), 'utf8'); window.eval(freezeSource); - window.eval(`${source}\nwindow.editingTestApi = { editCell, setSingleSelection };`); + window.eval(`${source}\nwindow.editingTestApi = { editCell, setSingleSelection, selectRange, selectFullRowRange };`); return { window, messages, close() { window.close(); }, diff --git a/src/test/provider-clipboard.test.ts b/src/test/provider-clipboard.test.ts index 79acc37..cdca05d 100644 --- a/src/test/provider-clipboard.test.ts +++ b/src/test/provider-clipboard.test.ts @@ -66,7 +66,7 @@ describe('Provider system clipboard integration', () => { const delimiter = extension === 'csv' ? ',' : '\t'; const editor = await createEditor(`old${delimiter}old\nold${delimiter}old`, extension); const matrix = [['literal,comma', 'line\nvalue'], ['"quote"', '']]; - await editor.receive({ type: 'copyCells', matrix, startRow: 0, startCol: 0, cut: false }); + await editor.receive({ type: 'copyCells', matrix, rowIds: [0, 1], startCol: 0, cut: false }); assert.strictEqual(editor.clipboardWrites.length, 1); assert.deepStrictEqual(Papa.parse(editor.clipboardWrites[0], { delimiter }).data, matrix); assert.strictEqual(editor.text, `old${delimiter}old\nold${delimiter}old`); @@ -87,7 +87,7 @@ describe('Provider system clipboard integration', () => { await pendingWrite; editor.events.push('write'); }; - const cut = editor.receive({ type: 'copyCells', matrix: [['one', 'two']], startRow: 0, startCol: 0, cut: true }); + const cut = editor.receive({ type: 'copyCells', matrix: [['one', 'two']], rowIds: [0], startCol: 0, cut: true }); await writeStarted; assert.strictEqual(editor.text, 'one,two\nthree,four'); finishWrite(); @@ -99,7 +99,7 @@ describe('Provider system clipboard integration', () => { it('keeps cut data when the clipboard write fails', async () => { const editor = await createEditor('one,two'); writeClipboard = async () => { throw new Error('clipboard unavailable'); }; - await editor.receive({ type: 'copyCells', matrix: [['one']], startRow: 0, startCol: 0, cut: true }); + await editor.receive({ type: 'copyCells', matrix: [['one']], rowIds: [0], startCol: 0, cut: true }); assert.strictEqual(editor.text, 'one,two'); assert.deepStrictEqual(editor.events, []); assert.strictEqual(errors.length, 1); @@ -108,7 +108,7 @@ describe('Provider system clipboard integration', () => { it('keeps newer document contents if a cut write completes after another edit', async () => { const editor = await createEditor('one,two'); writeClipboard = async () => { editor.mutate(); }; - await editor.receive({ type: 'copyCells', matrix: [['one']], startRow: 0, startCol: 0, cut: true }); + await editor.receive({ type: 'copyCells', matrix: [['one']], rowIds: [0], startCol: 0, cut: true }); assert.strictEqual(editor.text, 'newer,value'); assert.deepStrictEqual(editor.events, []); }); diff --git a/src/test/webview-clipboard.test.ts b/src/test/webview-clipboard.test.ts index a81f101..2e5415c 100644 --- a/src/test/webview-clipboard.test.ts +++ b/src/test/webview-clipboard.test.ts @@ -153,7 +153,7 @@ describe('Shipped webview clipboard interactions', () => { view.select(0, -1, 1, 1); const event = view.dispatch(target, 'copy'); assert.strictEqual(event.defaultPrevented, true); - assert.deepStrictEqual(view.messages, [{ type: 'copyCells', matrix: [['Header', ''], ['literal,comma\nand newline', ' ']], startRow: 0, startCol: 0, cut: false }]); + assert.deepStrictEqual(view.messages, [{ type: 'copyCells', matrix: [['Header', ''], ['literal,comma\nand newline', ' ']], rowIds: [0, 1], startCol: 0, cut: false }]); }); it('routes grid Ctrl/Cmd+C and X through system clipboard messages', () => { From 511bfc6a9ea5a68be3dc65b2c0897800e26bc67f Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:32:44 -0400 Subject: [PATCH 05/11] Clarify filtered operation scopes and preserve native control focus --- README.md | 8 +++++--- media/frozen-panes.js | 8 ++++---- media/main.js | 15 ++++++++------- src/CsvEditorProvider.ts | 13 +++++-------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6c13f3a..bf77cd7 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Add/Delete Columns:** Right-click any cell to add a column left or right, or remove the selected column. - **Add/Delete Rows:** Insert above/below or remove the selected row via context menu. - **Edit Empty CSVs:** Create or open an empty CSV file and start typing immediately. -- **Column Sorting:** Right-click a header and choose A–Z or Z–A. +- **Column Sorting:** Right-click a header and choose A–Z or Z–A. With filters active, sorting rearranges matching rows within their source positions and leaves hidden rows in place. +- **Column Filters:** Right-click a column and choose Filter this column. Combine contains, equals, empty or not-empty conditions across columns; text matching ignores case. The active-filter bar shows matching/total row counts, removable conditions and Clear all filters. Filters search the full file and are remembered per file. - **Custom Font Controls:** Choose a font family and optional font-size override, or inherit VS Code defaults. - **In-View Zoom:** Use `Ctrl/Cmd + Mouse Wheel` or `Ctrl/Cmd + +/-/0` to zoom the CSV view without changing global editor zoom. - **Find & Replace Overlay:** Built-in find/replace bar with match options (case, whole-word, regex), keyboard navigation, and single/all replace actions across the full file (including chunked rows). @@ -75,7 +76,7 @@ Cursor (built on VS Code 1.99) and the latest VS Code releases (1.102). - Detail edit: press `Enter` on a selected cell or double‑click to enter a focused edit. Arrow Left/Right move the text caret; Arrow Up goes to start; Arrow Down goes to end. Click outside the cell (or blur) to save. - **Keyboard Navigation:** Use Arrow keys to move between cells when not editing. Use `Tab`/`Shift+Tab` to move horizontally (wrapping across rows as needed). - **Multi-Cell Selection:** Click and drag or use `Shift + Click` to select multiple cells, then copy them as CSV using `Ctrl/Cmd + C`. -- **Find & Replace:** Press `Ctrl/Cmd + F` to open Find, or `Ctrl/Cmd + H` to open Find + Replace. +- **Find & Replace:** Press `Ctrl/Cmd + F` to open Find, or `Ctrl/Cmd + H` to open Find + Replace. With filters active, Find and Replace All operate on the visible filtered table (including its header); hidden metadata and nonmatching rows are excluded. --- @@ -124,6 +125,7 @@ Per-file (stored by the extension; set via commands): - 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 (inherits `csv.defaultIgnoreRows`) — `CSV: Hide First N Rows`. An explicit `0` overrides a nonzero default; blank input clears the per-file override. +- Column filter conditions — set from a column’s context menu and remove from the active-filter bar. Conditions, including any entered CSV text, are stored locally in VS Code workspace state. Clear all filters removes the saved conditions for that file. No CSV rows are removed by filtering. - 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. --- @@ -141,7 +143,7 @@ Per-file (stored by the extension; set via commands): - Save the file: `Ctrl/Cmd + S` commits the active cell before saving, including all inserted line breaks. - Global: - Copy selection: `Ctrl/Cmd + C` - - Paste selection: `Ctrl/Cmd + V` (selection mode). Pasting a single value into a selected rectangle fills that rectangle. + - Paste selection: `Ctrl/Cmd + V` (selection mode). Pasting a single value into a selected rectangle fills that rectangle. With filters active, copying/cutting and filling affect visible selected rows; matrix paste advances through matching rows and appends at the physical end when needed. Nonmatching rows remain intact, and saving always includes the full source file. - Zoom in/out/reset: `Ctrl/Cmd + +`, `Ctrl/Cmd + -`, `Ctrl/Cmd + 0` (also `Ctrl/Cmd + Mouse Wheel`) - Find: `Ctrl/Cmd + F` - Replace: `Ctrl/Cmd + H` diff --git a/media/frozen-panes.js b/media/frozen-panes.js index 47a1a79..536f0f6 100644 --- a/media/frozen-panes.js +++ b/media/frozen-panes.js @@ -43,11 +43,11 @@ window.createFrozenPaneManager = ({ table, firstRow, realRowCount, rowOrder, top } } let top = topInset?.() || 0; - const visibleRows = rowOrder?.length ? rowOrder.filter(row => row < realRowCount) : rendered.filter(row => row.index < realRowCount).map(row => row.index); - const frozenRows = new Set(visibleRows.slice(0, rows)); - const lastFrozenRow = visibleRows[Math.min(rows, visibleRows.length) - 1] ?? -1; + const visibleRows = rowOrder?.length ? rowOrder.filter(row => row < realRowCount) : null; + const frozenRows = visibleRows ? new Set(visibleRows.slice(0, rows)) : null; + const lastFrozenRow = visibleRows ? (visibleRows[Math.min(rows, visibleRows.length) - 1] ?? -1) : Math.min(firstRow + rows, realRowCount) - 1; for (const { element, index } of rendered) { - const frozenRow = frozenRows.has(index); + const frozenRow = frozenRows ? frozenRows.has(index) : index >= firstRow && index <= lastFrozenRow; const height = element.getBoundingClientRect().height || Math.max(0, ...Array.from(element.cells).map(cell => cell.getBoundingClientRect().height)); for (const cell of Array.from(element.cells)) { const attr = cell.getAttribute('data-col'); diff --git a/media/main.js b/media/main.js index 5bff633..9d6d7aa 100644 --- a/media/main.js +++ b/media/main.js @@ -443,9 +443,9 @@ const showContextMenu = (x, y, row, col) => { /* Header-only: SORT functionality */ if (lastContextIsHeader) { - item('Sort: A-Z', () => + item(root?.dataset?.filtered === '1' ? 'Sort matching rows: A-Z' : 'Sort: A-Z', () => vscode.postMessage({ type: 'sortColumn', index: col, ascending: true })); - item('Sort: Z-A', () => + item(root?.dataset?.filtered === '1' ? 'Sort matching rows: Z-A' : 'Sort: Z-A', () => vscode.postMessage({ type: 'sortColumn', index: col, ascending: false })); } @@ -1105,6 +1105,7 @@ const isFindWidgetTarget = target => { const el = getElementTarget(target); return !!(el && el.closest('#findReplaceWidget')); }; +const isGridControlTarget = target => isFindWidgetTarget(target) || !!getElementTarget(target)?.closest('#filterStatus'); const clearFindHighlights = () => { document.querySelectorAll('.highlight, .active-match').forEach(el => { el.classList.remove('highlight'); @@ -1474,7 +1475,7 @@ window.addEventListener('csvChunkLoaded', () => { // Capture-phase handler to intercept Cmd/Ctrl + Arrow and move to extremes document.addEventListener('keydown', e => { - if (isFindWidgetTarget(e.target)) { + if (isGridControlTarget(e.target)) { return; } const isArrowKey = (k) => ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Up','Down','Left','Right','Home','End'].includes(k); @@ -1580,7 +1581,7 @@ document.addEventListener('keydown', e => { closeFindReplace(); return; } - if (isFindWidgetTarget(e.target)) { + if (isGridControlTarget(e.target)) { return; } if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !editingCell) { @@ -1902,12 +1903,12 @@ const pasteFromClipboard = () => { }; for (const type of ['copy', 'cut']) { document.addEventListener(type, e => { - if (editingCell || isTextInputTarget(e.target) || isFindWidgetTarget(e.target)) return; + if (editingCell || isTextInputTarget(e.target) || isGridControlTarget(e.target)) return; if (copySelectionToClipboard(type === 'cut')) e.preventDefault(); }); } document.addEventListener('paste', e => { - if (isTextInputTarget(e.target) || isFindWidgetTarget(e.target)) { + if (isTextInputTarget(e.target) || isGridControlTarget(e.target)) { return; } if (editingCell) { @@ -2097,7 +2098,7 @@ table.addEventListener('dblclick', e => { const copySelectionToClipboard = (cut = false) => { const bounds = getDataSelectionBounds(); if (!bounds) return false; - const { minRow, maxRow, minCol, maxCol } = bounds; + const { minCol, maxCol } = bounds; const matrix = []; const rowIds = [...new Set(currentSelection.map(getDataCellCoords).filter(Boolean).map(coords => coords.row))].sort((a, b) => a - b); for (const row of rowIds) { diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 2e85ffe..63f6135 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -40,7 +40,6 @@ type PasteApplyResult = { type ChunkRenderState = { rowIds: number[]; virtualRow: number; - startAbs: number; allRows: string[][]; allRowsCount: number; chunkRows: number; @@ -1588,7 +1587,7 @@ class CsvEditorController { frozenPanes: this.getCurrentFrozenPanes(), firstVisibleRow: hiddenRows, realRowCount: data.length, - visibleRowIds: [...visibleSourceRows(data, hiddenRows, treatHeader, filters), data.length], + visibleRowIds: filters.length ? [...visibleSourceRows(data, hiddenRows, treatHeader, filters), data.length] : [], filters, matchedRows: visibleSourceRows(data, hiddenRows, false, filters).filter(row => !(treatHeader && row === hiddenRows)).length, totalFilterRows: Math.max(0, data.length - hiddenRows - (treatHeader ? 1 : 0)), @@ -1680,7 +1679,6 @@ class CsvEditorController { break; } const htmlChunk = allRows.slice(i, i + chunkRows).map((row, localR) => { - const startAbs = headerFlag ? offset + 1 : offset; const absRow = rowIds[i + localR]; const displayIdx = i + localR + 1; // numbering relative to first visible data row let cells = ''; @@ -1776,7 +1774,6 @@ class CsvEditorController { // If chunked, append a final chunk with the virtual row so it appears at the end. if (chunked && includeTrailingEmptyRow) { if (nextChunkStart === -1 && serializedChunkCount < safeMaxSerializedChunks) { - const startAbs = headerFlag ? offset + 1 : offset; const virtualAbs = data.length; const displayIdx = allRowsCount + 1; const idxCell = addSerialIndex ? `${displayIdx}` : ''; @@ -1793,7 +1790,6 @@ class CsvEditorController { ? { rowIds, virtualRow: data.length, - startAbs: headerFlag ? offset + 1 : offset, allRows, allRowsCount, chunkRows, @@ -2098,8 +2094,9 @@ class CsvEditorController { #csv-root .csv-frozen-row.active-match, #csv-root .csv-frozen-column.active-match { background-image: linear-gradient(var(--vscode-editor-findMatchBackground, transparent), var(--vscode-editor-findMatchBackground, transparent)); } - #filterStatus { position: sticky; top: 0; left: 0; z-index: 6; display: flex; gap: 8px; align-items: center; padding: 6px; background: var(--vscode-editorWidget-background, ${isDark ? '#252526' : '#ffffff'}); color: var(--vscode-editorWidget-foreground, inherit); border-bottom: 1px solid var(--vscode-editorWidget-border, ${isDark ? '#555' : '#ccc'}); } + #filterStatus { position: sticky; top: 0; left: 0; z-index: 6; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-height: 25vh; overflow: auto; padding: 6px; background: var(--vscode-editorWidget-background, ${isDark ? '#252526' : '#ffffff'}); color: var(--vscode-editorWidget-foreground, inherit); border-bottom: 1px solid var(--vscode-editorWidget-border, ${isDark ? '#555' : '#ccc'}); } #filterStatus button { color: inherit; background: transparent; border: 1px solid var(--vscode-button-border, currentColor); border-radius: 3px; cursor: pointer; } + #filterStatus [data-clear-filter] { max-width: 32ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } th[data-filter-column="active"]::after { content: " ⏷"; } #filterStatus button:focus-visible { outline: 2px solid var(--vscode-focusBorder); } /* Per-column computed colors */ @@ -2109,7 +2106,7 @@ class CsvEditorController {
- ${filters.length ? `
Filtered: ${matchedRows} of ${totalFilterRows} rows${filters.map(filter => ``).join('')}
` : ''} + ${filters.length ? `
Filtered: ${matchedRows} of ${totalFilterRows} rows${filters.map(filter => ``).join('')}
` : ''} ${tableHtml}
@@ -2145,7 +2142,7 @@ class CsvEditorController {
- +