From 0c9cdd7166b6ca0657e56d567acfedd1c3814667 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:03:57 -0400 Subject: [PATCH 01/83] fix: preserve multiline edits and unsaved tab contents (#75, #107, #108) --- README.md | 3 +- media/main.js | 33 ++- package-lock.json | 426 +++++++++++++++++++++++++++++ package.json | 1 + src/CsvEditorProvider.ts | 37 ++- src/test/edit-integrity-harness.ts | 181 ++++++++++++ src/test/edit-integrity.test.ts | 216 +++++++++++++++ 7 files changed, 875 insertions(+), 22 deletions(-) create mode 100644 src/test/edit-integrity-harness.ts create mode 100644 src/test/edit-integrity.test.ts diff --git a/README.md b/README.md index 7dec9d6..fd300ce 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: ## Features -- **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Saves on blur. +- **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs. - **Smart Resizing:** Automatic calculation of column widths for improved readability. - **Dynamic Color Coding:** Visual cues based on data type help you quickly identify numbers, dates, booleans, and more (with opt‑in cool palette and theme-foreground mode settings). - **Sticky Headers:** Keep column titles in view as you scroll through large datasets. @@ -133,6 +133,7 @@ Per-file (stored by the extension; set via commands): - Caret navigation: Arrow Left/Right move one character; Arrow Up moves caret to start; Arrow Down moves caret to end. - New line in cell: `Shift + Enter` inserts a line break inside the current cell. - Exit/save: click outside the cell or move focus to commit changes. + - 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. diff --git a/media/main.js b/media/main.js index 58079a4..04d192d 100644 --- a/media/main.js +++ b/media/main.js @@ -1819,7 +1819,7 @@ document.addEventListener('keydown', e => { } } if (editingCell && e.key === 'Escape') { - e.preventDefault(); editingCell.innerText = originalCellValue; editingCell.blur(); + e.preventDefault(); editingCell.textContent = originalCellValue; editingCell.blur(); } if (!editingCell && e.key === 'Escape') { clearSelection(); @@ -1858,10 +1858,10 @@ document.addEventListener('paste', e => { const selectAllCells = () => { clearSelection(); document.querySelectorAll('td, th').forEach(cell => { cell.classList.add('selected'); currentSelection.push(cell); }); }; -const setCursorToEnd = cell => { setTimeout(() => { +const setCursorToEnd = cell => { const range = document.createRange(); range.selectNodeContents(cell); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); -}, 10); }; +}; const setCursorToStart = cell => { setTimeout(() => { const range = document.createRange(); range.selectNodeContents(cell); range.collapse(true); @@ -1907,11 +1907,9 @@ const placeCaretBeforeSentinel = sentinel => { const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); - if (sentinel.firstChild) { - range.setStart(sentinel.firstChild, 0); - } else { - range.setStartBefore(sentinel); - } + // Keep typed text outside the disposable placeholder. A text-bearing span + // would absorb new input and delete that input when the placeholder is removed. + range.setStartBefore(sentinel); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); @@ -1919,9 +1917,8 @@ const placeCaretBeforeSentinel = sentinel => { const appendVisibleNewlineAtEnd = cell => { removeNewlineSentinels(cell); - const sentinel = document.createElement('span'); + const sentinel = document.createElement('br'); sentinel.setAttribute(NEWLINE_SENTINEL_ATTR, 'true'); - sentinel.textContent = '\u200B'; cell.appendChild(document.createTextNode('\n')); cell.appendChild(sentinel); placeCaretBeforeSentinel(sentinel); @@ -1944,9 +1941,8 @@ const insertNewlineAtCaret = cell => { const atEnd = range.collapsed && isRangeAtEndOfCell(cell, range); range.deleteContents(); if (atEnd) { - const sentinel = document.createElement('span'); + const sentinel = document.createElement('br'); sentinel.setAttribute(NEWLINE_SENTINEL_ATTR, 'true'); - sentinel.textContent = '\u200B'; const fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode('\n')); fragment.appendChild(sentinel); @@ -2083,7 +2079,7 @@ window.addEventListener('message', event => { isUpdating = true; const { row, col, value, rendered } = message; const cell = table.querySelector('td[data-row="'+row+'"][data-col="'+col+'"], th[data-row="'+row+'"][data-col="'+col+'"]'); - if (cell) { + if (cell && cell !== editingCell) { if (typeof rendered === 'string') { cell.innerHTML = rendered; } else { @@ -2180,7 +2176,16 @@ const maybeResumePendingEdit = () => { // Try after load and after visibility/focus restores setTimeout(maybeResumePendingEdit, 0); -document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') setTimeout(maybeResumePendingEdit, 0); }); +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + setTimeout(maybeResumePendingEdit, 0); + } else if (editingCell) { + editingCell.blur(); + } +}); +window.addEventListener('blur', () => { + if (editingCell) editingCell.blur(); +}); window.addEventListener('focus', () => { setTimeout(maybeResumePendingEdit, 0); }, { passive: true }); document.addEventListener('keydown', e => { diff --git a/package-lock.json b/package-lock.json index fe4da3a..2d9813c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@typescript-eslint/parser": "^7.9.0", "@vscode/vsce": "^3.7.1", "eslint": "^8.57.0", + "jsdom": "^26.1.0", "typescript": "^4.4.3", "vscode": "^1.1.37" }, @@ -27,6 +28,27 @@ "vscode": "^1.70.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@azu/format-text": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", @@ -238,6 +260,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -1806,6 +1943,34 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1824,6 +1989,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2869,6 +3041,19 @@ "node": ">=10" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -3131,6 +3316,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", @@ -3208,6 +3400,84 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3741,6 +4011,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4406,6 +4683,13 @@ "node": "*" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -4481,6 +4765,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/secretlint": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", @@ -4950,6 +5247,13 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/table": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", @@ -5086,6 +5390,26 @@ "url": "https://bevry.me/fund" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -5109,6 +5433,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -5599,6 +5949,29 @@ "node": ">=4" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -5623,6 +5996,20 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5656,6 +6043,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -5672,6 +6081,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -5696,6 +6115,13 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 31d2bda..092489b 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "@typescript-eslint/parser": "^7.9.0", "@vscode/vsce": "^3.7.1", "eslint": "^8.57.0", + "jsdom": "^26.1.0", "typescript": "^4.4.3", "vscode": "^1.1.37" } diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 9135ca8..2f1babf 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -64,6 +64,7 @@ class CsvEditorController { private isUpdatingDocument = false; private isSaving = false; + private pendingOperation: Promise = Promise.resolve(); private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; private separatorCache: { version: number; configKey: string; separator: string } | undefined; @@ -110,21 +111,26 @@ class CsvEditorController { } webviewPanel.webview.postMessage({ type: 'focus' }); + let wasVisible = webviewPanel.visible; webviewPanel.onDidChangeViewState(e => { + const becameVisible = e.webviewPanel.visible && !wasVisible; + wasVisible = e.webviewPanel.visible; if (e.webviewPanel.active) { const diffChanged = this.refreshDiffContext(e.webviewPanel); - if (diffChanged) { - this.updateWebviewContent(); + if (diffChanged || becameVisible) { + // A hidden webview can be recreated from its original HTML snapshot. + // Rebuild from the current (possibly unsaved) document after pending edits. + void this.enqueueOperation(() => this.updateWebviewContent()); } e.webviewPanel.webview.postMessage({ type: 'focus' }); CsvEditorProvider.currentActive = this; } }); - webviewPanel.webview.onDidReceiveMessage(async e => { + webviewPanel.webview.onDidReceiveMessage(e => this.enqueueOperation(async () => { switch (e.type) { case 'editCell': - this.updateDocument(e.row, e.col, e.value); + await this.updateDocument(e.row, e.col, e.value); break; case 'replaceCells': await this.replaceCells(e.replacements); @@ -182,7 +188,7 @@ class CsvEditorController { await this.openLinkExternally(e.url); break; } - }); + })); const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { if ( @@ -194,13 +200,31 @@ class CsvEditorController { } }); + const saveDocumentSubscription = vscode.workspace.onWillSaveTextDocument(e => { + if (e.document.uri.toString() === document.uri.toString() && !this.isSaving) { + // Native File > Save can run while the webview's blur edit is applying. + // The custom save command is already in this queue and must not await itself. + e.waitUntil(this.pendingOperation); + } + }); + webviewPanel.onDidDispose(() => { changeDocumentSubscription.dispose(); + saveDocumentSubscription.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); this.currentWebviewPanel = undefined; }); } + private enqueueOperation(operation: () => void | Promise): Promise { + // Event listeners are not awaited by VS Code. Serialize mutations, reads and + // saves so every command sees the result of the preceding document edit. + this.pendingOperation = this.pendingOperation.then(operation).catch(error => { + console.error('CSV: Editor operation failed', error); + }); + return this.pendingOperation; + } + private getMaxFileSizeLimitMb(config: vscode.WorkspaceConfiguration): number { const raw = Number(config.get('maxFileSizeMB', CsvEditorController.DEFAULT_MAX_FILE_SIZE_MB)); if (!Number.isFinite(raw) || raw <= 0) { @@ -442,8 +466,7 @@ class CsvEditorController { ); const edit = new vscode.WorkspaceEdit(); edit.replace(this.document.uri, fullRange, newCsvText); - await vscode.workspace.applyEdit(edit); - applied = true; + applied = await vscode.workspace.applyEdit(edit); } finally { this.isUpdatingDocument = false; } diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts new file mode 100644 index 0000000..1a48ed0 --- /dev/null +++ b/src/test/edit-integrity-harness.ts @@ -0,0 +1,181 @@ +import fs from 'fs'; +import path from 'path'; +import Module from 'module'; +import Papa from 'papaparse'; + +class TestUri { + constructor(public readonly path: string, public readonly scheme = 'file') {} + get fsPath() { return this.path; } + toString() { return `${this.scheme}://${this.path}`; } + static file(filePath: string) { return new TestUri(filePath); } +} + +let activeHarness: any; +const vscodeStub = { + Uri: TestUri, + Range: class {}, + WorkspaceEdit: class { + text = ''; + replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } + }, + ColorThemeKind: { Dark: 1 }, + window: { activeColorTheme: { kind: 1 } }, + workspace: { + getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), + applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), + onDidChangeTextDocument: (listener: (event: unknown) => void) => { + activeHarness.changeListeners.push(listener); + return { dispose() {} }; + }, + onWillSaveTextDocument: (listener: (event: unknown) => void) => { + activeHarness.saveListeners.push(listener); + return { dispose() {} }; + } + } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return vscodeStub; + } + return originalRequire.apply(this, arguments as any); +}; +// Load the provider only after installing the VS Code API stub. +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); +const { JSDOM } = require('jsdom'); + +export async function createEditorHarness(options: { + 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 diskText = text; + let messageListener: (message: any) => Promise; + const changeListeners: Array<(event: unknown) => void> = []; + const viewListeners: Array<(event: unknown) => void> = []; + const saveListeners: Array<(event: unknown) => void> = []; + const messages: any[] = []; + const delayedEdits: Array<() => void> = []; + let deferEdits = false; + const uri = new TestUri(`/test/data.${separator === '\t' ? 'tsv' : 'csv'}`, options.remote ? 'vscode-remote' : 'file'); + const document = { + uri, + version: 1, + getText: () => text, + get lineCount() { return text.split(/\r\n|\n|\r/).length; }, + lineAt: (index: number) => ({ text: text.split(/\r\n|\n|\r/)[index] ?? '' }), + save: async () => { + const pending: Promise[] = []; + saveListeners.forEach(listener => listener({ document, waitUntil: (promise: Promise) => pending.push(promise) })); + await Promise.all(pending); + diskText = text; + return true; + } + }; + const context = { + extensionPath: process.cwd(), + workspaceState: { + values: {} as Record, + get(key: string, fallback: unknown) { return this.values[key] ?? fallback; }, + async update(key: string, value: unknown) { this.values[key] = value; } + } + }; + await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); + const panel = { + active: true, + visible: true, + title: 'data.csv', + webview: { + html: '', + options: {}, + cspSource: 'test:', + asWebviewUri: (resource: TestUri) => resource.toString(), + postMessage: (message: unknown) => { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage: (listener: typeof messageListener) => { messageListener = listener; } + }, + onDidChangeViewState: (listener: (event: unknown) => void) => { viewListeners.push(listener); }, + onDidDispose() {} + }; + const harness = { + document, panel, messages, changeListeners, saveListeners, + get diskText() { return diskText; }, + get pendingEdits() { return delayedEdits.length; }, + deferEdits() { deferEdits = true; }, + releaseEdit() { delayedEdits.shift()?.(); }, + async applyEdit(nextText: string): Promise { + return new Promise(resolve => { + const apply = () => { + text = nextText; + document.version += 1; + changeListeners.forEach(listener => listener({ document })); + resolve(true); + }; + if (deferEdits) { + delayedEdits.push(apply); + } else { + apply(); + } + }); + }, + receive(message: unknown) { return messageListener(message); }, + async view(active: boolean, visible: boolean) { + panel.active = active; + panel.visible = visible; + viewListeners.forEach(listener => listener({ webviewPanel: panel })); + await messageListener({ type: 'test-drain' }); + }, + rows() { return Papa.parse(text, { delimiter: separator }).data; } + }; + activeHarness = harness; + await new CsvEditorProvider(context as any).resolveCustomTextEditor(document as any, panel as any, { isCancellationRequested: false } as any); + return harness; +} + +export function createWebviewDom(html: string) { + const dom = new JSDOM(html, { runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const messages: any[] = []; + let state: unknown = {}; + window.acquireVsCodeApi = () => ({ + postMessage: (message: unknown) => messages.push(message), + getState: () => state, + setState: (nextState: unknown) => { state = nextState; } + }); + window.HTMLElement.prototype.scrollIntoView = () => {}; + window.IntersectionObserver = class { observe() {} }; + const source = fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'); + window.eval(`${source}\nwindow.editingTestApi = { editCell, setSingleSelection };`); + return { + window, messages, + close() { window.close(); }, + cell(row = 1, col = 0) { + return window.document.querySelector(`[data-row="${row}"][data-col="${col}"]`); + }, + start(cell: any) { window.editingTestApi.setSingleSelection(cell); window.editingTestApi.editCell(cell); }, + key(key: string, options: Record = {}) { + window.document.activeElement.dispatchEvent(new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options })); + }, + type(value: string) { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + range.deleteContents(); + const node = window.document.createTextNode(value); + range.insertNode(node); + range.setStartAfter(node); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }, + caret(node: any, offset: number) { + const range = window.document.createRange(); + range.setStart(node, offset); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + }; +} diff --git a/src/test/edit-integrity.test.ts b/src/test/edit-integrity.test.ts new file mode 100644 index 0000000..5d62f06 --- /dev/null +++ b/src/test/edit-integrity.test.ts @@ -0,0 +1,216 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +describe('Multiline editing preserves text', () => { + for (const separator of [',', '\t']) { + for (const header of [true, false]) { + it(`keeps repeated Shift+Enter lines on blur (${JSON.stringify(separator)}, header ${header})`, async () => { + const host = await createEditorHarness({ separator, header }); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.start(cell); + for (const line of ['second', 'third', 'fourth']) { + view.key('Enter', { shiftKey: true }); + view.type(line); + } + cell.blur(); + const edit = view.messages.find(message => message.type === 'editCell'); + assert.strictEqual(edit.value, 'one\nsecond\nthird\nfourth'); + assert.strictEqual(cell.querySelector('[data-csv-newline-sentinel]'), null); + await host.receive(edit); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one\nsecond\nthird\nfourth', 'two']]); + } finally { + view.close(); + } + }); + } + } + + it('inserts exactly one break in the middle and at the end after reopening an edit', async () => { + const host = await createEditorHarness({ rows: [['name'], ['first\nlast']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.start(cell); + view.caret(cell.firstChild, 5); + view.key('Enter', { shiftKey: true }); + cell.blur(); + view.start(cell); + view.key('Enter', { shiftKey: true }); + view.type('final'); + cell.blur(); + assert.strictEqual(view.messages.filter(message => message.type === 'editCell').at(-1)?.value, 'first\n\nlast\nfinal'); + } finally { + view.close(); + } + }); + + it('starts Shift+Enter from selection without a delayed caret moving after the placeholder', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.window.editingTestApi.setSingleSelection(cell); + view.key('Enter', { shiftKey: true }); + await new Promise(resolve => setTimeout(resolve, 20)); + const selection = view.window.getSelection(); + assert.strictEqual(selection.anchorNode, cell); + assert.strictEqual(cell.childNodes[selection.anchorOffset].nodeName, 'BR'); + view.type('second'); + cell.blur(); + assert.strictEqual(view.messages.find(message => message.type === 'editCell').value, 'one\nsecond'); + } finally { + view.close(); + } + }); + + it('commits the full multiline value before a save message', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('second'); + view.key('s', { ctrlKey: true }); + const operations = view.messages.filter(message => message.type === 'editCell' || message.type === 'save'); + assert.deepStrictEqual(operations.map(message => message.type), ['editCell', 'save']); + await Promise.all(operations.map(message => host.receive(message))); + assert.strictEqual(host.diskText, host.document.getText()); + assert.strictEqual(host.rows()[1][0], 'one\nsecond'); + } finally { + view.close(); + } + }); + + it('does not replace an active edit with an earlier update acknowledgment', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.start(cell); + view.type(' changed'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: { + type: 'updateCell', row: 1, col: 0, value: 'old', rendered: 'old' + } })); + cell.blur(); + assert.strictEqual(view.messages.find(message => message.type === 'editCell').value, 'one changed'); + } finally { + view.close(); + } + }); + + for (const leave of ['window blur', 'hidden document']) { + it(`commits an active multiline edit on ${leave}`, async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('second'); + if (leave === 'window blur') { + view.window.dispatchEvent(new view.window.Event('blur')); + } else { + Object.defineProperty(view.window.document, 'visibilityState', { value: 'hidden' }); + view.window.document.dispatchEvent(new view.window.Event('visibilitychange')); + } + assert.strictEqual(view.messages.find(message => message.type === 'editCell').value, 'one\nsecond'); + } finally { + view.close(); + } + }); + } + + it('restores the original multiline value when cancelling an edit', async () => { + const host = await createEditorHarness({ rows: [['name'], ['first\nlast']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.type(' changed'); + view.key('Escape'); + assert.strictEqual(view.messages.find(message => message.type === 'editCell').value, 'first\nlast'); + } finally { + view.close(); + } + }); +}); + +describe('Document operations use current unsaved contents', () => { + it('native Save waits for a pending blur edit', async () => { + const host = await createEditorHarness(); + host.deferEdits(); + const edit = host.receive({ type: 'editCell', row: 1, col: 0, value: 'one\nsecond' }); + await new Promise(resolve => setImmediate(resolve)); + const save = host.document.save(); + host.releaseEdit(); + await Promise.all([edit, save]); + assert.ok(host.diskText.includes('second')); + }); + + it('waits for a pending edit before saving and applying the next edit', async () => { + const host = await createEditorHarness(); + host.deferEdits(); + const edit = host.receive({ type: 'editCell', row: 1, col: 0, value: 'one\nsecond' }); + const save = host.receive({ type: 'save' }); + const nextEdit = host.receive({ type: 'editCell', row: 1, col: 1, value: 'next' }); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + assert.ok(!host.diskText.includes('second')); + host.releaseEdit(); + await edit; + await save; + assert.ok(host.diskText.includes('second')); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + host.releaseEdit(); + await nextEdit; + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one\nsecond', 'next']]); + }); + + for (const separator of [',', '\t']) { + for (const header of [true, false]) { + it(`inserts rows after a pending multiline edit (${JSON.stringify(separator)}, header ${header})`, async () => { + const host = await createEditorHarness({ separator, header }); + const edit = host.receive({ type: 'editCell', row: 1, col: 0, value: 'one\nsecond' }); + const insert = host.receive({ type: 'insertRow', index: 2 }); + const fill = host.receive({ type: 'editCell', row: 2, col: 0, value: 'new' }); + await Promise.all([edit, insert, fill]); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one\nsecond', 'two'], ['new', '']]); + }); + } + } + + it('rebuilds a hidden remote webview from dirty contents and supplies current later chunks', async () => { + const rows = [['name', 'value'], ...Array.from({ length: 1100 }, (_, index) => [`row-${index}`, 'old'])]; + const host = await createEditorHarness({ rows, remote: true }); + const originalHtml = host.panel.webview.html; + await host.receive({ type: 'editCell', row: 1, col: 0, value: 'unsaved-first' }); + await host.receive({ type: 'editCell', row: 1050, col: 1, value: 'unsaved-later' }); + assert.strictEqual(host.panel.webview.html, originalHtml); + await host.view(false, false); + await host.view(true, true); + assert.ok(host.panel.webview.html.includes('unsaved-first')); + assert.ok(!host.diskText.includes('unsaved')); + await host.receive({ type: 'requestChunk', start: 1001, requestId: 1 }); + assert.ok(host.messages.find(message => message.type === 'chunkData').html.includes('unsaved-later')); + }); + + it('keeps one virtual row and ignores an empty edit on it after multiline editing', async () => { + const host = await createEditorHarness(); + await host.receive({ type: 'editCell', row: 1, col: 0, value: 'one\nsecond' }); + await host.receive({ type: 'editCell', row: 2, col: 0, value: '' }); + await host.view(false, false); + await host.view(true, true); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one\nsecond', 'two']]); + const view = createWebviewDom(host.panel.webview.html); + try { + const dataRows = view.window.document.querySelectorAll('tbody tr'); + assert.strictEqual(dataRows.length, 2); + assert.strictEqual(view.cell(2, 0).textContent, ''); + assert.strictEqual(view.cell(3, 0), null); + } finally { + view.close(); + } + }); +}); From b74f4885ee20e9ed2ebe1bbabec61194156b9262 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:09:32 -0400 Subject: [PATCH 02/83] 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 7ae53cdf102cce3f2280387148455edb63698a98 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:04:10 -0400 Subject: [PATCH 03/83] Fix clipboard fidelity and native text shortcuts (#81, #95, #105) --- README.md | 1 + media/main.js | 76 ++++++--- src/CsvEditorProvider.ts | 57 +++---- src/clipboard.ts | 25 +++ src/test/clipboard.test.ts | 45 ++++++ src/test/provider-clipboard.test.ts | 106 ++++++++++++ src/test/webview-clipboard.test.ts | 239 ++++++++++++++++++++++++++++ 7 files changed, 495 insertions(+), 54 deletions(-) create mode 100644 src/clipboard.ts create mode 100644 src/test/clipboard.test.ts create mode 100644 src/test/provider-clipboard.test.ts create mode 100644 src/test/webview-clipboard.test.ts diff --git a/README.md b/README.md index fd300ce..9dc6cd7 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Smart Column Sizing & Dynamic Color Coding:** Columns automatically adjust to fit content while being visually distinguished by data type. Whether it’s boolean, date, integer, float, or text, each column gets its own adaptive color that adjusts for light and dark themes. - **Sticky Headers & Fluid Navigation:** Keep your header row always visible as you scroll. Effortlessly move through cells using intuitive keyboard shortcuts like `Tab`, `Shift + Tab`, and arrow keys—just like a full-featured spreadsheet. - **Efficient Multi-Cell Selection & Clipboard Integration:** Select a range of cells with click-and-drag and copy them as well-formatted CSV data using `Ctrl/Cmd + C`. +- **Clipboard Actions:** Use Ctrl/Cmd + C or X, or right-click a selection and choose Copy or Cut, to copy properly quoted data to the system clipboard. Right-click Paste inserts clipboard data into the grid. Clipboard shortcuts in Find, Replace, and cell text edits keep their normal text-editing behavior. - **Grid Paste Support:** Paste copied ranges directly into the selected cell/range with `Ctrl/Cmd + V` in selection mode. - **Robust Data Handling:** Leveraging the power of [Papa Parse](https://www.papaparse.com/), the extension handles complex CSV structures, special characters, and various data types gracefully. - **Theme-Optimized Interface:** Whether you prefer light or dark mode, CSV automatically adapts its styles for an optimal viewing experience. diff --git a/media/main.js b/media/main.js index 04d192d..8b4678a 100644 --- a/media/main.js +++ b/media/main.js @@ -6,7 +6,6 @@ try { document.body.focus({ preventScroll: true }); } catch { try { document.bod const vscode = acquireVsCodeApi(); const root = document.getElementById('csv-root'); -const CSV_SEPARATOR = String.fromCodePoint(parseInt(root?.dataset?.sepcode || '44', 10)); // default ',' const parsePositiveNumber = value => { const parsed = typeof value === 'string' ? Number.parseFloat(value) : Number(value); if (!Number.isFinite(parsed) || parsed <= 0) return undefined; @@ -387,6 +386,10 @@ const showContextMenu = (x, y, row, col) => { d.style.margin = '1px 0'; contextMenu.appendChild(d); }; + item('Cut', () => copySelectionToClipboard(true)); + item('Copy', () => copySelectionToClipboard()); + item('Paste', () => pasteFromClipboard()); + divider(); // Derive multi-row/column selection counts const selectedIndexCells = currentSelection.filter(el => el && el.getAttribute && el.getAttribute('data-col') === '-1'); const selectedRowIds = Array.from(new Set(selectedIndexCells.map(el => parseInt(el.getAttribute('data-row') || '-1', 10)).filter(n => !isNaN(n)))).sort((a,b)=>a-b); @@ -747,7 +750,7 @@ document.addEventListener('click', (e) => { /* ──────── UPDATED contextmenu listener ──────── */ table.addEventListener('contextmenu', e => { const target = getCellTarget(e.target); - if (!target) return; + if (!target || editingCell) return; const colAttr = target.getAttribute('data-col'); const rowAttr = target.getAttribute('data-row'); const col = parseInt(colAttr); @@ -1313,41 +1316,46 @@ const closeFindReplace = () => { findInput.addEventListener('input', () => scheduleFind(false)); findInput.addEventListener('keydown', e => { - e.stopPropagation(); if (e.key === 'Escape') { e.preventDefault(); + e.stopPropagation(); closeFindReplace(); return; } if (e.key === 'Enter') { e.preventDefault(); + e.stopPropagation(); navigateFind(e.shiftKey); return; } if (e.key === 'F3') { e.preventDefault(); + e.stopPropagation(); navigateFind(e.shiftKey); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'g') { e.preventDefault(); + e.stopPropagation(); navigateFind(e.shiftKey); } }); replaceInput.addEventListener('keydown', e => { - e.stopPropagation(); if (e.key === 'Escape') { e.preventDefault(); + e.stopPropagation(); closeFindReplace(); return; } if (e.key === 'F3') { e.preventDefault(); + e.stopPropagation(); navigateFind(e.shiftKey); return; } if (e.key === 'Enter') { e.preventDefault(); + e.stopPropagation(); replaceCurrentMatch(); } }); @@ -1528,8 +1536,9 @@ document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !editingCell) { e.preventDefault(); selectAllCells(); return; } - if ((e.ctrlKey || e.metaKey) && e.key === 'c' && currentSelection.length > 0) { - e.preventDefault(); copySelectionToClipboard(); return; + if ((e.ctrlKey || e.metaKey) && !editingCell && !isTextInputTarget(e.target) && + (key === 'c' || key === 'x') && currentSelection.length > 0) { + e.preventDefault(); copySelectionToClipboard(key === 'x'); return; } // Clear contents of selected cells when not editing @@ -1826,8 +1835,29 @@ document.addEventListener('keydown', e => { } }); +const isTextInputTarget = target => { + const el = getElementTarget(target); + return !!(el && (el.closest('input, textarea, select') || el.isContentEditable)); +}; +const pasteFromClipboard = () => { + if (editingCell) return; + const anchor = getPasteAnchorCoords(); + if (!anchor) return; + vscode.postMessage({ + type: 'pasteFromClipboard', + anchorRow: anchor.row, + anchorCol: anchor.col, + selection: getDataSelectionBounds() || undefined + }); +}; +for (const type of ['copy', 'cut']) { + document.addEventListener(type, e => { + if (editingCell || isTextInputTarget(e.target) || isFindWidgetTarget(e.target)) return; + if (copySelectionToClipboard(type === 'cut')) e.preventDefault(); + }); +} document.addEventListener('paste', e => { - if (isFindWidgetTarget(e.target)) { + if (isTextInputTarget(e.target) || isFindWidgetTarget(e.target)) { return; } if (editingCell) { @@ -2013,27 +2043,21 @@ table.addEventListener('dblclick', e => { editCell(target, e); }); -const copySelectionToClipboard = () => { - if (currentSelection.length === 0) return; - - // Only copy real data/header columns; skip serial index column (col === -1) - const coords = currentSelection - .map(cell => getCellCoords(cell)) - .filter(c => !isNaN(c.row) && !isNaN(c.col) && c.col >= 0); - if (coords.length === 0) return; - const minRow = Math.min(...coords.map(c => c.row)), maxRow = Math.max(...coords.map(c => c.row)); - const minCol = Math.min(...coords.map(c => c.col)), maxCol = Math.max(...coords.map(c => c.col)); - let csv = ''; - for(let r = minRow; r <= maxRow; r++){ - let rowVals = []; - for(let c = minCol; c <= maxCol; c++){ - const selector = (hasHeader && r === 0 ? 'th' : 'td') + '[data-row="'+r+'"][data-col="'+c+'"]'; - const cell = table.querySelector(selector); - rowVals.push(cell ? cell.innerText : ''); +const copySelectionToClipboard = (cut = false) => { + const bounds = getDataSelectionBounds(); + if (!bounds) return false; + const { minRow, maxRow, minCol, maxCol } = bounds; + const matrix = []; + for (let row = minRow; row <= maxRow; row++) { + const values = []; + for (let col = minCol; col <= maxCol; col++) { + const cell = getRenderedCellByCoords(row, col); + values.push(cell ? cell.textContent : ''); } - csv += rowVals.join(CSV_SEPARATOR) + '\n'; + matrix.push(values); } - vscode.postMessage({ type: 'copyToClipboard', text: csv.trimEnd() }); + vscode.postMessage({ type: 'copyCells', matrix, startRow: minRow, startCol: minCol, cut }); + return true; }; window.addEventListener('message', event => { diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 2f1babf..6e0aab0 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -1,6 +1,7 @@ import Papa from 'papaparse'; import * as vscode from 'vscode'; import * as path from 'path'; +import { parseClipboardMatrix, serializeClipboardMatrix } from './clipboard'; type SeparatorMode = 'extension' | 'auto' | 'default'; type SeparatorSettings = { @@ -147,9 +148,11 @@ class CsvEditorController { case 'save': await this.handleSave(); break; - case 'copyToClipboard': - await vscode.env.clipboard.writeText(e.text); - console.log('CSV: Copied to clipboard'); + case 'copyCells': + await this.copyCells(e.matrix, e.startRow, e.startCol, e.cut === true); + break; + case 'pasteFromClipboard': + await this.pasteCells(await vscode.env.clipboard.readText(), e.anchorRow, e.anchorCol, e.selection); break; case 'insertColumn': await this.insertColumn(e.index); @@ -551,34 +554,32 @@ class CsvEditorController { } } - private parseClipboardMatrix(text: string): string[][] { - if (!text || text.length === 0) { - return []; + private async copyCells(rawMatrix: unknown, rawStartRow: 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 parsed = Papa.parse(text, { dynamicTyping: false, delimiter: '' }); - const rowsRaw = Array.isArray(parsed.data) ? parsed.data : []; - const matrix: string[][] = rowsRaw.map(rawRow => { - if (Array.isArray(rawRow)) { - return rawRow.map(cell => String(cell ?? '')); - } - return [String(rawRow ?? '')]; - }); - while (matrix.length > 0 && matrix[matrix.length - 1].every(value => value === '')) { - matrix.pop(); + const matrix = rawMatrix as string[][]; + const startRow = Number(rawStartRow); + const startCol = Number(rawStartCol); + if (!Number.isInteger(startRow) || startRow < 0 || !Number.isInteger(startCol) || startCol < 0) { + return; } - if (matrix.length === 0) { - return []; + const documentVersion = this.document.version; + try { + await vscode.env.clipboard.writeText(serializeClipboardMatrix(matrix, this.getSeparator())); + } catch { + void vscode.window.showErrorMessage('CSV: Unable to write to the clipboard. The selected cells were kept.'); + return; } - const width = matrix.reduce((max, row) => Math.max(max, row.length), 0); - if (width <= 0) { - return []; + if (cut && this.document.version === documentVersion) { + await this.replaceCells(matrix.flatMap((row, rowOffset) => row.map((_value, colOffset) => ({ + row: startRow + rowOffset, + col: startCol + colOffset, + value: '' + })))); } - matrix.forEach(row => { - while (row.length < width) { - row.push(''); - } - }); - return matrix; } private static parsePasteSelectionBounds(raw: unknown): PasteSelectionBounds | undefined { @@ -721,7 +722,7 @@ class CsvEditorController { if (!Number.isInteger(anchorRow) || anchorRow < 0 || !Number.isInteger(anchorCol) || anchorCol < 0) { return; } - const matrix = this.parseClipboardMatrix(text); + const matrix = parseClipboardMatrix(text, this.getSeparator()); if (!matrix.length || !matrix[0].length) { return; } diff --git a/src/clipboard.ts b/src/clipboard.ts new file mode 100644 index 0000000..685fdec --- /dev/null +++ b/src/clipboard.ts @@ -0,0 +1,25 @@ +import Papa from 'papaparse'; + +// Quote every field so even a single empty cell and trailing empty rows survive. +export function serializeClipboardMatrix(matrix: string[][], delimiter: string): string { + return Papa.unparse(matrix, { delimiter, quotes: true, newline: '\r\n' }); +} + +export function parseClipboardMatrix(text: string, fallbackDelimiter: string): string[][] { + if (!text) { + return []; + } + const parsed = Papa.parse(text, { + dynamicTyping: false, + delimiter: '', + delimitersToGuess: [fallbackDelimiter, '\t', ',', ';', '|'].filter((value, index, all) => all.indexOf(value) === index) + }); + const matrix = parsed.data.map(row => row.map(value => String(value ?? ''))); + // A terminal record separator creates one parser sentinel, not an extra record. + // Preserve explicit blank records, including quoted empty fields. + if (/[\r\n]$/.test(text) && matrix.length > 1 && matrix[matrix.length - 1].length === 1 && matrix[matrix.length - 1][0] === '') { + matrix.pop(); + } + const width = matrix.reduce((max, row) => Math.max(max, row.length), 0); + return matrix.map(row => [...row, ...Array(width - row.length).fill('')]); +} diff --git a/src/test/clipboard.test.ts b/src/test/clipboard.test.ts new file mode 100644 index 0000000..47b0a39 --- /dev/null +++ b/src/test/clipboard.test.ts @@ -0,0 +1,45 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import Papa from 'papaparse'; +import { parseClipboardMatrix, serializeClipboardMatrix } from '../clipboard'; + +// Independent expectations exercise the same codec used by the extension host. +describe('Clipboard matrix fidelity', () => { + for (const delimiter of [',', '\t', '|', ';', '^']) { + it(`preserves cell and record boundaries with delimiter ${JSON.stringify(delimiter)}`, () => { + const matrix = [ + ['comma,value', 'line one\nline two', 'a "literal" quote', ''], + ['tab\tvalue', 'windows\r\nnewline', ' whitespace ', ''], + ['', '', '', ''] + ]; + const text = serializeClipboardMatrix(matrix, delimiter); + assert.deepStrictEqual(Papa.parse(text, { delimiter }).data, matrix); + assert.deepStrictEqual(parseClipboardMatrix(text, delimiter), matrix); + }); + } + + it('keeps each special value in a single cell', () => { + for (const value of ['comma,value', 'line\nvalue', '"literal"', ' ', '\t', '\r\n']) { + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix([[value]], ','), ','), [[value]]); + } + }); + + it('preserves one empty cell and rows made entirely of empty cells', () => { + for (const matrix of [[['']], [[''], ['']], [['', ''], ['', '']]]) { + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix(matrix, '\t'), '\t'), matrix); + } + }); + + it('accepts external spreadsheet TSV in a CSV document', () => { + assert.deepStrictEqual(parseClipboardMatrix('one\t"two\nlines"\t\r\nthree\tfour\t\r\n', ','), [ + ['one', 'two\nlines', ''], ['three', 'four', ''] + ]); + }); + + it('removes a terminal parser sentinel while preserving intentional empty records', () => { + assert.deepStrictEqual(parseClipboardMatrix('one,two\n,,\n', ','), [['one', 'two', ''], ['', '', '']]); + assert.deepStrictEqual(parseClipboardMatrix('one\n""\n', ','), [['one'], ['']]); + assert.deepStrictEqual(parseClipboardMatrix('"\n"', ','), [['\n']]); + assert.deepStrictEqual(parseClipboardMatrix('', ','), []); + }); +}); diff --git a/src/test/provider-clipboard.test.ts b/src/test/provider-clipboard.test.ts new file mode 100644 index 0000000..df93ac1 --- /dev/null +++ b/src/test/provider-clipboard.test.ts @@ -0,0 +1,106 @@ +import assert from 'assert'; +import Module from 'module'; +import Papa from 'papaparse'; +import { describe, it } from 'node:test'; + +let writeClipboard: (text: string) => Promise; +let readClipboard: () => Promise; +let applyEdit: (edit: any) => Promise; +const errors: string[] = []; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + Uri: { file: (filePath: string) => ({ fsPath: filePath, toString: () => filePath }) }, + Range: class {}, + WorkspaceEdit: class { text = ''; replace(_uri: any, _range: any, text: string) { this.text = text; } }, + env: { clipboard: { writeText: (text: string) => writeClipboard(text), readText: () => readClipboard() } }, + window: { activeColorTheme: { kind: 1 }, showErrorMessage: (message: string) => errors.push(message) }, + ColorThemeKind: { Dark: 1 }, + workspace: { + getConfiguration: () => ({ get: (key: string, fallback: any) => key === 'maxFileSizeMB' ? 0 : fallback }), + onDidChangeTextDocument: () => ({ dispose() {} }), + applyEdit: (edit: any) => applyEdit(edit) + } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +async function createEditor(initial: string, extension = 'csv') { + let text = initial; + let version = 1; + let receive: (message: any) => Promise = async () => {}; + const clipboardWrites: string[] = []; + const events: string[] = []; + const messages: any[] = []; + errors.length = 0; + writeClipboard = async value => { clipboardWrites.push(value); events.push('write'); }; + readClipboard = async () => clipboardWrites[clipboardWrites.length - 1] || ''; + applyEdit = async edit => { events.push('edit'); text = edit.text; version++; return true; }; + const uri = { fsPath: `/test.${extension}`, toString: () => `file:///test.${extension}` }; + const document = { + uri, getText: () => text, get version() { return version; }, + get lineCount() { return text.split('\n').length; }, lineAt: (index: number) => ({ text: text.split('\n')[index] }) + }; + const webview = { + html: '', cspSource: 'test:', options: {}, asWebviewUri: (value: any) => value.toString(), + postMessage: (message: any) => { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage: (callback: any) => { receive = callback; } + }; + const panel = { active: true, title: 'test', webview, onDidChangeViewState() {}, onDidDispose() {} }; + const context = { extensionPath: process.cwd(), workspaceState: { get: (_key: string, fallback: any) => fallback } }; + await new CsvEditorProvider(context as any).resolveCustomTextEditor(document as any, panel as any, { isCancellationRequested: false } as any); + return { + receive: (message: any) => receive(message), clipboardWrites, events, messages, + get text() { return text; }, mutate: () => { text = 'newer,value'; version++; } + }; +} + +describe('Provider system clipboard integration', () => { + it('writes quoted CSV/TSV to the system clipboard and round-trips paste through the document', async () => { + for (const extension of ['csv', 'tsv']) { + 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 }); + 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`); + await editor.receive({ type: 'pasteFromClipboard', anchorRow: 0, anchorCol: 0 }); + assert.deepStrictEqual(Papa.parse(editor.text, { delimiter }).data, matrix); + assert.strictEqual(editor.messages[editor.messages.length - 1].type, 'pasteApplied'); + } + }); + + it('clears cut cells in one batch only after the clipboard write succeeds', async () => { + const editor = await createEditor('one,two\nthree,four'); + let finishWrite: () => void = () => {}; + writeClipboard = async () => { await new Promise(resolve => { finishWrite = resolve; }); editor.events.push('write'); }; + const cut = editor.receive({ type: 'copyCells', matrix: [['one', 'two']], startRow: 0, startCol: 0, cut: true }); + assert.strictEqual(editor.text, 'one,two\nthree,four'); + finishWrite(); + await cut; + assert.deepStrictEqual(editor.events, ['write', 'edit']); + assert.deepStrictEqual(Papa.parse(editor.text).data, [['', ''], ['three', 'four']]); + }); + + 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 }); + assert.strictEqual(editor.text, 'one,two'); + assert.deepStrictEqual(editor.events, []); + assert.strictEqual(errors.length, 1); + }); + + 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 }); + 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 new file mode 100644 index 0000000..a81f101 --- /dev/null +++ b/src/test/webview-clipboard.test.ts @@ -0,0 +1,239 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { describe, it } from 'node:test'; + +type Listener = { callback: (event: any) => void; capture: boolean }; + +// Execute the complete shipped script. The small DOM model supplies selection, +// focus and event propagation; it does not reimplement clipboard behavior. +function createWebview() { + class Element { + listeners = new Map(); + attributes = new Map(); + classes = new Set(); + children: Element[] = []; + parentElement: Element | null = null; + style: Record = {}; + dataset: Record = {}; + textContent = ''; + value = ''; + isContentEditable = false; + isConnected = true; + classList = { + add: (...names: string[]) => names.forEach(name => this.classes.add(name)), + remove: (...names: string[]) => names.forEach(name => this.classes.delete(name)), + contains: (name: string) => this.classes.has(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !this.classes.has(name); + if (next) { this.classes.add(name); } else { this.classes.delete(name); } + return next; + } + }; + constructor(public tagName = 'DIV', public id = '') {} + addEventListener(type: string, callback: (event: any) => void, options?: any) { + const listeners = this.listeners.get(type) || []; + listeners.push({ callback, capture: options === true }); + this.listeners.set(type, listeners); + } + removeEventListener() {} + setAttribute(name: string, value: string) { this.attributes.set(name, value); } + getAttribute(name: string) { return this.attributes.get(name) ?? null; } + hasAttribute(name: string) { return this.attributes.has(name); } + appendChild(child: Element) { child.parentElement = this; this.children.push(child); } + contains(other: Element): boolean { return other === this || this.children.some(child => child.contains(other)); } + focus() { document.activeElement = this; } + scrollIntoView() {} + matches(selector: string): boolean { + if (selector.includes(' ')) { + const [ancestor, child] = selector.split(' '); + return this.matches(child) && !!this.parentElement?.closest(ancestor); + } + if (selector.startsWith('#')) { return this.id === selector.slice(1); } + const tag = selector.match(/^[a-z]+/i)?.[0]; + if (tag && this.tagName !== tag.toUpperCase()) { return false; } + if (selector.includes('.')) { return selector.split('.').slice(1).every(name => this.classes.has(name)); } + for (const match of selector.matchAll(/\[([^=\]]+)(?:="([^"]*)")?\]/g)) { + if (match[2] === undefined ? !this.hasAttribute(match[1]) : this.getAttribute(match[1]) !== match[2]) { return false; } + } + return true; + } + closest(selector: string): Element | null { + if (selector.split(',').some(part => this.matches(part.trim()))) { return this; } + return this.parentElement?.closest(selector) ?? null; + } + querySelectorAll(selector: string): Element[] { + const descendants = this.children.flatMap(child => [child, ...child.querySelectorAll('*')]); + return descendants.filter(child => selector === '*' || selector.split(',').some(part => child.matches(part.trim()))); + } + querySelector(selector: string) { return this.querySelectorAll(selector)[0] ?? null; } + get innerHTML() { return ''; } + set innerHTML(_value: string) { this.children = []; } + get innerText() { return this.textContent; } + set innerText(value: string) { this.textContent = value; } + } + const document = new Element('DOCUMENT') as Element & { body: Element; activeElement: Element | null; getElementById: (id: string) => Element }; + document.body = new Element('BODY'); + document.activeElement = null; + document.appendChild(document.body); + const elements = new Map(); + const byId = (id: string) => { + if (!elements.has(id)) { + const el = new Element(id.endsWith('Input') ? 'INPUT' : 'DIV', id); + elements.set(id, el); + document.body.appendChild(el); + } + return elements.get(id)!; + }; + document.getElementById = byId; + const root = byId('csv-root'); + const table = new Element('TABLE'); + const head = new Element('THEAD'); + table.appendChild(head); + root.appendChild(table); + byId('__csvChunks').textContent = '[]'; + const widget = byId('findReplaceWidget'); + for (const id of ['findInput', 'replaceInput']) { + const input = byId(id); + document.body.children = document.body.children.filter(el => el !== input); + widget.appendChild(input); + } + const window = new Element('WINDOW') as any; + window.getComputedStyle = () => ({ fontSize: '14px' }); + const messages: any[] = []; + const context = vm.createContext({ + document, window, Element, Node: Element, + acquireVsCodeApi: () => ({ getState: () => ({}), setState: () => {}, postMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))) }), + setTimeout: () => 0, clearTimeout: () => {}, requestAnimationFrame: () => 0, + console + }); + (document as any).createElement = (tag: string) => new Element(tag.toUpperCase()); + vm.runInContext(fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'), context); + const cell = (row: number, col: number, value: string, header = false) => { + const el = new Element(header ? 'TH' : 'TD'); + el.setAttribute('data-row', String(row)); + el.setAttribute('data-col', String(col)); + el.textContent = value; + (header ? head : table).appendChild(el); + return el; + }; + const dispatch = (target: Element, type: string, options: any = {}) => { + const event: any = { + type, target, defaultPrevented: false, stopped: false, immediate: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.stopped = true; }, + stopImmediatePropagation() { this.stopped = true; this.immediate = true; }, + ...options + }; + const invoke = (el: Element, capture: boolean) => { + for (const listener of el.listeners.get(type) || []) { + if (listener.capture === capture && !event.immediate) { listener.callback(event); } + } + }; + invoke(document, true); + if (!event.stopped) { invoke(target, false); } + for (let parent = target.parentElement; parent && !event.stopped; parent = parent.parentElement) { invoke(parent, false); } + return event; + }; + return { context, cell, byId, document, table, messages, dispatch, select: (startRow: number, startCol: number, endRow = startRow, endCol = startCol) => { + vm.runInContext(`selectRange({ row: ${startRow}, col: ${startCol} }, { row: ${endRow}, col: ${endCol} }); anchorCell = currentSelection[0];`, context); + }}; +} + +describe('Shipped webview clipboard interactions', () => { + it('copies headers and multiline values without serial indices or trimmed blanks', () => { + const view = createWebview(); + view.cell(0, -1, '#', true); + view.cell(0, 0, 'Header', true); + view.cell(0, 1, '', true); + view.cell(1, -1, '1'); + const target = view.cell(1, 0, 'literal,comma\nand newline'); + view.cell(1, 1, ' '); + 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 }]); + }); + + it('routes grid Ctrl/Cmd+C and X through system clipboard messages', () => { + for (const modifier of ['ctrlKey', 'metaKey']) { + for (const key of ['c', 'x']) { + const view = createWebview(); + const target = view.cell(1, 0, 'value'); + view.select(1, 0); + const event = view.dispatch(target, 'keydown', { key, [modifier]: true }); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(view.messages[0].cut, key === 'x'); + assert.strictEqual(target.textContent, 'value', 'cut must wait for the clipboard write'); + } + } + }); + + it('provides Cut, Copy and Paste actions for the preserved right-click selection', () => { + const view = createWebview(); + const first = view.cell(1, 0, 'one'); + const second = view.cell(1, 1, 'two'); + view.select(1, 0, 1, 1); + view.dispatch(second, 'mousedown', { button: 2 }); + view.dispatch(second, 'contextmenu', { pageX: 10, pageY: 20 }); + const menu = view.byId('contextMenu'); + for (const label of ['Copy', 'Cut', 'Paste']) { + const item = menu.children.find(child => child.textContent === label)!; + assert.ok(item); + view.dispatch(item, 'click'); + } + assert.deepStrictEqual(view.messages.map(message => message.type), ['copyCells', 'copyCells', 'pasteFromClipboard']); + assert.deepStrictEqual(view.messages[0].matrix, [['one', 'two']]); + assert.strictEqual(view.messages[1].cut, true); + assert.strictEqual(view.messages[2].anchorCol, 0); + assert.strictEqual(first.textContent, 'one'); + }); + + it('allows native text clipboard shortcuts and paste in Find and Replace', () => { + for (const id of ['findInput', 'replaceInput']) { + for (const modifier of ['ctrlKey', 'metaKey']) { + const view = createWebview(); + view.cell(1, 0, 'selected grid value'); + view.select(1, 0); + const input = view.byId(id); + for (const key of ['a', 'c', 'x', 'v']) { + const event = view.dispatch(input, 'keydown', { key, [modifier]: true }); + assert.strictEqual(event.defaultPrevented, false, `${id} ${key} default`); + assert.strictEqual(event.stopped, false, `${id} ${key} must reach the VS Code webview bridge`); + } + for (const type of ['copy', 'cut', 'paste']) { + const event = view.dispatch(input, type, { clipboardData: { getData: () => 'SKU-123' } }); + assert.strictEqual(event.defaultPrevented, false); + } + assert.deepStrictEqual(view.messages, []); + } + } + }); + + it('keeps native text copy, cut, paste and context menus while editing a cell', () => { + const view = createWebview(); + const target = view.cell(1, 0, 'text edit'); + view.select(1, 0); + vm.runInContext('editingCell = currentSelection[0];', view.context); + target.isContentEditable = true; + for (const type of ['copy', 'cut', 'paste', 'contextmenu']) { + const event = view.dispatch(target, type, { clipboardData: { getData: () => 'text' } }); + assert.strictEqual(event.defaultPrevented, false); + } + const event = view.dispatch(target, 'keydown', { key: 'c', metaKey: true }); + assert.strictEqual(event.defaultPrevented, false); + assert.deepStrictEqual(view.messages, []); + }); + + it('posts a grid paste while leaving the selected cell contents to the host', () => { + const view = createWebview(); + const target = view.cell(1, 0, 'before'); + view.select(1, 0); + const event = view.dispatch(target, 'paste', { clipboardData: { getData: () => '"with,comma"' } }); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(view.messages[0].type, 'pasteCells'); + assert.strictEqual(view.messages[0].text, '"with,comma"'); + assert.strictEqual(target.textContent, 'before'); + }); +}); From 9550664adc03b8c411f3cc32e2b9e3bf5ef9437d Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:06:50 -0400 Subject: [PATCH 04/83] Support save-event integration in clipboard test host --- src/test/provider-clipboard.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/provider-clipboard.test.ts b/src/test/provider-clipboard.test.ts index df93ac1..fcd831e 100644 --- a/src/test/provider-clipboard.test.ts +++ b/src/test/provider-clipboard.test.ts @@ -20,6 +20,7 @@ Module.prototype.require = function (id: string) { workspace: { getConfiguration: () => ({ get: (key: string, fallback: any) => key === 'maxFileSizeMB' ? 0 : fallback }), onDidChangeTextDocument: () => ({ dispose() {} }), + onWillSaveTextDocument: () => ({ dispose() {} }), applyEdit: (edit: any) => applyEdit(edit) } }; From a164b2311f3f1da8214ca9aa25de762d6d9919fe Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:08:11 -0400 Subject: [PATCH 05/83] Await clipboard write start in deferred cut regression --- src/test/provider-clipboard.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/test/provider-clipboard.test.ts b/src/test/provider-clipboard.test.ts index fcd831e..79acc37 100644 --- a/src/test/provider-clipboard.test.ts +++ b/src/test/provider-clipboard.test.ts @@ -79,8 +79,16 @@ describe('Provider system clipboard integration', () => { it('clears cut cells in one batch only after the clipboard write succeeds', async () => { const editor = await createEditor('one,two\nthree,four'); let finishWrite: () => void = () => {}; - writeClipboard = async () => { await new Promise(resolve => { finishWrite = resolve; }); editor.events.push('write'); }; + let signalWriteStarted: () => void = () => {}; + const writeStarted = new Promise(resolve => { signalWriteStarted = resolve; }); + writeClipboard = async () => { + const pendingWrite = new Promise(resolve => { finishWrite = resolve; }); + signalWriteStarted(); + await pendingWrite; + editor.events.push('write'); + }; const cut = editor.receive({ type: 'copyCells', matrix: [['one', 'two']], startRow: 0, startCol: 0, cut: true }); + await writeStarted; assert.strictEqual(editor.text, 'one,two\nthree,four'); finishWrite(); await cut; From 0c3ec9416c81ff53fac2f2af5d9ae7c6638ac925 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:03:58 -0400 Subject: [PATCH 06/83] fix: preserve editor lifecycle and text navigation handoffs --- src/CsvEditorProvider.ts | 101 +++++++---- src/test/edit-integrity-harness.ts | 2 + src/test/editor-lifecycle.test.ts | 275 +++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 32 deletions(-) create mode 100644 src/test/editor-lifecycle.test.ts diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 2f1babf..c0639eb 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -65,6 +65,8 @@ class CsvEditorController { private isUpdatingDocument = false; private isSaving = false; private pendingOperation: Promise = Promise.resolve(); + private isDisposed = false; + private defaultEditorHandoff: Promise | undefined; private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; private separatorCache: { version: number; configKey: string; separator: string } | undefined; @@ -81,6 +83,24 @@ class CsvEditorController { _token: vscode.CancellationToken ): Promise { this.document = document; + this.currentWebviewPanel = webviewPanel; + let changeDocumentSubscription: vscode.Disposable | undefined; + let saveDocumentSubscription: vscode.Disposable | undefined; + // Remote file checks can outlive the preview tab that requested them. + // Observe disposal before awaiting anything so a closed panel stays closed. + webviewPanel.onDidDispose(() => { + this.isDisposed = true; + changeDocumentSubscription?.dispose(); + saveDocumentSubscription?.dispose(); + CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); + if (CsvEditorProvider.currentActive === this) { + CsvEditorProvider.currentActive = undefined; + } + this.currentWebviewPanel = undefined; + }); + if (_token.isCancellationRequested) { + return; + } const config = vscode.workspace.getConfiguration('csv', this.document.uri); if (!config.get('enabled', true)) { @@ -90,17 +110,20 @@ class CsvEditorController { } const proceed = await this.confirmLargeFileOpen(config, webviewPanel, _token); - if (!proceed) { + if (!proceed || this.isDisposed || _token.isCancellationRequested) { + return; + } + // csv.enabled may have changed while a remote stat or the size prompt was pending. + if (!vscode.workspace.getConfiguration('csv', document.uri).get('enabled', true)) { + await this.openWithDefaultEditorAndClose(webviewPanel, document.uri); return; } - this.currentWebviewPanel = webviewPanel; CsvEditorProvider.editors.push(this); webviewPanel.webview.options = { enableScripts: true, - // Use file path for compatibility with older VS Code types (no Uri.joinPath) - localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'media'))] + localResourceRoots: [this.getMediaUri()] }; this.refreshDiffContext(webviewPanel); @@ -124,6 +147,8 @@ class CsvEditorController { } e.webviewPanel.webview.postMessage({ type: 'focus' }); CsvEditorProvider.currentActive = this; + } else if (CsvEditorProvider.currentActive === this) { + CsvEditorProvider.currentActive = undefined; } }); @@ -190,7 +215,7 @@ class CsvEditorController { } })); - const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { + changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { if ( e.document.uri.toString() === document.uri.toString() && !this.isUpdatingDocument && @@ -200,7 +225,7 @@ class CsvEditorController { } }); - const saveDocumentSubscription = vscode.workspace.onWillSaveTextDocument(e => { + saveDocumentSubscription = vscode.workspace.onWillSaveTextDocument(e => { if (e.document.uri.toString() === document.uri.toString() && !this.isSaving) { // Native File > Save can run while the webview's blur edit is applying. // The custom save command is already in this queue and must not await itself. @@ -208,12 +233,6 @@ class CsvEditorController { } }); - webviewPanel.onDidDispose(() => { - changeDocumentSubscription.dispose(); - saveDocumentSubscription.dispose(); - CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); - this.currentWebviewPanel = undefined; - }); } private enqueueOperation(operation: () => void | Promise): Promise { @@ -252,16 +271,30 @@ class CsvEditorController { } private async openWithDefaultEditorAndClose(webviewPanel: vscode.WebviewPanel, uri: vscode.Uri): Promise { - try { - const opts: any = { - viewColumn: webviewPanel.viewColumn, - preserveFocus: !webviewPanel.active, - preview: webviewPanel.active ? webviewPanel.active : false - }; - await vscode.commands.executeCommand('vscode.openWith', uri, 'default', opts); - } finally { - try { webviewPanel.dispose(); } catch {} + if (this.isDisposed) { + return; } + if (!this.defaultEditorHandoff) { + this.defaultEditorHandoff = (async () => { + try { + // Search can already have revealed the text editor. Opening it again + // loses its destination, so leave an existing editor in this group alone. + const existing = vscode.window.visibleTextEditors.find(editor => + editor.document.uri.toString() === uri.toString() && + editor.viewColumn === webviewPanel.viewColumn + ); + if (!existing) { + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { + viewColumn: webviewPanel.viewColumn, + preserveFocus: !webviewPanel.active + }); + } + } finally { + try { webviewPanel.dispose(); } catch {} + } + })(); + } + await this.defaultEditorHandoff; } private async confirmLargeFileOpen( @@ -319,8 +352,12 @@ class CsvEditorController { public refresh() { const config = vscode.workspace.getConfiguration('csv', this.document.uri); if (!config.get('enabled', true)) { - this.currentWebviewPanel?.dispose(); - vscode.commands.executeCommand('vscode.openWith', this.document.uri, 'default'); + const panel = this.currentWebviewPanel; + if (panel) { + void this.openWithDefaultEditorAndClose(panel, this.document.uri).catch(error => { + console.error('CSV: failed to reopen the text editor', error); + }); + } } else { if (this.currentWebviewPanel) { this.forceReload(); @@ -1690,6 +1727,12 @@ class CsvEditorController { return !matches; } + private getMediaUri(...segments: string[]): vscode.Uri { + return this.context.extensionUri.with({ + path: path.posix.join(this.context.extensionUri.path, 'media', ...segments) + }); + } + private wrapHtml(args: { webview: vscode.Webview; nonce: string; @@ -1707,9 +1750,8 @@ class CsvEditorController { }): string { const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert } = 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( - vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'main.js')) + this.getMediaUri('main.js') ); // Safe separator transport (assumes single character; see assumptions) @@ -2539,17 +2581,12 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { ): Promise { console.log(`CSV(reg): creating controller for ${document.uri.toString()}`); const controller = new CsvEditorController(this.context); - // Track active controller - webviewPanel.onDidChangeViewState(e => { - if (e.webviewPanel.active) { - CsvEditorProvider.currentActive = controller; - } - }); await controller.resolveCustomTextEditor(document, webviewPanel, _token); } public static getActiveProvider(): CsvEditorController | undefined { - return CsvEditorProvider.currentActive || CsvEditorProvider.editors.find(ed => ed.isActive()); + const current = CsvEditorProvider.currentActive; + return current?.isActive() ? current : CsvEditorProvider.editors.find(ed => ed.isActive()); } public static getHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri): number { diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..fe987e2 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -8,6 +8,7 @@ class TestUri { get fsPath() { return this.path; } toString() { return `${this.scheme}://${this.path}`; } static file(filePath: string) { return new TestUri(filePath); } + with(changes: { path: string }) { return new TestUri(changes.path, this.scheme); } } let activeHarness: any; @@ -77,6 +78,7 @@ export async function createEditorHarness(options: { }; const context = { extensionPath: process.cwd(), + extensionUri: TestUri.file(process.cwd()), workspaceState: { values: {} as Record, get(key: string, fallback: unknown) { return this.values[key] ?? fallback; }, diff --git a/src/test/editor-lifecycle.test.ts b/src/test/editor-lifecycle.test.ts new file mode 100644 index 0000000..d3731d6 --- /dev/null +++ b/src/test/editor-lifecycle.test.ts @@ -0,0 +1,275 @@ +import assert from 'assert'; +import Module from 'module'; +import { beforeEach, describe, it } from 'node:test'; + +class Uri { + constructor(public scheme: string, public authority: string, public path: string) {} + get fsPath() { return this.path; } + toString() { return `${this.scheme}://${this.authority}${this.path}`; } + with(changes: { path: string }) { + return new Uri(this.scheme, this.authority, changes.path); + } +} + +const state = { + enabled: true, + visibleTextEditors: [] as any[], + commands: [] as any[][], + stat: async (): Promise<{ size: number }> => ({ size: 12 }), + executeCommand: async (..._args: any[]): Promise => {}, + documentListeners: new Set<(...args: any[]) => void>() +}; +const vscode = { + Uri, + ColorThemeKind: { Dark: 1 }, + window: { + activeColorTheme: { kind: 1 }, + tabGroups: { all: [] }, + get visibleTextEditors() { return state.visibleTextEditors; } + }, + workspace: { + getConfiguration: () => ({ + get: (key: string, fallback: any) => key === 'enabled' ? state.enabled : fallback + }), + fs: { stat: () => state.stat() }, + onWillSaveTextDocument: () => ({ dispose() {} }), + onDidChangeTextDocument: (callback: (...args: any[]) => void) => { + state.documentListeners.add(callback); + return { dispose: () => state.documentListeners.delete(callback) }; + } + }, + commands: { + executeCommand: async (...args: any[]) => { + state.commands.push(args); + await state.executeCommand(...args); + } + } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return vscode; + } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +const context = { + extensionUri: new Uri('vscode-remote', 'wsl+Ubuntu', '/home/user/.vscode-server/extensions/csv'), + // A path alone cannot carry the remote authority of extensionUri. + extensionPath: '/home/user/.vscode-server/extensions/csv', + workspaceState: { get: (_key: string, fallback: any) => fallback } +}; + +function makePanel(viewColumn = 1) { + const disposeListeners: Array<() => void> = []; + const viewStateListeners: Array<(event: any) => void> = []; + const messages: any[] = []; + const resources: Uri[] = []; + let html = ''; + let disposed = false; + const panel = { + active: true, + viewColumn, + title: 'test.csv', + get disposed() { return disposed; }, + webview: { + options: {} as { localResourceRoots?: Uri[] }, + cspSource: 'vscode-webview:', + get html() { return html; }, + set html(value: string) { + assert.strictEqual(disposed, false, 'must not render into a disposed webview'); + html = value; + }, + asWebviewUri: (uri: Uri) => { resources.push(uri); return uri; }, + postMessage: (message: any) => { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage: () => ({ dispose() {} }) + }, + onDidDispose: (callback: () => void) => { + disposeListeners.push(callback); + return { dispose() {} }; + }, + onDidChangeViewState: (callback: (event: any) => void) => { + viewStateListeners.push(callback); + return { dispose() {} }; + }, + dispose: () => { + if (!disposed) { + disposed = true; + disposeListeners.forEach(callback => callback()); + } + }, + setActive: (active: boolean) => { + panel.active = active; + viewStateListeners.forEach(callback => callback({ webviewPanel: panel })); + } + }; + return { panel, messages, resources }; +} + +function makeDocument(uri: Uri, text = 'name,value\nfirst,1') { + return { uri, version: 1, getText: () => text }; +} + +async function resolve(document: ReturnType, panel: ReturnType['panel'], token = { isCancellationRequested: false }) { + const provider = new CsvEditorProvider(context as any); + await provider.resolveCustomTextEditor(document as any, panel as any, token as any); +} + +beforeEach(() => { + state.enabled = true; + state.visibleTextEditors = []; + state.commands = []; + state.stat = async () => ({ size: 12 }); + state.executeCommand = async () => {}; + state.documentListeners.clear(); + CsvEditorProvider.editors = []; + CsvEditorProvider.currentActive = undefined; +}); + +describe('CSV editor lifecycle and native text handoff', () => { + for (const [extension, text, separatorCode] of [ + ['csv', 'name,value\nfirst,1', 44], + ['tsv', 'name\tvalue\nfirst\t1', 9] + ] as const) { + for (const scheme of ['file', 'vscode-remote']) { + it(`preserves the ${scheme} ${extension.toUpperCase()} controller and resources across tab changes`, async () => { + const uri = new Uri(scheme, scheme === 'file' ? '' : 'wsl+Ubuntu', `/workspace/data.${extension}`); + const document = makeDocument(uri, text); + const { panel, messages, resources } = makePanel(); + await resolve(document, panel); + assert.ok(panel.webview.html.includes(`data-sepcode="${separatorCode}"`)); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.strictEqual(state.documentListeners.size, 1); + const controller = CsvEditorProvider.getActiveProvider(); + assert.ok(controller); + + panel.setActive(false); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + panel.setActive(true); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), controller); + assert.deepStrictEqual(messages[messages.length - 1], { type: 'focus' }); + assert.strictEqual(state.commands.length, 0, 'tab changes must not reopen or replace editors'); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.strictEqual(resources[0].toString(), `${context.extensionUri}/media/main.js`); + assert.strictEqual(panel.webview.options.localResourceRoots?.[0].toString(), `${context.extensionUri}/media`); + panel.dispose(); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + assert.strictEqual(state.documentListeners.size, 0); + }); + } + } + + it('does not register or render a preview closed during a slow remote file check', async () => { + let finishStat!: (value: { size: number }) => void; + state.stat = () => new Promise(resolveStat => { finishStat = resolveStat; }); + const { panel } = makePanel(); + const pending = resolve(makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', '/data.csv')), panel); + panel.dispose(); + finishStat({ size: 12 }); + await pending; + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(state.documentListeners.size, 0); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + + state.stat = async () => ({ size: 12 }); + const replacement = makePanel(); + await resolve(makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', '/data.csv')), replacement.panel); + assert.ok(replacement.panel.webview.html.includes('')); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.ok(CsvEditorProvider.getActiveProvider()); + replacement.panel.dispose(); + }); + + it('honors cancellation during asynchronous initialization', async () => { + let finishStat!: (value: { size: number }) => void; + state.stat = () => new Promise(resolveStat => { finishStat = resolveStat; }); + const { panel } = makePanel(); + const token = { isCancellationRequested: false }; + const pending = resolve(makeDocument(new Uri('file', '', '/data.csv')), panel, token); + token.isCancellationRequested = true; + finishStat({ size: 12 }); + await pending; + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(state.documentListeners.size, 0); + }); + + for (const extension of ['csv', 'tsv']) { + it(`keeps an already visible ${extension.toUpperCase()} search destination when disabled`, async () => { + state.enabled = false; + const document = makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', `/data.${extension}`)); + const selection = { start: { line: 87, character: 4 }, end: { line: 87, character: 10 } }; + const editor = { document, viewColumn: 2, selection }; + state.visibleTextEditors.push(editor); + const { panel } = makePanel(2); + await resolve(document, panel); + assert.strictEqual(state.commands.length, 0, 'must not reopen a text editor already at the requested result'); + assert.strictEqual(editor.selection, selection); + assert.strictEqual(panel.disposed, true); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + }); + } + + it('reopens the correct group once when disabling with no visible text editor', async () => { + const document = makeDocument(new Uri('file', '', '/data.csv')); + const { panel } = makePanel(2); + await resolve(document, panel); + const controller = CsvEditorProvider.getActiveProvider()!; + panel.setActive(false); + let finishOpen!: () => void; + state.executeCommand = () => new Promise(resolveOpen => { finishOpen = resolveOpen; }); + state.enabled = false; + controller.refresh(); + controller.refresh(); + assert.deepStrictEqual(state.commands, [[ + 'vscode.openWith', document.uri, 'default', { viewColumn: 2, preserveFocus: true } + ]]); + finishOpen(); + await Promise.resolve(); + await Promise.resolve(); + controller.refresh(); + assert.strictEqual(state.commands.length, 1, 'a disposed controller cannot reopen the file later'); + assert.strictEqual(panel.disposed, true); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + }); + + it('does not reuse a matching text editor from another group during fallback', async () => { + state.enabled = false; + const document = makeDocument(new Uri('file', '', '/data.tsv')); + state.visibleTextEditors.push({ document, viewColumn: 1 }); + const { panel } = makePanel(2); + await resolve(document, panel); + assert.deepStrictEqual(state.commands, [[ + 'vscode.openWith', document.uri, 'default', { viewColumn: 2, preserveFocus: false } + ]]); + assert.strictEqual(panel.disposed, true); + }); + + it('honors disabling while the remote size check is pending', async () => { + let finishStat!: (value: { size: number }) => void; + state.stat = () => new Promise(resolveStat => { finishStat = resolveStat; }); + const { panel } = makePanel(); + const document = makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', '/data.csv')); + const pending = resolve(document, panel); + state.enabled = false; + finishStat({ size: 12 }); + await pending; + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(state.commands.length, 1); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(panel.disposed, true); + }); + + it('cleans up a disabled panel even if the native fallback fails', async () => { + state.enabled = false; + state.executeCommand = async () => { throw new Error('editor unavailable'); }; + const { panel } = makePanel(); + await assert.rejects(resolve(makeDocument(new Uri('file', '', '/data.csv')), panel), /editor unavailable/); + assert.strictEqual(panel.disposed, true); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + }); +}); From 2dadc9c3eb69769f41bab5630fc73b854ec09f18 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:07:37 -0400 Subject: [PATCH 07/83] Make multiline and manual column widths predictable (#87, #98, #101) --- README.md | 2 +- media/main.js | 51 ++++--- src/CsvEditorProvider.ts | 48 ++++++- src/test-support/sizing-webview-harness.ts | 147 +++++++++++++++++++++ src/test/column-sizing.test.ts | 92 +++++++++++++ src/test/webview-size-persistence.test.ts | 2 +- 6 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 src/test-support/sizing-webview-harness.ts create mode 100644 src/test/column-sizing.test.ts diff --git a/README.md b/README.md index 9dc6cd7..8f7a614 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: ## Features - **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs. -- **Smart Resizing:** Automatic calculation of column widths for improved readability. +- **Smart Resizing:** Automatic widths follow the longest line in multiline cells. Drag within 10 pixels on either side of a column boundary to resize; widths are remembered per file across closing and reopening. Double-click a column boundary to restore automatic sizing. Resizing also works with the header row hidden. - **Dynamic Color Coding:** Visual cues based on data type help you quickly identify numbers, dates, booleans, and more (with opt‑in cool palette and theme-foreground mode settings). - **Sticky Headers:** Keep column titles in view as you scroll through large datasets. - **Enhanced Keyboard Navigation:** Navigate cells with arrows and Tab/Shift+Tab; quick edits can commit with arrow keys; `Ctrl/Cmd + A` selects all; `Ctrl/Cmd + C` copies selection. diff --git a/media/main.js b/media/main.js index 8b4678a..2b204c5 100644 --- a/media/main.js +++ b/media/main.js @@ -33,6 +33,7 @@ let editingCell = null, originalCellValue = ""; let editMode = null; // 'quick' | 'detail' | null const DRAG_THRESHOLD_PX = 4; const RESIZE_HANDLE_PX = 6; +const COLUMN_RESIZE_HANDLE_PX = 10; let resizeState = null; let reorderState = null; @@ -45,6 +46,9 @@ dragIndicator.style.zIndex = '20000'; dragIndicator.style.background = '#0a84ff'; dragIndicator.style.display = 'none'; document.body.appendChild(dragIndicator); +const automaticColumnSizes = new WeakMap(); +let persistedColumnSizes = {}; +try { persistedColumnSizes = JSON.parse(root?.dataset?.columnsizes || '{}'); } catch {} let columnSizeState = {}; let rowSizeState = {}; @@ -61,13 +65,22 @@ const normalizeSizeState = (raw, minSize) => { return out; }; +const setRenderedColumnWidth = (cell, px) => { + if (!automaticColumnSizes.has(cell)) { + automaticColumnSizes.set(cell, { width: cell.style.width, minWidth: cell.style.minWidth, maxWidth: cell.style.maxWidth }); + } + cell.style.width = `${px}px`; + cell.style.minWidth = `${px}px`; + cell.style.maxWidth = `${px}px`; +}; +const persistColumnSizes = () => { + vscode.postMessage({ type: 'columnSizesChanged', columnSizes: { ...columnSizeState } }); +}; const applySizeStateToRenderedCells = () => { for (const [col, width] of Object.entries(columnSizeState)) { const px = Math.max(40, Math.round(Number(width))); table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = `${px}px`; - cell.style.minWidth = `${px}px`; - cell.style.maxWidth = `${px}px`; + setRenderedColumnWidth(cell, px); }); } for (const [row, height] of Object.entries(rowSizeState)) { @@ -153,7 +166,7 @@ const restoreState = () => { const st = vscode.getState() || {}; const restoredZoom = parsePositiveNumber(st.zoomScale); setZoomScale(restoredZoom ?? 1, false); - columnSizeState = normalizeSizeState(st.columnSizes, 40); + columnSizeState = normalizeSizeState(st.columnSizes ?? persistedColumnSizes, 40); rowSizeState = normalizeSizeState(st.rowSizes, getMinRowHeight()); applySizeStateToRenderedCells(); if (typeof st.scrollX === 'number' && scrollContainer) { @@ -561,13 +574,18 @@ const getRowDropTarget = clientY => { }; const getResizeEdgeInfo = (target, e) => { if (!target) return null; - if (isColumnHeaderCell(target)) { - const col = parseInt(target.getAttribute('data-col') || 'NaN', 10); - if (!Number.isNaN(col)) { - const rect = target.getBoundingClientRect(); - const edgeDelta = rect.right - e.clientX; - if (edgeDelta >= 0 && edgeDelta <= RESIZE_HANDLE_PX) { - return { axis: 'column', index: col, rect }; + const coords = getDataCellCoords(target); + if (coords) { + const rect = target.getBoundingClientRect(); + const rightDistance = rect.right - e.clientX; + if (rightDistance >= 0 && rightDistance <= COLUMN_RESIZE_HANDLE_PX) { + return { axis: 'column', index: coords.col, rect }; + } + const leftDistance = e.clientX - rect.left; + if (coords.col > 0 && leftDistance >= 0 && leftDistance <= COLUMN_RESIZE_HANDLE_PX) { + const previous = getRenderedCellByCoords(coords.row, coords.col - 1); + if (previous) { + return { axis: 'column', index: coords.col - 1, rect: previous.getBoundingClientRect() }; } } } @@ -587,17 +605,14 @@ const applyColumnWidth = (col, widthPx) => { const width = Math.max(40, Math.round(widthPx)); columnSizeState[String(col)] = width; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = `${width}px`; - cell.style.minWidth = `${width}px`; - cell.style.maxWidth = `${width}px`; + setRenderedColumnWidth(cell, width); }); }; const resetColumnWidth = col => { delete columnSizeState[String(col)]; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = ''; - cell.style.minWidth = ''; - cell.style.maxWidth = ''; + const automatic = automaticColumnSizes.get(cell); + if (automatic) Object.assign(cell.style, automatic); }); }; const applyRowHeight = (row, heightPx) => { @@ -700,6 +715,7 @@ const onGlobalDragMove = e => { }; const onGlobalDragEnd = () => { if (resizeState) { + if (resizeState.axis === 'column') persistColumnSizes(); resizeState = null; table.style.cursor = ''; persistState(); @@ -2022,6 +2038,7 @@ table.addEventListener('dblclick', e => { e.stopPropagation(); if (edge.axis === 'column') { resetColumnWidth(edge.index); + persistColumnSizes(); } else { resetRowHeight(edge.index); } diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 6e0aab0..a1484e4 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -148,6 +148,9 @@ class CsvEditorController { case 'save': await this.handleSave(); break; + case 'columnSizesChanged': + 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); break; @@ -1452,7 +1455,8 @@ class CsvEditorController { nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, - mouseWheelZoomInvert + mouseWheelZoomInvert, + columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri) }); } @@ -1705,8 +1709,9 @@ class CsvEditorController { hasRemoteChunks: boolean; mouseWheelZoomEnabled: boolean; mouseWheelZoomInvert: boolean; + columnSizes: Record; }): string { - const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert } = args; + const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes } = 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( @@ -1918,7 +1923,7 @@ class CsvEditorController { -
+
${tableHtml}
@@ -1978,7 +1983,9 @@ class CsvEditorController { const widths = Array(numColumns).fill(0); for (const row of data) { for (let i = 0; i < numColumns; i++){ - widths[i] = Math.max(widths[i], (row[i] || '').length); + for (const line of (row[i] || '').split(/\r\n|[\r\n]/)) { + widths[i] = Math.max(widths[i], line.length); + } } } console.log(`CSV: Column widths: ${widths}`); @@ -2226,6 +2233,7 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { public static readonly headerKey = 'csv.headerByUri'; public static readonly serialKey = 'csv.serialIndexByUri'; public static readonly sepKey = 'csv.separatorByUri'; + public static readonly columnSizesKey = 'csv.columnSizesByUri'; private static readonly DEFAULT_SEPARATOR = ','; private static readonly DEFAULT_SEPARATOR_MODE: SeparatorMode = 'extension'; private static readonly BUILTIN_SEPARATORS_BY_EXTENSION: Record = { @@ -2596,6 +2604,38 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { await context.workspaceState.update(CsvEditorProvider.serialKey, map); } + public static normalizeColumnSizes(raw: unknown): Record { + const sizes: Record = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return sizes; + } + for (const [key, value] of Object.entries(raw)) { + const col = Number(key); + const width = Number(value); + if (!Number.isInteger(col) || col < 0 || !Number.isFinite(width) || width < 40) { + continue; + } + sizes[String(col)] = Math.round(width); + } + return sizes; + } + + public static getColumnSizesForUri(context: vscode.ExtensionContext, uri: vscode.Uri): Record { + const map = context.workspaceState.get>(CsvEditorProvider.columnSizesKey, {}); + return CsvEditorProvider.normalizeColumnSizes(map[uri.toString()]); + } + + public static async setColumnSizesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, raw: unknown): Promise { + const map = { ...context.workspaceState.get>(CsvEditorProvider.columnSizesKey, {}) }; + const sizes = CsvEditorProvider.normalizeColumnSizes(raw); + if (Object.keys(sizes).length) { + map[uri.toString()] = sizes; + } else { + delete map[uri.toString()]; + } + await context.workspaceState.update(CsvEditorProvider.columnSizesKey, map); + } + public static getSeparatorForUri(context: vscode.ExtensionContext, uri: vscode.Uri): string | undefined { const map = context.workspaceState.get>(CsvEditorProvider.sepKey, {}); return map[uri.toString()]; diff --git a/src/test-support/sizing-webview-harness.ts b/src/test-support/sizing-webview-harness.ts new file mode 100644 index 0000000..82c9558 --- /dev/null +++ b/src/test-support/sizing-webview-harness.ts @@ -0,0 +1,147 @@ +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; + +type Listener = { callback: (event: any) => void; capture: boolean }; + +// Execute the complete shipped script. The small DOM model supplies selection, +// focus and event propagation; it does not reimplement clipboard behavior. +export function createWebview(options: { state?: any; persistedColumns?: Record; header?: boolean } = {}) { + class Element { + listeners = new Map(); + attributes = new Map(); + classes = new Set(); + children: Element[] = []; + parentElement: Element | null = null; + style: Record = {}; + dataset: Record = {}; + textContent = ''; + value = ''; + isContentEditable = false; + isConnected = true; + rect = { left: 0, right: 100, top: 0, bottom: 30, width: 100, height: 30 }; + getBoundingClientRect() { return this.rect; } + classList = { + add: (...names: string[]) => names.forEach(name => this.classes.add(name)), + remove: (...names: string[]) => names.forEach(name => this.classes.delete(name)), + contains: (name: string) => this.classes.has(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !this.classes.has(name); + if (next) { this.classes.add(name); } else { this.classes.delete(name); } + return next; + } + }; + constructor(public tagName = 'DIV', public id = '') {} + addEventListener(type: string, callback: (event: any) => void, options?: any) { + const listeners = this.listeners.get(type) || []; + listeners.push({ callback, capture: options === true }); + this.listeners.set(type, listeners); + } + removeEventListener() {} + setAttribute(name: string, value: string) { this.attributes.set(name, value); } + getAttribute(name: string) { return this.attributes.get(name) ?? null; } + hasAttribute(name: string) { return this.attributes.has(name); } + appendChild(child: Element) { child.parentElement = this; this.children.push(child); } + contains(other: Element): boolean { return other === this || this.children.some(child => child.contains(other)); } + focus() { document.activeElement = this; } + scrollIntoView() {} + matches(selector: string): boolean { + if (selector.includes(' ')) { + const [ancestor, child] = selector.split(' '); + return this.matches(child) && !!this.parentElement?.closest(ancestor); + } + if (selector.startsWith('#')) { return this.id === selector.slice(1); } + const tag = selector.match(/^[a-z]+/i)?.[0]; + if (tag && this.tagName !== tag.toUpperCase()) { return false; } + if (selector.includes('.')) { return selector.split('.').slice(1).every(name => this.classes.has(name)); } + for (const match of selector.matchAll(/\[([^=\]]+)(?:="([^"]*)")?\]/g)) { + if (match[2] === undefined ? !this.hasAttribute(match[1]) : this.getAttribute(match[1]) !== match[2]) { return false; } + } + return true; + } + closest(selector: string): Element | null { + if (selector.split(',').some(part => this.matches(part.trim()))) { return this; } + return this.parentElement?.closest(selector) ?? null; + } + querySelectorAll(selector: string): Element[] { + const descendants = this.children.flatMap(child => [child, ...child.querySelectorAll('*')]); + return descendants.filter(child => selector === '*' || selector.split(',').some(part => child.matches(part.trim()))); + } + querySelector(selector: string) { return this.querySelectorAll(selector)[0] ?? null; } + get innerHTML() { return ''; } + set innerHTML(_value: string) { this.children = []; } + get innerText() { return this.textContent; } + set innerText(value: string) { this.textContent = value; } + } + const document = new Element('DOCUMENT') as Element & { body: Element; activeElement: Element | null; getElementById: (id: string) => Element }; + document.body = new Element('BODY'); + document.activeElement = null; + document.appendChild(document.body); + const elements = new Map(); + const byId = (id: string) => { + if (!elements.has(id)) { + const el = new Element(id.endsWith('Input') ? 'INPUT' : 'DIV', id); + elements.set(id, el); + document.body.appendChild(el); + } + return elements.get(id)!; + }; + document.getElementById = byId; + const root = byId('csv-root'); + const table = new Element('TABLE'); + const head = new Element('THEAD'); + if (options.header !== false) { table.appendChild(head); } + root.appendChild(table); + root.dataset.columnsizes = JSON.stringify(options.persistedColumns || {}); + byId('__csvChunks').textContent = '[]'; + const widget = byId('findReplaceWidget'); + for (const id of ['findInput', 'replaceInput']) { + const input = byId(id); + document.body.children = document.body.children.filter(el => el !== input); + widget.appendChild(input); + } + const window = new Element('WINDOW') as any; + window.getComputedStyle = () => ({ fontSize: '14px' }); + const messages: any[] = []; + let savedState = options.state || {}; + const context = vm.createContext({ + document, window, Element, Node: Element, + acquireVsCodeApi: () => ({ getState: () => savedState, setState: (state: any) => { savedState = state; }, postMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))) }), + setTimeout: () => 0, clearTimeout: () => {}, requestAnimationFrame: () => 0, + console + }); + (document as any).createElement = (tag: string) => new Element(tag.toUpperCase()); + vm.runInContext(fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'), context); + const cell = (row: number, col: number, value: string, header = false) => { + const el = new Element(header ? 'TH' : 'TD'); + el.rect = { left: col * 100, right: (col + 1) * 100, top: row * 30, bottom: (row + 1) * 30, width: 100, height: 30 }; + el.style.width = ''; el.style.minWidth = '8ch'; el.style.maxWidth = '100ch'; + el.setAttribute('data-row', String(row)); + el.setAttribute('data-col', String(col)); + el.textContent = value; + (header ? head : table).appendChild(el); + return el; + }; + const dispatch = (target: Element, type: string, options: any = {}) => { + const event: any = { + type, target, defaultPrevented: false, stopped: false, immediate: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.stopped = true; }, + stopImmediatePropagation() { this.stopped = true; this.immediate = true; }, + ...options + }; + const invoke = (el: Element, capture: boolean) => { + for (const listener of el.listeners.get(type) || []) { + if (listener.capture === capture && !event.immediate) { listener.callback(event); } + } + }; + invoke(document, true); + if (!event.stopped) { invoke(target, false); } + for (let parent = target.parentElement; parent && !event.stopped; parent = parent.parentElement) { invoke(parent, false); } + return event; + }; + return { context, cell, byId, document, table, messages, dispatch, select: (startRow: number, startCol: number, endRow = startRow, endCol = startCol) => { + vm.runInContext(`selectRange({ row: ${startRow}, col: ${startCol} }, { row: ${endRow}, col: ${endCol} }); anchorCell = currentSelection[0];`, context); + }}; +} + diff --git a/src/test/column-sizing.test.ts b/src/test/column-sizing.test.ts new file mode 100644 index 0000000..833aa42 --- /dev/null +++ b/src/test/column-sizing.test.ts @@ -0,0 +1,92 @@ +import assert from 'assert'; +import Module from 'module'; +import vm from 'vm'; +import { describe, it } from 'node:test'; +import { createWebview } from '../test-support/sizing-webview-harness'; + +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { return { window: { activeColorTheme: { kind: 1 } }, ColorThemeKind: { Dark: 1 } }; } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +describe('Column sizing behavior', () => { + it('measures longest physical line across LF, CRLF and CR without counting separators', () => { + assert.deepStrictEqual(CsvEditorProvider.__test.computeColumnWidths([ + ['short\nlongest', 'a\r\nb\r\nc', '123\r45', ''], + ['x\nx\nx\nx\nx\nx\nx\nx', 'header', '1', 'tiny'] + ]), [7, 6, 3, 4]); + }); + + it('stores validated manual widths per URI and removes only the reset file', async () => { + const store: Record = {}; + const context = { workspaceState: { + get: (key: string, fallback: any) => store[key] ?? fallback, + update: async (key: string, value: any) => { store[key] = value; } + }} as any; + const first = { toString: () => 'file:///one.csv' } as any; + const second = { toString: () => 'file:///two.tsv' } as any; + await CsvEditorProvider.setColumnSizesForUri(context, first, { '0': 180.3, '-1': 100, invalid: 200, '1': 39, '2': Infinity }); + await CsvEditorProvider.setColumnSizesForUri(context, second, { '1': 220 }); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, first), { '0': 180 }); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, second), { '1': 220 }); + await CsvEditorProvider.setColumnSizesForUri(context, first, {}); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, first), {}); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, second), { '1': 220 }); + }); + + it('restores durable widths when a fresh webview opens, including newly mounted chunks', () => { + const view = createWebview({ persistedColumns: { '0': 210 } }); + const cell = view.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells();', view.context); + assert.strictEqual(cell.style.width, '210px'); + const chunkCell = view.cell(1001, 0, 'later chunk'); + vm.runInContext('applySizeStateToRenderedCells();', view.context); + assert.strictEqual(chunkCell.style.minWidth, '210px'); + }); + + it('honors an explicit webview reset over durable values and restores automatic CSS', () => { + const reset = createWebview({ state: { columnSizes: {} }, persistedColumns: { '0': 210 } }); + const untouched = reset.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells();', reset.context); + assert.strictEqual(untouched.style.minWidth, '8ch'); + const view = createWebview({ persistedColumns: { '0': 210 } }); + const cell = view.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells(); resetColumnWidth(0); persistColumnSizes();', view.context); + assert.strictEqual(cell.style.width, ''); + assert.strictEqual(cell.style.minWidth, '8ch'); + assert.strictEqual(cell.style.maxWidth, '100ch'); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: {} }]); + }); + + it('starts and saves a resize from both sides of a boundary with headers on or off', () => { + for (const header of [true, false]) { + for (const side of ['left', 'right']) { + const view = createWebview({ header }); + const first = view.cell(header ? 0 : 1, 0, 'one', header); + const second = view.cell(header ? 0 : 1, 1, 'two', header); + const target = side === 'left' ? first : second; + const clientX = side === 'left' ? 91 : 109; + const event = view.dispatch(target, 'mousedown', { button: 0, clientX, clientY: 15 }); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + view.dispatch(target, 'mousemove', { clientX: clientX + 50, clientY: 15 }); + view.dispatch(target, 'mouseup'); + assert.strictEqual(first.style.width, '150px'); + assert.strictEqual(second.style.width, ''); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: { '0': 150 } }]); + } + } + }); + + it('double-click resets a resized column and persists the reset', () => { + const view = createWebview(); + const header = view.cell(0, 0, 'one', true); + vm.runInContext('applyColumnWidth(0, 230);', view.context); + view.dispatch(header, 'dblclick', { clientX: 96, clientY: 15 }); + assert.strictEqual(header.style.minWidth, '8ch'); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: {} }]); + }); +}); diff --git a/src/test/webview-size-persistence.test.ts b/src/test/webview-size-persistence.test.ts index 4399f1e..3ecdddd 100644 --- a/src/test/webview-size-persistence.test.ts +++ b/src/test/webview-size-persistence.test.ts @@ -13,7 +13,7 @@ describe('Webview size persistence', () => { }); it('restores and reapplies size state after render/chunk loads', () => { - assert.ok(source.includes('columnSizeState = normalizeSizeState(st.columnSizes, 40);')); + assert.ok(source.includes('columnSizeState = normalizeSizeState(st.columnSizes ?? persistedColumnSizes, 40);')); assert.ok(source.includes('rowSizeState = normalizeSizeState(st.rowSizes, getMinRowHeight());')); assert.ok(source.includes('setZoomScale(restoredZoom ?? 1, false);')); assert.ok(source.includes('applySizeStateToRenderedCells();')); From ec65f0fcb8547f67cfcaa560af2d4d20c3be9fec Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:12:25 -0400 Subject: [PATCH 08/83] fix: guide explicit source encoding and respect cancelled reopen (#76) --- README.md | 12 +++- package-lock.json | 1 + package.json | 1 + src/CsvEditorProvider.ts | 18 +++++ src/commands.ts | 12 ++-- src/test/edit-integrity-harness.ts | 36 +++++++++- src/test/encoding-integrity.test.ts | 88 +++++++++++++++++++++++++ src/test/fixtures/README.md | 7 ++ src/test/fixtures/issue-76-products.csv | 79 ++++++++++++++++++++++ 9 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 src/test/encoding-integrity.test.ts create mode 100644 src/test/fixtures/README.md create mode 100644 src/test/fixtures/issue-76-products.csv diff --git a/README.md b/README.md index fd300ce..f9dee2e 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Global (Settings UI or `settings.json`): - `csv.defaultSeparator` (string, default `,`): Fallback separator. Use `\\t` in `settings.json` for tabs. - `csv.separatorByExtension` (object): Extension-to-separator mapping (defaults include `.csv`→`,`, `.tsv`/`.tab`→tab, `.psv`→`|`). - `csv.maxFileSizeMB` (number, default `10`): Soft limit for opening files in CSV view. If exceeded, CSV prompts: `Cancel`, `Continue This Time`, or `Ignore Forever` (sets this setting to `0`). -- Per-file encoding: use `CSV: Change File Encoding` to set a file's encoding (e.g., `utf8`, `utf16le`, `windows1250`, `gbk`). The extension will reopen the file using the chosen encoding. +- Per-file encoding: `CSV: Change File Encoding` opens VS Code's encoding picker. Use **Reopen with Encoding** and select the source file's encoding to correct incorrectly displayed characters before editing or saving. Per-file (stored by the extension; set via commands): @@ -121,6 +121,16 @@ Per-file (stored by the extension; set via commands): - CSV separator override — `CSV: Change CSV Separator` (or clear it with `CSV: Reset CSV Separator (Inherit)`) - Hide first N rows (default `0`) — `CSV: Hide First N Rows` +### File encodings and accented characters + +CSV displays the Unicode text supplied by VS Code; VS Code controls decoding the file's bytes and encoding saved files. If accents look wrong, run `CSV: Change File Encoding`, choose **Reopen with Encoding**, and select the encoding used by the source. Check the names before saving. Cancelling the unsaved-changes prompt stops the command and preserves your open edits. + +The editor shows a nonmodal warning once per opened document when it contains the replacement character `�`, offering the same encoding command. This character can be legitimate text, so the warning indicates a possible mismatch and does not guess an encoding or change any data. Some incorrect decodings produce other characters instead and will not trigger this warning. + +For example, the original attachment in [issue #76](https://github.com/jonaraphael/csv/issues/76) is not valid UTF-8. Explicitly decoding it as **DOS (CP 437)** or **Western European DOS (CP 850)** restores names such as `Knäckebröd` and `Thüringer`; both encodings produce the same text for that particular file, so its bytes do not uniquely identify which one created it. Use the source's documented encoding for other files. Available picker labels depend on your VS Code version. + +**Save with Encoding** changes how the current text is written; it cannot restore characters already decoded incorrectly. If a corrupted version was saved, reopen the original source or a backup using the correct encoding first. + --- ## Editing Modes and Shortcuts diff --git a/package-lock.json b/package-lock.json index 2d9813c..6f578fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@typescript-eslint/parser": "^7.9.0", "@vscode/vsce": "^3.7.1", "eslint": "^8.57.0", + "iconv-lite": "^0.6.3", "jsdom": "^26.1.0", "typescript": "^4.4.3", "vscode": "^1.1.37" diff --git a/package.json b/package.json index 092489b..a1745ea 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "@typescript-eslint/parser": "^7.9.0", "@vscode/vsce": "^3.7.1", "eslint": "^8.57.0", + "iconv-lite": "^0.6.3", "jsdom": "^26.1.0", "typescript": "^4.4.3", "vscode": "^1.1.37" diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 2f1babf..31db8a3 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -61,6 +61,7 @@ class CsvEditorController { private static readonly DEFAULT_MAX_FILE_SIZE_MB = 10; private static readonly LARGE_FILE_CONTINUE_THIS_TIME = 'Continue This Time'; private static readonly LARGE_FILE_IGNORE_FOREVER = 'Ignore Forever'; + private static readonly encodingWarnings = new WeakSet(); private isUpdatingDocument = false; private isSaving = false; @@ -109,6 +110,7 @@ class CsvEditorController { if (webviewPanel.active) { CsvEditorProvider.currentActive = this; } + void this.warnAboutPossibleDecoding(); webviewPanel.webview.postMessage({ type: 'focus' }); let wasVisible = webviewPanel.visible; @@ -225,6 +227,22 @@ class CsvEditorController { return this.pendingOperation; } + private async warnAboutPossibleDecoding(): Promise { + if (!this.document.getText().includes('\uFFFD') || CsvEditorController.encodingWarnings.has(this.document)) { + return; + } + CsvEditorController.encodingWarnings.add(this.document); + const action = 'Change File Encoding'; + const selected = await vscode.window.showWarningMessage( + 'CSV: This file contains replacement characters (�), which can indicate a decoding mismatch. Check the source encoding before saving.', + action + ); + if (selected === action) { + // Bind the action to this document even if another tab becomes active. + await vscode.commands.executeCommand('csv.changeEncoding', this.document.uri); + } + } + private getMaxFileSizeLimitMb(config: vscode.WorkspaceConfiguration): number { const raw = Number(config.get('maxFileSizeMB', CsvEditorController.DEFAULT_MAX_FILE_SIZE_MB)); if (!Number.isFinite(raw) || raw <= 0) { diff --git a/src/commands.ts b/src/commands.ts index 6241b3d..f0feb8d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -127,10 +127,10 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { .forEach(ed => ed.refresh()); vscode.window.showInformationMessage(`CSV: Hiding first ${n} row(s) for this file.`); }), - vscode.commands.registerCommand('csv.changeEncoding', async () => { + vscode.commands.registerCommand('csv.changeEncoding', async (requestedUri?: vscode.Uri) => { const active = CsvEditorProvider.getActiveProvider(); - if (!active) { vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); return; } - const uri = active.getDocumentUri(); + const uri = requestedUri instanceof vscode.Uri ? requestedUri : active?.getDocumentUri(); + if (!uri) { vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); return; } // Close any existing tabs for this URI so we can reuse the slot cleanly try { @@ -144,10 +144,14 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { }); if (toClose.length) { console.log(`[CSV(encoding)]: closing ${toClose.length} tab(s) for ${uri.fsPath}`); - await vscode.window.tabGroups.close(toClose); + const closed = await vscode.window.tabGroups.close(toClose); + if (!closed) { + return; + } } } catch (e) { console.warn('[CSV(encoding)]: failed to close existing tabs', e); + return; } // Open the default text editor in-place and invoke the built-in encoding picker diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..438de98 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -19,7 +19,27 @@ const vscodeStub = { replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } }, ColorThemeKind: { Dark: 1 }, - window: { activeColorTheme: { kind: 1 } }, + window: { + activeColorTheme: { kind: 1 }, + showWarningMessage: async (message: string) => { + activeHarness.warnings.push(message); + return activeHarness.warningChoice; + }, + showInformationMessage() {}, + tabGroups: { + get all() { return [{ tabs: [{ input: { uri: activeHarness.document.uri, viewType: 'csv.editor' } }] }]; }, + close: async () => activeHarness.closeResult + } + }, + commands: { + registerCommand: (name: string, command: (...args: any[]) => unknown) => { + activeHarness.commands.set(name, command); + return { dispose() {} }; + }, + executeCommand: async (name: string, ...args: unknown[]) => { + activeHarness.executedCommands.push({ name, args }); + } + }, workspace: { getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), @@ -45,13 +65,15 @@ const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import(' const { JSDOM } = require('jsdom'); export async function createEditorHarness(options: { + text?: string; + warningChoice?: 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> = []; @@ -76,6 +98,7 @@ export async function createEditorHarness(options: { } }; const context = { + subscriptions: [] as unknown[], extensionPath: process.cwd(), workspaceState: { values: {} as Record, @@ -101,6 +124,15 @@ export async function createEditorHarness(options: { }; const harness = { document, panel, messages, changeListeners, saveListeners, + warnings: [] as string[], + warningChoice: options.warningChoice, + closeResult: true, + commands: new Map unknown>(), + executedCommands: [] as Array<{ name: string; args: unknown[] }>, + registerCommands() { + const { registerCsvCommands } = require('../commands') as typeof import('../commands'); + registerCsvCommands(context as any); + }, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, diff --git a/src/test/encoding-integrity.test.ts b/src/test/encoding-integrity.test.ts new file mode 100644 index 0000000..33e3787 --- /dev/null +++ b/src/test/encoding-integrity.test.ts @@ -0,0 +1,88 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness } from './edit-integrity-harness'; +import iconv from 'iconv-lite'; + +describe('Explicit source decoding preserves original non-UTF8 characters', () => { + const original = fs.readFileSync(path.join(process.cwd(), 'src', 'test', 'fixtures', 'issue-76-products.csv')); + const decoded = iconv.decode(original, 'cp437'); + + it('keeps the reporter fixture byte-for-byte and establishes the supported decoding ambiguity', () => { + assert.strictEqual(original.length, 4052); + assert.ok(original.toString('utf8').includes('\uFFFD')); + assert.ok(!decoded.includes('\uFFFD')); + assert.strictEqual(iconv.decode(original, 'cp850'), decoded); + assert.deepStrictEqual(iconv.encode(decoded, 'cp437'), original); + for (const name of ['Knäckebröd', 'Thüringer', 'Guaraná', 'Nuß']) { + assert.ok(decoded.includes(name)); + } + }); + + it('renders and edits the correctly decoded document without changing accented names', async () => { + const host = await createEditorHarness({ text: decoded }); + assert.strictEqual(host.warnings.length, 0); + assert.ok(host.panel.webview.html.includes('Knäckebröd')); + const row = host.rows().findIndex(record => record[1]?.includes('Knäckebröd')); + await host.receive({ type: 'editCell', row, col: 5, value: '22.00' }); + await host.receive({ type: 'save' }); + const saved = iconv.decode(iconv.encode(host.diskText, 'cp437'), 'cp437'); + for (const name of ['Knäckebröd', 'Thüringer', 'Guaraná', 'Nuß']) { + assert.ok(saved.includes(name)); + } + }); +}); + +describe('Possible decoding mismatches use an explicit encoding workflow', () => { + it('warns once without changing already decoded data when dismissed', async () => { + const host = await createEditorHarness({ rows: [['text'], ['replacement � may be intentional']] }); + const originalText = host.document.getText(); + assert.strictEqual(host.warnings.length, 1); + assert.match(host.warnings[0], /can indicate a decoding mismatch/); + await host.view(false, false); + await host.view(true, true); + assert.strictEqual(host.warnings.length, 1); + assert.strictEqual(host.executedCommands.length, 0); + assert.strictEqual(host.document.getText(), originalText); + }); + + it('binds the warning action to the originating document URI', async () => { + const host = await createEditorHarness({ rows: [['text'], ['�']], warningChoice: 'Change File Encoding' }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(host.executedCommands, [{ name: 'csv.changeEncoding', args: [host.document.uri] }]); + }); + + it('stops the encoding command when the user cancels closing a dirty document', async () => { + const host = await createEditorHarness(); + await host.receive({ type: 'editCell', row: 1, col: 0, value: 'unsaved' }); + host.closeResult = false; + host.registerCommands(); + await host.commands.get('csv.changeEncoding')?.(); + assert.strictEqual(host.executedCommands.length, 0); + assert.strictEqual(host.rows()[1][0], 'unsaved'); + assert.ok(!host.diskText.includes('unsaved')); + }); + + it('opens the native encoding picker and returns to the CSV view after a successful close', async () => { + const host = await createEditorHarness(); + host.registerCommands(); + await host.commands.get('csv.changeEncoding')?.(host.document.uri); + assert.deepStrictEqual(host.executedCommands.map(command => command.name), [ + 'vscode.openWith', 'workbench.action.editor.changeEncoding', 'vscode.openWith' + ]); + assert.strictEqual(host.executedCommands[0].args[0], host.document.uri); + assert.strictEqual(host.executedCommands[2].args[1], 'csv.editor'); + }); + + it('round-trips UTF-8 accented CSV and TSV without triggering a warning', async () => { + for (const separator of [',', '\t']) { + const host = await createEditorHarness({ separator, rows: [['name', 'value'], ['éäöüßøå', 'before']] }); + await host.receive({ type: 'editCell', row: 1, col: 1, value: 'after' }); + await host.receive({ type: 'save' }); + assert.strictEqual(host.warnings.length, 0); + assert.strictEqual(host.rows()[1][0], 'éäöüßøå'); + assert.ok(Buffer.from(host.diskText, 'utf8').toString('utf8').includes('éäöüßøå')); + } + }); +}); diff --git a/src/test/fixtures/README.md b/src/test/fixtures/README.md new file mode 100644 index 0000000..4af6753 --- /dev/null +++ b/src/test/fixtures/README.md @@ -0,0 +1,7 @@ +`issue-76-products.csv` is the original 4,052-byte attachment from +https://github.com/jonaraphael/csv/issues/76, downloaded without conversion from +https://github.com/user-attachments/files/25353528/w3schools_dbo_Products.csv. + +It is not valid UTF-8. CP437 and CP850 produce identical Unicode text for all +bytes present in this particular fixture; the fixture cannot distinguish which +encoding created it. Tests decode explicitly and do not infer or repair bytes. diff --git a/src/test/fixtures/issue-76-products.csv b/src/test/fixtures/issue-76-products.csv new file mode 100644 index 0000000..c5d87b3 --- /dev/null +++ b/src/test/fixtures/issue-76-products.csv @@ -0,0 +1,79 @@ +[ProductID],[ProductName],[SuplID],[CatID],[Unit],[Price] +[int],[varchar(50)],[int],[int],[varchar(50)],[decimal(18, 2)] +1,"Chais",1,1,"10 boxes x 20 bags",18.00 +2,"Chang",1,1,"24 - 12 oz bottles",19.00 +3,"Aniseed Syrup",1,2,"12 - 550 ml bottles",10.00 +4,"Chef Antons Cajun Seasoning",2,2,"48 - 6 oz jars",22.00 +5,"Chef Antons Gumbo Mix",2,2,"36 boxes",21.35 +6,"Grandmas Boysenberry Spread",3,2,"12 - 8 oz jars",25.00 +7,"Uncle Bobs Organic Dried Pears",3,7,"12 - 1 lb pkgs",30.00 +8,"Northwoods Cranberry Sauce",3,2,"12 - 12 oz jars",40.00 +9,"Mishi Kobe Niku",4,6,"18 - 500 g pkgs",97.00 +10,"Ikura",4,8,"12 - 200 ml jars",31.00 +11,"Queso Cabrales",5,4,"1 kg pkg",21.00 +12,"Queso Manchego La Pastora",5,4,"10 - 500 g pkgs",38.00 +13,"Konbu",6,8,"2 kg box",6.00 +14,"Tofu",6,7,"40 - 100 g pkgs",23.25 +15,"Genen Shouyu",6,2,"24 - 250 ml bottles",15.50 +16,"Pavlova",7,3,"32 - 500 g boxes",17.45 +17,"Alice Mutton",7,6,"20 - 1 kg tins",39.00 +18,"Carnarvon Tigers",7,8,"16 kg pkg",62.50 +19,"Teatime Chocolate Biscuits",8,3,"10 boxes x 12 pieces",9.20 +20,"Sir Rodneys Marmalade",8,3,"30 gift boxes",81.00 +21,"Sir Rodneys Scones",8,3,"24 pkgs. x 4 pieces",10.00 +22,"Gustafs Kn„ckebr”d",9,5,"24 - 500 g pkgs",21.00 +23,"Tunnbr”d",9,5,"12 - 250 g pkgs",9.00 +24,"Guaran  Fant stica",10,1,"12 - 355 ml cans",4.50 +25,"NuNuCa Nuá-Nougat-Creme",11,3,"20 - 450 g glasses",14.00 +26,"Gumb„r Gummib„rchen",11,3,"100 - 250 g bags",31.23 +27,"Schoggi Schokolade",11,3,"100 - 100 g pieces",43.90 +28,"R”ssle Sauerkraut",12,7,"25 - 825 g cans",45.60 +29,"Th�ringer Rostbratwurst",12,6,"50 bags x 30 sausgs",123.79 +30,"Nord-Ost Matjeshering",13,8,"10 - 200 g glasses",25.89 +31,"Gorgonzola Telino",14,4,"12 - 100 g pkgs",12.50 +32,"Mascarpone Fabioli",14,4,"24 - 200 g pkgs",32.00 +33,"Geitost",15,4,"500 g",2.50 +34,"Sasquatch Ale",16,1,"24 - 12 oz bottles",14.00 +35,"Steeleye Stout",16,1,"24 - 12 oz bottles",18.00 +36,"Inlagd Sill",17,8,"24 - 250 g jars",19.00 +37,"Gravad lax",17,8,"12 - 500 g pkgs",26.00 +38,"C“te de Blaye",18,1,"12 - 75 cl bottles",263.50 +39,"Chartreuse verte",18,1,"750 cc per bottle",18.00 +40,"Boston Crab Meat",19,8,"24 - 4 oz tins",18.40 +41,"Jacks New England Clam Chowder",19,8,"12 - 12 oz cans",9.65 +42,"Singaporean Hokkien Fried Mee",20,5,"32 - 1 kg pkgs",14.00 +43,"Ipoh Coffee",20,1,"16 - 500 g tins",46.00 +44,"Gula Malacca",20,2,"20 - 2 kg bags",19.45 +45,"Rogede sild",21,8,"1k pkg",9.50 +46,"Spegesild",21,8,"4 - 450 g glasses",12.00 +47,"Zaanse koeken",22,3,"10 - 4 oz boxes",9.50 +48,"Chocolade",22,3,"10 pkgs",12.75 +49,"Maxilaku",23,3,"24 - 50 g pkgs",20.00 +50,"Valkoinen suklaa",23,3,"12 - 100 g bars",16.25 +51,"Manjimup Dried Apples",24,7,"50 - 300 g pkgs",53.00 +52,"Filo Mix",24,5,"16 - 2 kg boxes",7.00 +53,"Perth Pasties",24,6,"48 pieces",32.80 +54,"TourtiŠre",25,6,"16 pies",7.45 +55,"Pƒt‚ chinois",25,6,"24 boxes x 2 pies",24.00 +56,"Gnocchi di nonna Alice",26,5,"24 - 250 g pkgs",38.00 +57,"Ravioli Angelo",26,5,"24 - 250 g pkgs",19.50 +58,"Escargots de Bourgogne",27,8,"24 pieces",13.25 +59,"Raclette Courdavault",28,4,"5 kg pkg",55.00 +60,"Camembert Pierrot",28,4,"15 - 300 g rounds",34.00 +61,"Sirop d‚rable",29,2,"24 - 500 ml bottles",28.50 +62,"Tarte au sucre",29,3,"48 pies",49.30 +63,"Vegie-spread",7,2,"15 - 625 g jars",43.90 +64,"Wimmers gute Semmelkn”del",12,5,"20 bags x 4 pieces",33.25 +65,"Louisiana Fiery Hot Pepper Sauce",2,2,"32 - 8 oz bottles",21.05 +66,"Louisiana Hot Spiced Okra",2,2,"24 - 8 oz jars",17.00 +67,"Laughing Lumberjack Lager",16,1,"24 - 12 oz bottles",14.00 +68,"Scottish Longbreads",8,3,"10 boxes x 8 pieces",12.50 +69,"Gudbrandsdalsost",15,4,"10 kg pkg",36.00 +70,"Outback Lager",7,1,"24 - 355 ml bottles",15.00 +71,"Flotemysost",15,4,"10 - 500 g pkgs",21.50 +72,"Mozzarella di Giovanni",14,4,"24 - 200 g pkgs",34.80 +73,"R”d Kaviar",17,8,"24 - 150 g jars",15.00 +74,"Longlife Tofu",4,7,"5 kg pkg",10.00 +75,"Rh”nbr„u Klosterbier",12,1,"24 - 0.5 l bottles",7.75 +76,"Lakkalik””ri",23,1,"500 ml",18.00 +77,"Original Frankfurter gr�ne Soáe",12,2,"12 boxes",13.00 From 27f74a6445ed323c1ded0400de7e68a8274273b7 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:13:45 -0400 Subject: [PATCH 09/83] Run CI for stacked pull requests --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eb0f53..15488cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [ main, work ] pull_request: - branches: [ main ] jobs: build: From e8247c0f152986efa90ed86fb5371c55b35ff201 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:14:25 -0400 Subject: [PATCH 10/83] 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 11/83] 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 b4e0e95af6ddb2bb3754c4cd18b3f2499c4f2049 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:19:03 -0400 Subject: [PATCH 12/83] fix: handle encoding action rejection and disposed views --- src/CsvEditorProvider.ts | 8 ++++-- src/test/edit-integrity-harness.ts | 14 ++++++++-- src/test/encoding-integrity.test.ts | 40 +++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 31db8a3..d45e0c7 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -65,6 +65,7 @@ class CsvEditorController { private isUpdatingDocument = false; private isSaving = false; + private disposed = false; private pendingOperation: Promise = Promise.resolve(); private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; @@ -110,7 +111,9 @@ class CsvEditorController { if (webviewPanel.active) { CsvEditorProvider.currentActive = this; } - void this.warnAboutPossibleDecoding(); + void this.warnAboutPossibleDecoding().catch(error => { + console.warn('CSV: Encoding warning action failed', error); + }); webviewPanel.webview.postMessage({ type: 'focus' }); let wasVisible = webviewPanel.visible; @@ -211,6 +214,7 @@ class CsvEditorController { }); webviewPanel.onDidDispose(() => { + this.disposed = true; changeDocumentSubscription.dispose(); saveDocumentSubscription.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); @@ -237,7 +241,7 @@ class CsvEditorController { 'CSV: This file contains replacement characters (�), which can indicate a decoding mismatch. Check the source encoding before saving.', action ); - if (selected === action) { + if (selected === action && !this.disposed) { // Bind the action to this document even if another tab becomes active. await vscode.commands.executeCommand('csv.changeEncoding', this.document.uri); } diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 438de98..6b1e5b9 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -38,6 +38,12 @@ const vscodeStub = { }, executeCommand: async (name: string, ...args: unknown[]) => { activeHarness.executedCommands.push({ name, args }); + if (activeHarness.commandError) { + throw activeHarness.commandError; + } + if (name === 'workbench.action.editor.changeEncoding') { + await activeHarness.commandWait; + } } }, workspace: { @@ -66,7 +72,7 @@ const { JSDOM } = require('jsdom'); export async function createEditorHarness(options: { text?: string; - warningChoice?: string; + warningChoice?: string | Promise; rows?: string[][]; separator?: string; header?: boolean; @@ -79,6 +85,7 @@ export async function createEditorHarness(options: { const changeListeners: Array<(event: unknown) => void> = []; const viewListeners: Array<(event: unknown) => void> = []; const saveListeners: Array<(event: unknown) => void> = []; + const disposeListeners: Array<() => void> = []; const messages: any[] = []; const delayedEdits: Array<() => void> = []; let deferEdits = false; @@ -120,12 +127,15 @@ export async function createEditorHarness(options: { onDidReceiveMessage: (listener: typeof messageListener) => { messageListener = listener; } }, onDidChangeViewState: (listener: (event: unknown) => void) => { viewListeners.push(listener); }, - onDidDispose() {} + onDidDispose(listener: () => void) { disposeListeners.push(listener); } }; const harness = { document, panel, messages, changeListeners, saveListeners, warnings: [] as string[], warningChoice: options.warningChoice, + commandError: undefined as Error | undefined, + commandWait: undefined as Promise | undefined, + dispose() { disposeListeners.forEach(listener => listener()); }, closeResult: true, commands: new Map unknown>(), executedCommands: [] as Array<{ name: string; args: unknown[] }>, diff --git a/src/test/encoding-integrity.test.ts b/src/test/encoding-integrity.test.ts index 33e3787..6b95fe3 100644 --- a/src/test/encoding-integrity.test.ts +++ b/src/test/encoding-integrity.test.ts @@ -53,6 +53,33 @@ describe('Possible decoding mismatches use an explicit encoding workflow', () => assert.deepStrictEqual(host.executedCommands, [{ name: 'csv.changeEncoding', args: [host.document.uri] }]); }); + it('does not act on a warning from a disposed editor', async () => { + let choose!: (value: string) => void; + const choice = new Promise(resolve => { choose = resolve; }); + const host = await createEditorHarness({ rows: [['text'], ['�']], warningChoice: choice }); + host.dispose(); + choose('Change File Encoding'); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.executedCommands.length, 0); + }); + + it('handles a rejected warning action without an unhandled promise rejection', async () => { + let choose!: (value: string) => void; + const choice = new Promise(resolve => { choose = resolve; }); + const host = await createEditorHarness({ rows: [['text'], ['�']], warningChoice: choice }); + host.commandError = new Error('test command rejection'); + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args); }; + try { + choose('Change File Encoding'); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(warnings[0]?.[0], 'CSV: Encoding warning action failed'); + } finally { + console.warn = originalWarn; + } + }); + it('stops the encoding command when the user cancels closing a dirty document', async () => { const host = await createEditorHarness(); await host.receive({ type: 'editCell', row: 1, col: 0, value: 'unsaved' }); @@ -75,6 +102,19 @@ describe('Possible decoding mismatches use an explicit encoding workflow', () => assert.strictEqual(host.executedCommands[2].args[1], 'csv.editor'); }); + it('waits for the native picker command to finish before reopening the CSV view', async () => { + const host = await createEditorHarness(); + let complete!: () => void; + host.commandWait = new Promise(resolve => { complete = resolve; }); + host.registerCommands(); + const changing = host.commands.get('csv.changeEncoding')?.(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(host.executedCommands.map(command => command.name), ['vscode.openWith', 'workbench.action.editor.changeEncoding']); + complete(); + await changing; + assert.strictEqual(host.executedCommands[2].name, 'vscode.openWith'); + }); + it('round-trips UTF-8 accented CSV and TSV without triggering a warning', async () => { for (const separator of [',', '\t']) { const host = await createEditorHarness({ separator, rows: [['name', 'value'], ['éäöüßøå', 'before']] }); From 4d6ad3922e67e61e1f7810ef2072620533efadea Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:11:21 -0400 Subject: [PATCH 13/83] Restore readability in high-contrast themes (#92) --- README.md | 2 +- media/high-contrast.css | 117 +++++++++++++++++++++++++++++++ src/CsvEditorProvider.ts | 14 +++- src/extension.ts | 4 ++ src/test/extension-theme.test.ts | 34 +++++++++ src/test/high-contrast.test.ts | 100 ++++++++++++++++++++++++++ 6 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 media/high-contrast.css create mode 100644 src/test/extension-theme.test.ts create mode 100644 src/test/high-contrast.test.ts diff --git a/README.md b/README.md index 8f7a614..bae56e3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Clipboard Actions:** Use Ctrl/Cmd + C or X, or right-click a selection and choose Copy or Cut, to copy properly quoted data to the system clipboard. Right-click Paste inserts clipboard data into the grid. Clipboard shortcuts in Find, Replace, and cell text edits keep their normal text-editing behavior. - **Grid Paste Support:** Paste copied ranges directly into the selected cell/range with `Ctrl/Cmd + V` in selection mode. - **Robust Data Handling:** Leveraging the power of [Papa Parse](https://www.papaparse.com/), the extension handles complex CSV structures, special characters, and various data types gracefully. -- **Theme-Optimized Interface:** Whether you prefer light or dark mode, CSV automatically adapts its styles for an optimal viewing experience. +- **Theme-Optimized Interface:** CSV adapts to light, dark, and high-contrast themes. High-contrast themes use your theme’s foreground colors for cells, selection, Find/Replace, and menus automatically. --- diff --git a/media/high-contrast.css b/media/high-contrast.css new file mode 100644 index 0000000..6622a86 --- /dev/null +++ b/media/high-contrast.css @@ -0,0 +1,117 @@ +/* VS Code updates these body classes and color variables on theme changes. + Override legacy inline table colors for high-contrast themes, including chunks. */ +body:is(.vscode-high-contrast, .vscode-high-contrast-light) { + color: var(--vscode-editor-foreground); + background-color: var(--vscode-editor-background); + --csv-contrast-border: var(--vscode-contrastBorder, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th) { + color: var(--vscode-editor-foreground) !important; + border-color: var(--csv-contrast-border) !important; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) th { + background-color: var(--vscode-editor-background) !important; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).selected { + color: var(--vscode-editor-selectionForeground, var(--vscode-editor-foreground)) !important; + background-color: var(--vscode-editor-selectionBackground, var(--vscode-editor-background)) !important; + outline: 1px solid var(--vscode-focusBorder, var(--csv-contrast-border)); + outline-offset: -1px; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).highlight { + background-color: var(--vscode-editor-findMatchHighlightBackground, var(--vscode-editor-background)) !important; + outline: 1px solid var(--vscode-editor-findMatchHighlightBorder, var(--csv-contrast-border)); + outline-offset: -1px; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).active-match { + color: var(--vscode-editor-findMatchForeground, var(--vscode-editor-foreground)) !important; + background-color: var(--vscode-editor-findMatchBackground, var(--vscode-editor-background)) !important; + outline: 2px solid var(--vscode-editor-findMatchBorder, var(--vscode-focusBorder)); + outline-offset: -2px; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) .csv-link { + color: var(--vscode-textLink-foreground, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) .csv-link:hover { + color: var(--vscode-textLink-activeForeground, var(--vscode-textLink-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) .selected .csv-link { + color: inherit; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu { + color: var(--vscode-menu-foreground, var(--vscode-editor-foreground)); + background-color: var(--vscode-menu-background, var(--vscode-editor-background)); + border-color: var(--csv-contrast-border); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div { + border-color: var(--csv-contrast-border) !important; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div:hover { + color: var(--vscode-menu-selectionForeground, var(--vscode-editor-foreground)); + background-color: var(--vscode-menu-selectionBackground, var(--vscode-editor-background)); + outline: 1px solid var(--vscode-menu-selectionBorder, var(--csv-contrast-border)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget { + color: var(--vscode-editorWidget-foreground, var(--vscode-editor-foreground)); + background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + border-color: var(--csv-contrast-border); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-gutter, .fr-overflow-menu) { + background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + border-color: var(--csv-contrast-border); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input { + color: var(--vscode-input-foreground, var(--vscode-editor-foreground)); + background-color: var(--vscode-input-background, var(--vscode-editor-background)); + border-color: var(--vscode-input-border, var(--csv-contrast-border)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input::placeholder { + color: var(--vscode-input-placeholderForeground, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-status, button) { + color: var(--vscode-editorWidget-foreground, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button:hover { + color: var(--vscode-toolbar-hoverForeground, var(--vscode-editor-foreground)); + background-color: var(--vscode-toolbar-hoverBackground, var(--vscode-editor-background)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-toggle-btn[aria-pressed="true"] { + color: var(--vscode-inputOption-activeForeground, var(--vscode-editor-foreground)); + background-color: var(--vscode-inputOption-activeBackground, var(--vscode-editor-background)); + box-shadow: inset 0 -2px 0 var(--vscode-inputOption-activeBorder, var(--csv-contrast-border)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button[disabled] { + color: var(--vscode-disabledForeground, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(button, input):focus-visible { + outline: 2px solid var(--vscode-focusBorder, var(--csv-contrast-border)); + outline-offset: -2px; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-divider { + background-color: var(--csv-contrast-border); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-inline-toggles { + border-color: var(--csv-contrast-border); +} diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index a1484e4..5630901 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -1482,7 +1482,8 @@ class CsvEditorController { const totalRows = data.length; const offset = Math.min(Math.max(0, hiddenRows), totalRows); - const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; + const themeKind = vscode.window.activeColorTheme.kind; + const isDark = themeKind === vscode.ColorThemeKind.Dark || themeKind === vscode.ColorThemeKind.HighContrast; let headerRow: string[] = []; let bodyData: string[][] = []; if (totalRows === 0 || offset >= totalRows) { @@ -1500,7 +1501,8 @@ class CsvEditorController { const columnData = Array.from({ length: numColumns }, (_, i) => bodyData.map(row => row[i] || '')); const columnTypes = columnData.map(col => this.estimateColumnDataType(col)); - const useThemeForeground = columnColorMode === 'theme'; + const highContrast = themeKind === vscode.ColorThemeKind.HighContrast || themeKind === vscode.ColorThemeKind.HighContrastLight; + const useThemeForeground = columnColorMode === 'theme' || highContrast; const palette = columnColorPalette === 'cool' ? 'cool' : (columnColorPalette === 'warm' ? 'warm' : 'default'); @@ -1712,12 +1714,17 @@ class CsvEditorController { columnSizes: Record; }): string { const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes } = args; - const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; + const themeKind = vscode.window.activeColorTheme.kind; + const isDark = themeKind === vscode.ColorThemeKind.Dark || themeKind === vscode.ColorThemeKind.HighContrast; // Build script URI using file path for compatibility (older APIs may lack Uri.joinPath) const scriptUri = webview.asWebviewUri( vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'main.js')) ); + const contrastStyleUri = webview.asWebviewUri( + vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'high-contrast.css')) + ); + // Safe separator transport (assumes single character; see assumptions) const sepCode = (separator && separator.length > 0) ? separator.codePointAt(0)! : ','.codePointAt(0)!; @@ -1921,6 +1928,7 @@ class CsvEditorController { /* Per-column computed colors */ ${extraColumnColorCss} +
diff --git a/src/extension.ts b/src/extension.ts index 50ec42b..abaff87 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -104,6 +104,10 @@ export function activate(context: vscode.ExtensionContext) { } }); context.subscriptions.push(cfgListener); + // Recompute type colors when entering/leaving high contrast, including open chunks. + context.subscriptions.push(vscode.window.onDidChangeActiveColorTheme(() => { + CsvEditorProvider.editors.forEach(editor => editor.refresh()); + })); // Register the custom editor provider for CSV/TSV context.subscriptions.push( diff --git a/src/test/extension-theme.test.ts b/src/test/extension-theme.test.ts new file mode 100644 index 0000000..f44573f --- /dev/null +++ b/src/test/extension-theme.test.ts @@ -0,0 +1,34 @@ +import assert from 'assert'; +import Module from 'module'; +import { it } from 'node:test'; + +let themeChanged: () => void = () => {}; +const themeListener = { dispose() {} }; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + commands: { registerCommand: () => ({ dispose() {} }) }, + workspace: { onDidChangeConfiguration: () => ({ dispose() {} }) }, + window: { + onDidChangeActiveColorTheme: (listener: () => void) => { themeChanged = listener; return themeListener; }, + registerCustomEditorProvider: () => ({ dispose() {} }) + } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { activate } from '../extension'; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +it('refreshes every open editor when the color theme changes and disposes the listener', () => { + const context = { subscriptions: [] as any[] }; + activate(context as any); + let refreshed = 0; + CsvEditorProvider.editors = [{ refresh: () => refreshed++ }, { refresh: () => refreshed++ }] as any; + themeChanged(); + assert.strictEqual(refreshed, 2); + assert.ok(context.subscriptions.includes(themeListener)); + CsvEditorProvider.editors = []; +}); diff --git a/src/test/high-contrast.test.ts b/src/test/high-contrast.test.ts new file mode 100644 index 0000000..ef2f3f0 --- /dev/null +++ b/src/test/high-contrast.test.ts @@ -0,0 +1,100 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +const theme = { kind: 1 }; +const kinds = { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + ColorThemeKind: kinds, + window: { activeColorTheme: theme }, + Uri: { file: (filePath: string) => ({ fsPath: filePath, toString: () => `test-resource:${filePath}` }) }, + workspace: { + getConfiguration: () => ({ get: (key: string, fallback: any) => key === 'maxFileSizeMB' ? 0 : fallback }), + onDidChangeTextDocument: () => ({ dispose() {} }), + onWillSaveTextDocument: () => ({ dispose() {} }) + } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +describe('High-contrast rendering', () => { + it('uses theme foreground for header, body and virtual cells in both high-contrast variants', () => { + for (const kind of [kinds.HighContrast, kinds.HighContrastLight]) { + theme.kind = kind; + const { tableHtml } = CsvEditorProvider.__test.generateTableAndChunksRaw([ + ['Name', 'Value'], ['hello', '123'] + ], true, true, 0); + const dataCells = tableHtml.match(/<(?:td|th)\b[^>]*data-col="[0-9]+"[^>]*>/g) || []; + assert.strictEqual(dataCells.length, 6); + for (const cell of dataCells) { assert.ok(cell.includes('color: var(--vscode-editor-foreground)')); } + } + }); + + it('applies high-contrast foreground to serialized and on-demand chunks', () => { + for (const kind of [kinds.HighContrast, kinds.HighContrastLight]) { + theme.kind = kind; + const rows = [['Header'], ...Array.from({ length: 1100 }, (_, index) => [String(index)])]; + const serialized = CsvEditorProvider.__test.generateTableAndChunksRaw(rows, true, false, 0); + assert.ok(serialized.chunks.length > 0); + assert.ok(serialized.chunks[0].includes('color:var(--vscode-editor-foreground)')); + const remote = CsvEditorProvider.__test.generateRuntimeChunkTransport(rows, true, false, 0, 1000); + assert.ok(remote.response?.html.includes('color:var(--vscode-editor-foreground)')); + } + }); + + it('retains type colors in ordinary light and dark themes', () => { + for (const kind of [kinds.Light, kinds.Dark]) { + theme.kind = kind; + const { tableHtml } = CsvEditorProvider.__test.generateTableAndChunksRaw([['Name'], ['hello']], true, false, 0); + assert.ok(!tableHtml.includes('var(--vscode-editor-foreground)')); + assert.ok(tableHtml.includes('color: #')); + } + }); + + it('loads the packaged high-contrast stylesheet after generated table styles', async () => { + const webview = { + html: '', options: {}, cspSource: 'test-resource:', + asWebviewUri: (uri: any) => uri.toString(), postMessage() {}, onDidReceiveMessage() {} + }; + const panel = { title: 'fixture', active: false, webview, onDidChangeViewState() {}, onDidDispose() {} }; + const context = { extensionPath: process.cwd(), workspaceState: { get: (_key: string, fallback: any) => fallback } }; + const uri = { fsPath: '/fixture.csv', toString: () => 'file:///fixture.csv' }; + await new CsvEditorProvider(context as any).resolveCustomTextEditor({ uri, version: 1, getText: () => 'Name\nhello' } as any, panel as any, {} as any); + const link = ``; + assert.ok(webview.html.includes(link)); + assert.ok(webview.html.indexOf(link) > webview.html.indexOf('')); + assert.ok(fs.existsSync(path.join(process.cwd(), 'media', 'high-contrast.css'))); + }); + + it('scopes accessibility styles to high-contrast themes and covers interactive states with theme tokens', () => { + const css = fs.readFileSync(path.join(process.cwd(), 'media', 'high-contrast.css'), 'utf8'); + const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').split('}').filter(rule => rule.trim()).map(rule => { + const [selector, declarations] = rule.split('{'); + return { selector: selector.trim(), declarations: declarations.trim() }; + }); + for (const rule of rules) { + assert.ok(rule.selector.startsWith('body:is(.vscode-high-contrast, .vscode-high-contrast-light)')); + assert.ok(!/#[0-9a-f]{3,8}\b/i.test(rule.declarations), 'high-contrast colors must come from the active theme'); + } + const expectedStates = new Map([ + ['.selected', ['selectionForeground', 'selectionBackground', 'outline']], + ['.active-match', ['findMatchForeground', 'findMatchBackground', 'outline']], + ['.fr-input {', ['input-foreground', 'input-background']], + ['#contextMenu {', ['menu-foreground', 'menu-background']], + [':focus-visible', ['focusBorder', 'outline']] + ]); + for (const [state, tokens] of expectedStates) { + const rule = rules.find(item => `${item.selector} {`.includes(state)); + assert.ok(rule, state); + for (const token of tokens) { assert.ok(rule!.declarations.includes(token), `${state}: ${token}`); } + } + }); +}); From 7a91fc520df27a1936cafc849378c3ba83a8f5e9 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:13:07 -0400 Subject: [PATCH 14/83] Switch theme colors without reloading active cell drafts --- src/CsvEditorProvider.ts | 11 +++++++++-- src/extension.ts | 4 ---- src/test/extension-theme.test.ts | 34 -------------------------------- src/test/high-contrast.test.ts | 8 +++++++- 4 files changed, 16 insertions(+), 41 deletions(-) delete mode 100644 src/test/extension-theme.test.ts diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 5630901..f892c65 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -1561,9 +1561,16 @@ class CsvEditorController { } } - const colorCss = useThemeForeground + // VS Code changes these classes in place. Keep both palettes available so + // entering/leaving high contrast never needs to reload a live cell edit. + const colorCss = columnColorMode === 'theme' ? '' - : columnColors.map((hex, i) => `td[data-col="${i}"], th[data-col="${i}"] { color: ${hex}; }`).join(''); + : columnTypes.map((type, i) => { + const light = this.getColumnColor(type, false, i, palette); + const dark = this.getColumnColor(type, true, i, palette); + return `body.vscode-light td[data-col="${i}"], body.vscode-light th[data-col="${i}"] { color: ${light} !important; }` + + `body.vscode-dark td[data-col="${i}"], body.vscode-dark th[data-col="${i}"] { color: ${dark} !important; }`; + }).join(''); let tableHtml = `
`; if (headerFlag) { diff --git a/src/extension.ts b/src/extension.ts index abaff87..50ec42b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -104,10 +104,6 @@ export function activate(context: vscode.ExtensionContext) { } }); context.subscriptions.push(cfgListener); - // Recompute type colors when entering/leaving high contrast, including open chunks. - context.subscriptions.push(vscode.window.onDidChangeActiveColorTheme(() => { - CsvEditorProvider.editors.forEach(editor => editor.refresh()); - })); // Register the custom editor provider for CSV/TSV context.subscriptions.push( diff --git a/src/test/extension-theme.test.ts b/src/test/extension-theme.test.ts deleted file mode 100644 index f44573f..0000000 --- a/src/test/extension-theme.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import assert from 'assert'; -import Module from 'module'; -import { it } from 'node:test'; - -let themeChanged: () => void = () => {}; -const themeListener = { dispose() {} }; -const originalRequire = Module.prototype.require; -Module.prototype.require = function (id: string) { - if (id === 'vscode') { - return { - commands: { registerCommand: () => ({ dispose() {} }) }, - workspace: { onDidChangeConfiguration: () => ({ dispose() {} }) }, - window: { - onDidChangeActiveColorTheme: (listener: () => void) => { themeChanged = listener; return themeListener; }, - registerCustomEditorProvider: () => ({ dispose() {} }) - } - }; - } - return originalRequire.apply(this, arguments as any); -}; -import { activate } from '../extension'; -import { CsvEditorProvider } from '../CsvEditorProvider'; -Module.prototype.require = originalRequire; - -it('refreshes every open editor when the color theme changes and disposes the listener', () => { - const context = { subscriptions: [] as any[] }; - activate(context as any); - let refreshed = 0; - CsvEditorProvider.editors = [{ refresh: () => refreshed++ }, { refresh: () => refreshed++ }] as any; - themeChanged(); - assert.strictEqual(refreshed, 2); - assert.ok(context.subscriptions.includes(themeListener)); - CsvEditorProvider.editors = []; -}); diff --git a/src/test/high-contrast.test.ts b/src/test/high-contrast.test.ts index ef2f3f0..2b7f889 100644 --- a/src/test/high-contrast.test.ts +++ b/src/test/high-contrast.test.ts @@ -67,11 +67,17 @@ describe('High-contrast rendering', () => { const panel = { title: 'fixture', active: false, webview, onDidChangeViewState() {}, onDidDispose() {} }; const context = { extensionPath: process.cwd(), workspaceState: { get: (_key: string, fallback: any) => fallback } }; const uri = { fsPath: '/fixture.csv', toString: () => 'file:///fixture.csv' }; - await new CsvEditorProvider(context as any).resolveCustomTextEditor({ uri, version: 1, getText: () => 'Name\nhello' } as any, panel as any, {} as any); + await new CsvEditorProvider(context as any).resolveCustomTextEditor({ uri, version: 1, getText: () => 'Name,Empty\nhello,' } as any, panel as any, {} as any); const link = ``; assert.ok(webview.html.includes(link)); assert.ok(webview.html.indexOf(link) > webview.html.indexOf('')); assert.ok(fs.existsSync(path.join(process.cwd(), 'media', 'high-contrast.css'))); + assert.ok(webview.html.includes('body.vscode-light td[data-col="0"]')); + assert.ok(webview.html.includes('body.vscode-dark td[data-col="0"]')); + assert.ok(webview.html.includes('color: #444 !important')); + assert.ok(webview.html.includes('color: #BBB !important')); + const extensionSource = fs.readFileSync(path.join(process.cwd(), 'src', 'extension.ts'), 'utf8'); + assert.ok(!extensionSource.includes('onDidChangeActiveColorTheme'), 'theme changes must not force-reload an active cell draft'); }); it('scopes accessibility styles to high-contrast themes and covers interactive states with theme tokens', () => { From 1a3d74dd834ca1d78a5de1aea3b00e914d85fc85 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:16:21 -0400 Subject: [PATCH 15/83] Keep chrome colors coherent across live theme transitions --- media/high-contrast.css | 53 ++++++++++++++++--------------- src/test/high-contrast.test.ts | 5 +-- src/test/theme-transition.test.ts | 33 +++++++++++++++++++ 3 files changed, 64 insertions(+), 27 deletions(-) create mode 100644 src/test/theme-transition.test.ts diff --git a/media/high-contrast.css b/media/high-contrast.css index 6622a86..b22ead2 100644 --- a/media/high-contrast.css +++ b/media/high-contrast.css @@ -1,117 +1,120 @@ /* VS Code updates these body classes and color variables on theme changes. - Override legacy inline table colors for high-contrast themes, including chunks. */ -body:is(.vscode-high-contrast, .vscode-high-contrast-light) { + Keep chrome coherent across every transition; override cell type colors only in high contrast. */ +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) { color: var(--vscode-editor-foreground); background-color: var(--vscode-editor-background); - --csv-contrast-border: var(--vscode-contrastBorder, var(--vscode-editor-foreground)); + --csv-contrast-border: var(--vscode-contrastBorder, var(--vscode-editorWidget-border, var(--vscode-panel-border, var(--vscode-editor-foreground)))); +} + +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) :is(td, th) { + border-color: var(--csv-contrast-border) !important; } body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th) { color: var(--vscode-editor-foreground) !important; - border-color: var(--csv-contrast-border) !important; } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) th { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) th { background-color: var(--vscode-editor-background) !important; } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).selected { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).selected { color: var(--vscode-editor-selectionForeground, var(--vscode-editor-foreground)) !important; background-color: var(--vscode-editor-selectionBackground, var(--vscode-editor-background)) !important; outline: 1px solid var(--vscode-focusBorder, var(--csv-contrast-border)); outline-offset: -1px; } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).highlight { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).highlight { background-color: var(--vscode-editor-findMatchHighlightBackground, var(--vscode-editor-background)) !important; outline: 1px solid var(--vscode-editor-findMatchHighlightBorder, var(--csv-contrast-border)); outline-offset: -1px; } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).active-match { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).active-match { color: var(--vscode-editor-findMatchForeground, var(--vscode-editor-foreground)) !important; background-color: var(--vscode-editor-findMatchBackground, var(--vscode-editor-background)) !important; outline: 2px solid var(--vscode-editor-findMatchBorder, var(--vscode-focusBorder)); outline-offset: -2px; } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) .csv-link { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) .csv-link { color: var(--vscode-textLink-foreground, var(--vscode-editor-foreground)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) .csv-link:hover { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) .csv-link:hover { color: var(--vscode-textLink-activeForeground, var(--vscode-textLink-foreground)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) .selected .csv-link { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) .selected .csv-link { color: inherit; } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #contextMenu { color: var(--vscode-menu-foreground, var(--vscode-editor-foreground)); background-color: var(--vscode-menu-background, var(--vscode-editor-background)); border-color: var(--csv-contrast-border); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div { border-color: var(--csv-contrast-border) !important; } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div:hover { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div:hover { color: var(--vscode-menu-selectionForeground, var(--vscode-editor-foreground)); background-color: var(--vscode-menu-selectionBackground, var(--vscode-editor-background)); outline: 1px solid var(--vscode-menu-selectionBorder, var(--csv-contrast-border)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget { color: var(--vscode-editorWidget-foreground, var(--vscode-editor-foreground)); background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background)); border-color: var(--csv-contrast-border); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-gutter, .fr-overflow-menu) { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-gutter, .fr-overflow-menu) { background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background)); border-color: var(--csv-contrast-border); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input { color: var(--vscode-input-foreground, var(--vscode-editor-foreground)); background-color: var(--vscode-input-background, var(--vscode-editor-background)); border-color: var(--vscode-input-border, var(--csv-contrast-border)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input::placeholder { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vscode-editor-foreground)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-status, button) { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-status, button) { color: var(--vscode-editorWidget-foreground, var(--vscode-editor-foreground)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button:hover { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button:hover { color: var(--vscode-toolbar-hoverForeground, var(--vscode-editor-foreground)); background-color: var(--vscode-toolbar-hoverBackground, var(--vscode-editor-background)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-toggle-btn[aria-pressed="true"] { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-toggle-btn[aria-pressed="true"] { color: var(--vscode-inputOption-activeForeground, var(--vscode-editor-foreground)); background-color: var(--vscode-inputOption-activeBackground, var(--vscode-editor-background)); box-shadow: inset 0 -2px 0 var(--vscode-inputOption-activeBorder, var(--csv-contrast-border)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button[disabled] { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button[disabled] { color: var(--vscode-disabledForeground, var(--vscode-editor-foreground)); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(button, input):focus-visible { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(button, input):focus-visible { outline: 2px solid var(--vscode-focusBorder, var(--csv-contrast-border)); outline-offset: -2px; } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-divider { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-divider { background-color: var(--csv-contrast-border); } -body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-inline-toggles { +body:is(.vscode-light, .vscode-dark, .vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-inline-toggles { border-color: var(--csv-contrast-border); } diff --git a/src/test/high-contrast.test.ts b/src/test/high-contrast.test.ts index 2b7f889..89883da 100644 --- a/src/test/high-contrast.test.ts +++ b/src/test/high-contrast.test.ts @@ -80,14 +80,15 @@ describe('High-contrast rendering', () => { assert.ok(!extensionSource.includes('onDidChangeActiveColorTheme'), 'theme changes must not force-reload an active cell draft'); }); - it('scopes accessibility styles to high-contrast themes and covers interactive states with theme tokens', () => { + it('scopes theme styles and covers interactive states with theme tokens', () => { const css = fs.readFileSync(path.join(process.cwd(), 'media', 'high-contrast.css'), 'utf8'); const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').split('}').filter(rule => rule.trim()).map(rule => { const [selector, declarations] = rule.split('{'); return { selector: selector.trim(), declarations: declarations.trim() }; }); for (const rule of rules) { - assert.ok(rule.selector.startsWith('body:is(.vscode-high-contrast, .vscode-high-contrast-light)')); + assert.ok(rule.selector.startsWith('body:is(')); + assert.ok(rule.selector.includes('.vscode-high-contrast-light')); assert.ok(!/#[0-9a-f]{3,8}\b/i.test(rule.declarations), 'high-contrast colors must come from the active theme'); } const expectedStates = new Map([ diff --git a/src/test/theme-transition.test.ts b/src/test/theme-transition.test.ts new file mode 100644 index 0000000..d0f28d9 --- /dev/null +++ b/src/test/theme-transition.test.ts @@ -0,0 +1,33 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { it } from 'node:test'; +const { JSDOM } = require('jsdom'); + +it('updates dark/light chrome after leaving either high-contrast variant without replacing an active multiline draft', () => { + const css = fs.readFileSync(path.join(process.cwd(), 'media', 'high-contrast.css'), 'utf8'); + for (const [initial, ordinary] of [['vscode-high-contrast', 'vscode-light'], ['vscode-high-contrast-light', 'vscode-dark']]) { + const dom = new JSDOM(`
Header
line one\nline two
`); + const { document } = dom.window; + const cell = document.querySelector('td'); + cell.focus(); + const range = document.createRange(); + range.setStart(cell.firstChild, 5); + range.collapse(true); + dom.window.getSelection().removeAllRanges(); + dom.window.getSelection().addRange(range); + document.body.className = ordinary; + const rules = Array.from(document.styleSheets[0].cssRules) as any[]; + const declarationsFor = (element: any, property: string) => rules.filter(rule => element.matches(rule.selectorText)).map(rule => rule.style.getPropertyValue(property)).filter(Boolean); + for (const [element, token] of [[document.body, '--vscode-editor-background'], [document.querySelector('th'), '--vscode-editor-background'], [document.querySelector('#contextMenu'), '--vscode-menu-background'], [document.querySelector('.fr-input'), '--vscode-input-background']] as any[]) { + assert.ok(declarationsFor(element, 'background-color').some(value => value.includes(token)), `${ordinary} ${element.tagName} background follows live theme`); + } + assert.strictEqual(document.querySelector('td'), cell); + assert.strictEqual(cell.textContent, 'line one\nline two'); + assert.strictEqual(document.activeElement, cell); + assert.strictEqual(dom.window.getSelection().anchorNode, cell.firstChild); + assert.strictEqual(dom.window.getSelection().anchorOffset, 5); + assert.ok(!declarationsFor(cell, 'color').some(value => value.includes('--vscode-editor-foreground')), 'ordinary cells retain their type palette'); + dom.window.close(); + } +}); From fd2029cbd7ccb27ff91d9901cbb3293ef476bcb2 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:19:32 -0400 Subject: [PATCH 16/83] Add persistent frozen leading rows and columns (#79) --- README.md | 5 +- media/frozen-panes.js | 82 ++++++++++ media/main.js | 44 +++++- package.json | 14 +- src/CsvEditorProvider.ts | 69 ++++++++- src/commands.ts | 19 +++ src/test/edit-integrity-harness.ts | 11 +- src/test/frozen-pane-commands.test.ts | 44 ++++++ src/test/frozen-panes.test.ts | 213 ++++++++++++++++++++++++++ 9 files changed, 492 insertions(+), 9 deletions(-) create mode 100644 media/frozen-panes.js create mode 100644 src/test/frozen-pane-commands.test.ts create mode 100644 src/test/frozen-panes.test.ts diff --git a/README.md b/README.md index 8f7a614..34625d7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs. - **Smart Resizing:** Automatic widths follow the longest line in multiline cells. Drag within 10 pixels on either side of a column boundary to resize; widths are remembered per file across closing and reopening. Double-click a column boundary to restore automatic sizing. Resizing also works with the header row hidden. - **Dynamic Color Coding:** Visual cues based on data type help you quickly identify numbers, dates, booleans, and more (with opt‑in cool palette and theme-foreground mode settings). -- **Sticky Headers:** Keep column titles in view as you scroll through large datasets. +- **Frozen Rows and Columns:** Keep any number of leading visible rows and data columns in view. Right-click a cell to freeze through its row/column or unfreeze; counts are remembered per file. Frozen rows use measured heights, including multiline cells and zoom. The serial index stays visible alongside frozen data columns. - **Enhanced Keyboard Navigation:** Navigate cells with arrows and Tab/Shift+Tab; quick edits can commit with arrow keys; `Ctrl/Cmd + A` selects all; `Ctrl/Cmd + C` copies selection. - **Advanced Multi-Cell Selection:** Easily select and copy blocks of data, then paste them elsewhere as properly formatted CSV. - **Add/Delete Columns:** Right-click any cell to add a column left or right, or remove the selected column. @@ -85,6 +85,8 @@ Open the Command Palette and search for: - `CSV: Toggle Extension On/Off` (`csv.toggleExtension`) - `CSV: Toggle First Row as Header` (`csv.toggleHeader`) +- `CSV: Freeze Leading Rows` (`csv.changeFrozenRows`) +- `CSV: Freeze Leading Columns` (`csv.changeFrozenColumns`) - `CSV: Toggle Serial Index Column` (`csv.toggleSerialIndex`) - `CSV: Change CSV Separator` (`csv.changeSeparator`) - `CSV: Reset CSV Separator (Inherit)` (`csv.resetSeparator`) @@ -121,6 +123,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 (default `0`) — `CSV: Hide First N Rows` +- Frozen leading visible rows/columns — `CSV: Freeze Leading Rows` / `CSV: Freeze Leading Columns`. Enter `0` to unfreeze that axis. The default keeps the first header row frozen; with headers off, no rows freeze until requested. Hidden metadata rows do not count; the virtual append row is never frozen as a leading row. --- diff --git a/media/frozen-panes.js b/media/frozen-panes.js new file mode 100644 index 0000000..381ca5c --- /dev/null +++ b/media/frozen-panes.js @@ -0,0 +1,82 @@ +// Layout uses measured dimensions so multiline cells, manual sizes and zoom agree. +window.createFrozenPaneManager = ({ table, firstRow, realRowCount, onNeedRows }) => { + let rows = 0; + let columns = 0; + let scheduled = false; + let disposed = false; + const count = value => Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : 0; + const renderedRows = () => Array.from(table.rows).map(row => { + const dataCell = Array.from(row.cells).find(cell => cell.hasAttribute('data-row')); + return { element: row, index: Number(dataCell?.getAttribute('data-row')) }; + }).filter(row => Number.isInteger(row.index)); + const schedule = () => { + if (scheduled || disposed) return; + scheduled = true; + requestAnimationFrame(() => { scheduled = false; if (!disposed) layout(); }); + }; + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null; + const observedRows = new WeakSet(); + const layout = () => { + const rendered = renderedRows(); + for (const { element } of rendered) { + if (!observedRows.has(element)) { + observedRows.add(element); + observer?.observe(element); + } + } + table.querySelectorAll('.csv-frozen-row, .csv-frozen-column').forEach(cell => { + cell.classList.remove('csv-frozen-row', 'csv-frozen-column'); + cell.style.top = ''; + cell.style.left = ''; + cell.style.zIndex = ''; + }); + const representative = rendered[0]?.element; + const leftByColumn = new Map(); + let left = 0; + if (columns && representative) { + for (const cell of Array.from(representative.cells)) { + const attr = cell.getAttribute('data-col'); + const col = attr === null ? -1 : Number(attr); + if (col >= columns) break; + leftByColumn.set(col, left); + left += cell.getBoundingClientRect().width; + } + } + let top = 0; + const lastFrozenRow = Math.min(firstRow + rows, realRowCount) - 1; + for (const { element, index } of rendered) { + const frozenRow = 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'); + const col = attr === null ? -1 : Number(attr); + const frozenColumn = leftByColumn.has(col); + if (frozenRow) { + cell.classList.add('csv-frozen-row'); + cell.style.top = `${top}px`; + } + if (frozenColumn) { + cell.classList.add('csv-frozen-column'); + cell.style.left = `${leftByColumn.get(col)}px`; + } + if (frozenRow || frozenColumn) { + cell.style.zIndex = frozenRow && frozenColumn ? '4' : (frozenRow ? '3' : '2'); + } + } + if (frozenRow) top += height; + } + if (lastFrozenRow >= firstRow && (rendered[rendered.length - 1]?.index ?? -1) < lastFrozenRow) { + onNeedRows?.(lastFrozenRow); + } + }; + return { + schedule, + layout, + update(nextRows, nextColumns) { + rows = count(nextRows); + columns = count(nextColumns); + schedule(); + }, + dispose() { disposed = true; observer?.disconnect(); } + }; +}; diff --git a/media/main.js b/media/main.js index 2b204c5..3feb207 100644 --- a/media/main.js +++ b/media/main.js @@ -6,6 +6,8 @@ try { document.body.focus({ preventScroll: true }); } catch { try { document.bod const vscode = acquireVsCodeApi(); const root = document.getElementById('csv-root'); +let frozenRowCount = Number(root?.dataset?.frozenrows || '0'); +let frozenColumnCount = Number(root?.dataset?.frozencolumns || '0'); const parsePositiveNumber = value => { const parsed = typeof value === 'string' ? Number.parseFloat(value) : Number(value); if (!Number.isFinite(parsed) || parsed <= 0) return undefined; @@ -77,6 +79,7 @@ const persistColumnSizes = () => { vscode.postMessage({ type: 'columnSizesChanged', columnSizes: { ...columnSizeState } }); }; const applySizeStateToRenderedCells = () => { + window.csvFrozenPanes?.schedule(); for (const [col, width] of Object.entries(columnSizeState)) { const px = Math.max(40, Math.round(Number(width))); table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { @@ -385,6 +388,12 @@ const clearSelection = () => { currentSelection.forEach(c => c.classList.remove( const contextMenu = document.getElementById('contextMenu'); /* ──────── UPDATED showContextMenu ──────── */ +const changeFrozenPanes = change => { + frozenRowCount = change.rows ?? frozenRowCount; + frozenColumnCount = change.columns ?? frozenColumnCount; + window.csvFrozenPanes?.update(frozenRowCount, frozenColumnCount); + vscode.postMessage({ type: 'freezePanesChanged', rows: frozenRowCount, columns: frozenColumnCount }); +}; const showContextMenu = (x, y, row, col) => { contextMenu.innerHTML = ''; const item = (label, cb) => { @@ -403,6 +412,15 @@ const showContextMenu = (x, y, row, col) => { item('Copy', () => copySelectionToClipboard()); item('Paste', () => pasteFromClipboard()); divider(); + if (Number.isInteger(row) && row >= Number(root?.dataset?.firstrow || '0') && row < Number(root?.dataset?.realrowcount || '0')) { + item('Freeze rows through this row', () => changeFrozenPanes({ rows: row - Number(root?.dataset?.firstrow || '0') + 1 })); + } + if (Number.isInteger(col) && col >= 0) { + item('Freeze columns through this column', () => changeFrozenPanes({ columns: col + 1 })); + } + if (frozenRowCount > 0) item('Unfreeze rows', () => changeFrozenPanes({ rows: 0 })); + if (frozenColumnCount > 0) item('Unfreeze columns', () => changeFrozenPanes({ columns: 0 })); + divider(); // Derive multi-row/column selection counts const selectedIndexCells = currentSelection.filter(el => el && el.getAttribute && el.getAttribute('data-col') === '-1'); const selectedRowIds = Array.from(new Set(selectedIndexCells.map(el => parseInt(el.getAttribute('data-row') || '-1', 10)).filter(n => !isNaN(n)))).sort((a,b)=>a-b); @@ -602,6 +620,7 @@ const getResizeEdgeInfo = (target, e) => { return null; }; const applyColumnWidth = (col, widthPx) => { + window.csvFrozenPanes?.schedule(); const width = Math.max(40, Math.round(widthPx)); columnSizeState[String(col)] = width; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { @@ -609,6 +628,7 @@ const applyColumnWidth = (col, widthPx) => { }); }; const resetColumnWidth = col => { + window.csvFrozenPanes?.schedule(); delete columnSizeState[String(col)]; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { const automatic = automaticColumnSizes.get(cell); @@ -616,6 +636,7 @@ const resetColumnWidth = col => { }); }; const applyRowHeight = (row, heightPx) => { + window.csvFrozenPanes?.schedule(); const height = Math.max(getMinRowHeight(), Math.round(heightPx)); rowSizeState[String(row)] = height; table.querySelectorAll(`[data-row="${row}"]`).forEach(cell => { @@ -624,6 +645,7 @@ const applyRowHeight = (row, heightPx) => { }); }; const resetRowHeight = row => { + window.csvFrozenPanes?.schedule(); delete rowSizeState[String(row)]; table.querySelectorAll(`[data-row="${row}"]`).forEach(cell => { cell.style.height = ''; @@ -719,6 +741,7 @@ const onGlobalDragEnd = () => { resizeState = null; table.style.cursor = ''; persistState(); + window.csvFrozenPanes?.schedule(); } if (!reorderState) return; @@ -2079,7 +2102,11 @@ const copySelectionToClipboard = (cut = false) => { window.addEventListener('message', event => { const message = event.data; - if(message.type === 'focus'){ + if (message.type === 'frozenPanes') { + frozenRowCount = Number(message.rows); + frozenColumnCount = Number(message.columns); + window.csvFrozenPanes?.update(frozenRowCount, frozenColumnCount); + } else if(message.type === 'focus'){ if (anchorCell) { try { anchorCell.focus({ preventScroll: true }); } catch { try { anchorCell.focus(); } catch {} } } else { @@ -2111,6 +2138,7 @@ window.addEventListener('message', event => { remoteNextChunkStart = -1; remoteHasMoreChunks = false; } + window.csvFrozenPanes?.schedule(); if (csvChunks.length && pendingEnsureTarget) { ensureTargetStep(); } else if (csvChunks.length && nearBottom()) { @@ -2237,3 +2265,17 @@ document.addEventListener('keydown', e => { clearSelection(); } }); + +// Freeze layout never replaces cells or moves focus, including during a live edit. +if (typeof window.createFrozenPaneManager === 'function') { + window.csvFrozenPanes = window.createFrozenPaneManager({ + table, + firstRow: Number(root?.dataset?.firstrow || '0'), + realRowCount: Number(root?.dataset?.realrowcount || '0'), + onNeedRows: () => loadNextChunk() + }); + window.csvFrozenPanes.update(frozenRowCount, frozenColumnCount); + window.addEventListener('csvChunkLoaded', () => window.csvFrozenPanes.schedule()); + window.addEventListener('resize', () => window.csvFrozenPanes.schedule()); + window.addEventListener('pagehide', () => window.csvFrozenPanes.dispose(), { once: true }); +} diff --git a/package.json b/package.json index 092489b..e787037 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "onCommand:csv.changeFontFamily", "onCommand:csv.changeIgnoreRows", "onCommand:csv.changeEncoding", - "onCommand:csv.toggleClickableLinks" + "onCommand:csv.toggleClickableLinks", + "onCommand:csv.changeFrozenRows", + "onCommand:csv.changeFrozenColumns" ], "main": "./out/extension.js", "contributes": { @@ -99,6 +101,14 @@ { "command": "csv.toggleClickableLinks", "title": "CSV: Toggle Clickable Links" + }, + { + "command": "csv.changeFrozenRows", + "title": "CSV: Freeze Leading Rows" + }, + { + "command": "csv.changeFrozenColumns", + "title": "CSV: Freeze Leading Columns" } ], "configuration": { @@ -113,7 +123,7 @@ "csv.fontFamily": { "type": "string", "default": "", - "description": "Font family used by the CSV custom editor. Leave empty to inherit ‘editor.fontFamily’.", + "description": "Font family used by the CSV custom editor. Leave empty to inherit \u2018editor.fontFamily\u2019.", "scope": "application" }, "csv.fontSize": { diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index a1484e4..e13e706 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -3,6 +3,8 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { parseClipboardMatrix, serializeClipboardMatrix } from './clipboard'; +type FrozenPanes = { rows: number; columns: number }; + type SeparatorMode = 'extension' | 'auto' | 'default'; type SeparatorSettings = { mode: SeparatorMode; @@ -70,6 +72,7 @@ class CsvEditorController { private document!: vscode.TextDocument; private separatorCache: { version: number; configKey: string; separator: string } | undefined; private isDiffContext = false; + private currentHeader = false; private chunkRenderState: ChunkRenderState | undefined; constructor(private readonly context: vscode.ExtensionContext) {} @@ -148,6 +151,9 @@ class CsvEditorController { case 'save': await this.handleSave(); break; + case 'freezePanesChanged': + await this.setFrozenPanes({ rows: e.rows, columns: e.columns }); + break; case 'columnSizesChanged': await CsvEditorProvider.setColumnSizesForUri(this.context, this.document.uri, e.columnSizes); break; @@ -416,6 +422,15 @@ class CsvEditorController { ); } + public getCurrentFrozenPanes(): FrozenPanes { + return CsvEditorProvider.getFrozenPanesForUri(this.context, this.document.uri, this.currentHeader); + } + + public async setFrozenPanes(change: Partial): Promise { + await CsvEditorProvider.setFrozenPanesForUri(this.context, this.document.uri, change); + this.currentWebviewPanel?.webview.postMessage({ type: 'frozenPanes', ...this.getCurrentFrozenPanes() }); + } + public getDocumentUri(): vscode.Uri { return this.document.uri; } @@ -1413,6 +1428,7 @@ class CsvEditorController { const cellPadding = config.get('cellPadding', 4); const data = this.trimTrailingEmptyRows((parsed.data || []) as string[][]); const treatHeader = this.getEffectiveHeader(data, hiddenRows); + this.currentHeader = treatHeader; const clickableLinks = config.get('clickableLinks', true); const configuredColumnColorMode = config.get('columnColorMode', 'type'); const diffUseThemeForeground = config.get('diffUseThemeForeground', true); @@ -1456,7 +1472,10 @@ class CsvEditorController { hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, - columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri) + columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri), + frozenPanes: this.getCurrentFrozenPanes(), + firstVisibleRow: hiddenRows, + realRowCount: data.length }); } @@ -1710,14 +1729,21 @@ class CsvEditorController { mouseWheelZoomEnabled: boolean; mouseWheelZoomInvert: boolean; columnSizes: Record; + frozenPanes: FrozenPanes; + firstVisibleRow: number; + realRowCount: number; }): string { - const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes } = args; + const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes, frozenPanes, firstVisibleRow, realRowCount } = 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( vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'main.js')) ); + const frozenPanesScriptUri = webview.asWebviewUri( + vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'frozen-panes.js')) + ); + // Safe separator transport (assumes single character; see assumptions) const sepCode = (separator && separator.length > 0) ? separator.codePointAt(0)! : ','.codePointAt(0)!; @@ -1734,7 +1760,7 @@ class CsvEditorController { .table-container { overflow: auto; height: 100vh; } table { border-collapse: collapse; width: max-content; } th, td { padding: ${cellPadding}px 8px; border: 1px solid ${isDark ? '#555' : '#ccc'}; font-size: inherit; } - th { position: sticky; top: 0; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + th { position: static; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } td { overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; } td.selected, th.selected { background-color: ${isDark ? '#333333' : '#cce0ff'} !important; } td.editing, th.editing { overflow: visible !important; white-space: pre-wrap !important; overflow-wrap: anywhere !important; max-width: none !important; } @@ -1918,12 +1944,25 @@ class CsvEditorController { #contextMenu div { padding: 4px 12px; cursor: pointer; } #contextMenu div:hover { background: ${isDark ? '#3d3d3d' : '#eeeeee'}; } + #csv-root .csv-frozen-row, #csv-root .csv-frozen-column { + position: sticky; + background-color: var(--vscode-editor-background, ${isDark ? '#1e1e1e' : '#ffffff'}) !important; + } + #csv-root .csv-frozen-row.selected, #csv-root .csv-frozen-column.selected { + background-image: linear-gradient(var(--vscode-editor-selectionBackground, ${isDark ? '#333333' : '#cce0ff'}), var(--vscode-editor-selectionBackground, ${isDark ? '#333333' : '#cce0ff'})); + } + #csv-root .csv-frozen-row.highlight, #csv-root .csv-frozen-column.highlight { + background-image: linear-gradient(var(--vscode-editor-findMatchHighlightBackground, transparent), var(--vscode-editor-findMatchHighlightBackground, transparent)); + } + #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)); + } /* Per-column computed colors */ ${extraColumnColorCss} -
+
${tableHtml}
@@ -1971,6 +2010,7 @@ class CsvEditorController {
+ `; @@ -2234,6 +2274,7 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { public static readonly serialKey = 'csv.serialIndexByUri'; public static readonly sepKey = 'csv.separatorByUri'; public static readonly columnSizesKey = 'csv.columnSizesByUri'; + public static readonly frozenPanesKey = 'csv.frozenPanesByUri'; private static readonly DEFAULT_SEPARATOR = ','; private static readonly DEFAULT_SEPARATOR_MODE: SeparatorMode = 'extension'; private static readonly BUILTIN_SEPARATORS_BY_EXTENSION: Record = { @@ -2604,6 +2645,26 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { await context.workspaceState.update(CsvEditorProvider.serialKey, 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()] ?? {}; + const valid = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + return { rows: valid(saved.rows) ? saved.rows : (header ? 1 : 0), columns: valid(saved.columns) ? saved.columns : 0 }; + } + + public static async setFrozenPanesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, change: Partial): Promise { + const map = { ...context.workspaceState.get>>(CsvEditorProvider.frozenPanesKey, {}) }; + const saved = { ...map[uri.toString()] }; + for (const axis of ['rows', 'columns'] as const) { + const value = change[axis]; + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) { + saved[axis] = value; + } + } + map[uri.toString()] = saved; + await context.workspaceState.update(CsvEditorProvider.frozenPanesKey, map); + } + public static normalizeColumnSizes(raw: unknown): Record { const sizes: Record = {}; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { diff --git a/src/commands.ts b/src/commands.ts index 6241b3d..ae6f429 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,8 +19,27 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref CsvEditorProvider.editors.forEach(ed => ed.refresh()); } +async function changeFrozenPaneCount(axis: 'rows' | 'columns') { + const active = CsvEditorProvider.getActiveProvider(); + if (!active) { + vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); + return; + } + const current = active.getCurrentFrozenPanes()[axis]; + const input = await vscode.window.showInputBox({ + prompt: `Number of leading visible ${axis} to freeze (0 unfreezes)`, + value: String(current), + validateInput: value => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? undefined : 'Enter a non-negative whole number.' + }); + if (input !== undefined && /^\d+$/.test(input) && Number.isSafeInteger(Number(input))) { + await active.setFrozenPanes({ [axis]: Number(input) }); + } +} + export function registerCsvCommands(context: vscode.ExtensionContext) { context.subscriptions.push( + vscode.commands.registerCommand('csv.changeFrozenRows', () => changeFrozenPaneCount('rows')), + vscode.commands.registerCommand('csv.changeFrozenColumns', () => changeFrozenPaneCount('columns')), vscode.commands.registerCommand('csv.toggleExtension', () => toggleBooleanConfig('enabled', true, 'CSV extension') ), diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..7d3e171 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -49,6 +49,9 @@ export async function createEditorHarness(options: { separator?: string; header?: boolean; remote?: boolean; + frozenRows?: number; + frozenColumns?: number; + hiddenRows?: number; } = {}) { const separator = options.separator ?? ','; let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); @@ -84,6 +87,10 @@ export async function createEditorHarness(options: { } }; await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); + if (options.hiddenRows !== undefined) { + await CsvEditorProvider.setHiddenRowsForUri(context as any, uri as any, options.hiddenRows); + } + await CsvEditorProvider.setFrozenPanesForUri(context as any, uri as any, { rows: options.frozenRows, columns: options.frozenColumns }); const panel = { active: true, visible: true, @@ -100,7 +107,7 @@ export async function createEditorHarness(options: { onDidDispose() {} }; const harness = { - document, panel, messages, changeListeners, saveListeners, + document, panel, messages, changeListeners, saveListeners, context, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, @@ -147,6 +154,8 @@ export function createWebviewDom(html: string) { window.HTMLElement.prototype.scrollIntoView = () => {}; window.IntersectionObserver = class { observe() {} }; 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 };`); return { window, messages, diff --git a/src/test/frozen-pane-commands.test.ts b/src/test/frozen-pane-commands.test.ts new file mode 100644 index 0000000..0330794 --- /dev/null +++ b/src/test/frozen-pane-commands.test.ts @@ -0,0 +1,44 @@ +import assert from 'assert'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +const commands = new Map Promise>(); +let input: string | undefined; +let prompt: any; +const changes: any[] = []; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + commands: { registerCommand: (name: string, callback: () => Promise) => { commands.set(name, callback); return { dispose() {} }; } }, + window: { showInputBox: async (options: any) => { prompt = options; return input; }, showInformationMessage() {} } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { registerCsvCommands } from '../commands'; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; +registerCsvCommands({ subscriptions: [] } as any); + +describe('Frozen-pane commands', () => { + it('updates the requested axis with validated counts and leaves cancellation unchanged', async () => { + CsvEditorProvider.currentActive = { getCurrentFrozenPanes: () => ({ rows: 3, columns: 1 }), setFrozenPanes: async (change: any) => changes.push(change) } as any; + input = '2'; + await commands.get('csv.changeFrozenRows')!(); + assert.strictEqual(prompt.value, '3'); + assert.strictEqual(prompt.validateInput('0'), undefined); + assert.ok(prompt.validateInput('-1')); + assert.ok(prompt.validateInput('1.5')); + assert.deepStrictEqual(changes, [{ rows: 2 }]); + input = '0'; + await commands.get('csv.changeFrozenColumns')!(); + assert.deepStrictEqual(changes, [{ rows: 2 }, { columns: 0 }]); + input = undefined; + await commands.get('csv.changeFrozenRows')!(); + input = 'invalid'; + await commands.get('csv.changeFrozenRows')!(); + assert.strictEqual(changes.length, 2); + CsvEditorProvider.currentActive = undefined; + }); +}); diff --git a/src/test/frozen-panes.test.ts b/src/test/frozen-panes.test.ts new file mode 100644 index 0000000..3078bd6 --- /dev/null +++ b/src/test/frozen-panes.test.ts @@ -0,0 +1,213 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); + +function measure(view: ReturnType, heights = [25, 60, 35, 30]) { + for (const row of Array.from(view.window.document.querySelectorAll('tr')) as any[]) { + const index = Number(row.querySelector('[data-row]')?.getAttribute('data-row')); + const height = () => (heights[index] ?? 30) * (parseFloat(view.window.document.body.style.fontSize) / 14 || 1); + row.getBoundingClientRect = () => ({ height: height() }); + for (const cell of Array.from(row.cells) as any[]) { + const col = cell.getAttribute('data-col') === null ? -1 : Number(cell.getAttribute('data-col')); + const width = () => parseFloat(cell.style.width) || (col === -1 ? 30 : (col === 0 ? 90 : 140)); + cell.getBoundingClientRect = () => ({ left: col === -1 ? 0 : 30 + col * 90, right: col === -1 ? 30 : 30 + col * 90 + width(), width: width(), height: height() }); + } + } +} + +async function fixture(options: Parameters[0] = {}) { + const host = await createEditorHarness({ rows: [['Name', 'Value'], ['one\nsecond line', 'a'], ['two', 'b'], ['three', 'c']], ...options }); + const view = createWebviewDom(host.panel.webview.html); + measure(view); + return { host, view }; +} + +describe('Frozen panes', () => { + it('keeps measured multiline leading rows and data columns with proper corner stacking', async () => { + const { view } = await fixture({ frozenRows: 3, frozenColumns: 1 }); + try { + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(0, 0).style.top, '0px'); + assert.strictEqual(view.cell(1, 0).style.top, '25px'); + assert.strictEqual(view.cell(2, 0).style.top, '85px'); + assert.strictEqual(view.cell(1, -1).style.left, '0px'); + assert.strictEqual(view.cell(1, 0).style.left, '30px'); + assert.strictEqual(view.cell(1, 0).style.zIndex, '4'); + assert.strictEqual(view.cell(1, 1).style.zIndex, '3'); + assert.strictEqual(view.cell(3, 0).style.zIndex, '2'); + assert.ok(!view.cell(3, 1).classList.contains('csv-frozen-row')); + assert.ok(!view.cell(4, 0).classList.contains('csv-frozen-row'), 'append row remains virtual'); + } finally { view.close(); } + }); + + it('recomputes measured offsets after zoom and column resizing', async () => { + const { view } = await fixture({ frozenRows: 3, frozenColumns: 2 }); + try { + view.window.csvFrozenPanes.layout(); + view.cell(1, 0).focus(); + view.key('+', { ctrlKey: true }); + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(2, 0).style.top, '93.5px'); + view.cell(0, 0).style.width = '200px'; + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(1, 1).style.left, '230px'); + } finally { view.close(); } + }); + + it('supports headers off and hidden metadata without freezing the append row', async () => { + const { view } = await fixture({ header: false, frozenRows: 100, hiddenRows: 1 }); + try { + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(1, 0).style.top, '0px'); + assert.strictEqual(view.cell(2, 0).style.top, '60px'); + assert.ok(!view.cell(4, 0).classList.contains('csv-frozen-row')); + } finally { view.close(); } + }); + + it('preserves the existing default header freeze and allows explicit zero to unfreeze it', async () => { + const { host, view } = await fixture(); + try { + view.window.csvFrozenPanes.layout(); + assert.ok(view.cell(0, 0).classList.contains('csv-frozen-row')); + await host.receive({ type: 'freezePanesChanged', rows: 0, columns: 0 }); + const update = host.messages.slice().reverse().find((message: any) => message.type === 'frozenPanes'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: update })); + view.window.csvFrozenPanes.layout(); + assert.ok(!view.cell(0, 0).classList.contains('csv-frozen-row')); + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, host.document.uri as any, true), { rows: 0, columns: 0 }); + } finally { view.close(); } + }); + + it('persists each axis by URI and retains defaults for untouched files/header modes', async () => { + const { host, view } = await fixture(); + try { + const uri = host.document.uri as any; + await CsvEditorProvider.setFrozenPanesForUri(host.context as any, uri, { rows: 3 }); + await CsvEditorProvider.setFrozenPanesForUri(host.context as any, uri, { columns: 2 }); + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, uri, true), { rows: 3, columns: 2 }); + await CsvEditorProvider.setFrozenPanesForUri(host.context as any, uri, { rows: -1, columns: Infinity }); + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, uri, true), { rows: 3, columns: 2 }); + const other = { toString: () => 'file:///other.tsv' } as any; + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, other, true), { rows: 1, columns: 0 }); + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, other, false), { rows: 0, columns: 0 }); + } finally { view.close(); } + }); + + it('updates freeze state without replacing an active multiline draft, caret or selection', async () => { + const { host, view } = await fixture(); + try { + const cell = view.cell(1, 0); + view.start(cell); + cell.textContent = 'unsaved\nmultiline draft'; + view.caret(cell.firstChild, 8); + const html = host.panel.webview.html; + await host.receive({ type: 'freezePanesChanged', rows: 3, columns: 1 }); + const update = host.messages.slice().reverse().find((message: any) => message.type === 'frozenPanes'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: update })); + view.window.csvFrozenPanes.layout(); + assert.strictEqual(host.panel.webview.html, html); + assert.strictEqual(view.cell(1, 0), cell); + assert.strictEqual(cell.textContent, 'unsaved\nmultiline draft'); + assert.strictEqual(view.window.document.activeElement, cell); + assert.strictEqual(view.window.getSelection().anchorOffset, 8); + } finally { view.close(); } + }); + + it('requests and freezes leading rows extending beyond the initial remote chunk', async () => { + const rows = Array.from({ length: 1100 }, (_, row) => [`row${row}`, 'value']); + const { host, view } = await fixture({ rows, frozenRows: 1002, frozenColumns: 1 }); + try { + view.window.csvFrozenPanes.layout(); + const request = view.messages.find((message: any) => message.type === 'requestChunk'); + assert.ok(request); + 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.csvFrozenPanes.layout(); + view.window.csvFrozenPanes.layout(); + assert.ok(view.cell(1001, 0).classList.contains('csv-frozen-row')); + assert.ok(view.cell(1002, 0).classList.contains('csv-frozen-column')); + const appendRequest = view.messages.slice().reverse().find((message: any) => message.type === 'requestChunk'); + await host.receive(appendRequest); + const appendResponse = host.messages.slice().reverse().find((message: any) => message.type === 'chunkData'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: appendResponse })); + view.window.__csvLoadNextChunk(); + view.window.csvFrozenPanes.layout(); + assert.ok(!view.cell(1100, 0).classList.contains('csv-frozen-row')); + } finally { view.close(); } + }); + + it('freezes through a context-menu target using visible row counts and can unfreeze', async () => { + const { view } = await fixture({ hiddenRows: 1, header: false }); + try { + const target = view.cell(2, 1); + const openMenu = () => { + target.dispatchEvent(new view.window.MouseEvent('mousedown', { button: 2, bubbles: true, cancelable: true })); + target.dispatchEvent(new view.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true })); + }; + const choose = (label: string) => { + const item = Array.from(view.window.document.querySelectorAll('#contextMenu div')).find((element: any) => element.textContent === label) as any; + assert.ok(item, label); + item.click(); + }; + openMenu(); + choose('Freeze rows through this row'); + openMenu(); + choose('Freeze columns through this column'); + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(2, 1).style.zIndex, '4'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(view.messages.filter((message: any) => message.type === 'freezePanesChanged'))), [ + { type: 'freezePanesChanged', rows: 2, columns: 0 }, { type: 'freezePanesChanged', rows: 2, columns: 2 } + ]); + openMenu(); + choose('Unfreeze rows'); + view.window.csvFrozenPanes.layout(); + assert.ok(!view.cell(2, 1).classList.contains('csv-frozen-row')); + assert.ok(view.cell(2, 1).classList.contains('csv-frozen-column')); + } finally { view.close(); } + }); + + it('observes row-height changes and recalculates sticky offsets on the next frame', async () => { + const { view } = await fixture({ frozenRows: 3 }); + try { + view.window.csvFrozenPanes.dispose(); + let resized = () => {}; + let observed = 0; + let disconnected = false; + view.window.ResizeObserver = class { + constructor(callback: () => void) { resized = callback; } + observe() { observed++; } + disconnect() { disconnected = true; } + }; + const heights = [25, 60, 35, 30]; + measure(view, heights); + const manager = view.window.createFrozenPaneManager({ table: view.window.document.querySelector('table'), firstRow: 0, realRowCount: 4 }); + manager.update(3, 0); + await new Promise(resolve => view.window.requestAnimationFrame(resolve)); + assert.ok(observed > 0); + assert.strictEqual(view.cell(2, 0).style.top, '85px'); + heights[1] = 100; + resized(); + await new Promise(resolve => view.window.requestAnimationFrame(resolve)); + assert.strictEqual(view.cell(2, 0).style.top, '125px'); + manager.dispose(); + assert.strictEqual(disconnected, true); + } finally { view.close(); } + }); + + it('adds documented command contributions and opaque sticky theme surfaces', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); + const readme = fs.readFileSync(path.join(process.cwd(), 'README.md'), 'utf8'); + for (const command of ['csv.changeFrozenRows', 'csv.changeFrozenColumns']) { + assert.ok(manifest.contributes.commands.some((item: any) => item.command === command)); + assert.ok(manifest.activationEvents.includes(`onCommand:${command}`)); + assert.ok(readme.includes(command)); + } + const provider = fs.readFileSync(path.join(process.cwd(), 'src', 'CsvEditorProvider.ts'), 'utf8'); + assert.ok(provider.includes('background-color: var(--vscode-editor-background,')); + assert.ok(provider.includes('background-image: linear-gradient(var(--vscode-editor-selectionBackground,')); + }); +}); From c24ca3a8ac88df5276bd8311472d1ca68e4cc32f Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:23:09 -0400 Subject: [PATCH 17/83] Add inherited defaults for hiding leading metadata rows (#99) --- README.md | 3 +- media/main.js | 6 +- package.json | 7 ++ src/CsvEditorProvider.ts | 33 ++++++-- src/commands.ts | 11 +-- src/extension.ts | 6 ++ src/test/default-ignore-rows-commands.test.ts | 54 +++++++++++++ src/test/default-ignore-rows.test.ts | 76 +++++++++++++++++++ src/test/edit-integrity-harness.ts | 9 ++- src/test/provider-state.test.ts | 3 +- 10 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 src/test/default-ignore-rows-commands.test.ts create mode 100644 src/test/default-ignore-rows.test.ts diff --git a/README.md b/README.md index 34625d7..3a4d519 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Open the Command Palette and search for: Global (Settings UI or `settings.json`): - `csv.enabled` (boolean, default `true`): Enable/disable the custom editor. +- `csv.defaultIgnoreRows` (integer, default `0`): Default number of leading metadata rows to hide. Set it globally or for a workspace/folder. `CSV: Hide First N Rows` overrides it per file; enter `0` to show all rows or leave the input blank to reset that file to the configured default. Hidden rows remain in the saved file. - `csv.fontFamily` (string, default empty): Override font family; falls back to `editor.fontFamily`. - `csv.fontSize` (number, default `0`): Override font size in px; set to `0` to inherit `editor.fontSize`. - `csv.mouseWheelZoom` (boolean, default `true`): Enable `Ctrl/Cmd + Mouse Wheel` zooming in the CSV editor. @@ -122,7 +123,7 @@ Per-file (stored by the extension; set via commands): - First row as header (default `true`) — `CSV: Toggle First Row as Header` - Serial index column (default `true`) — `CSV: Toggle Serial Index Column` - CSV separator override — `CSV: Change CSV Separator` (or clear it with `CSV: Reset CSV Separator (Inherit)`) -- Hide first N rows (default `0`) — `CSV: Hide First N Rows` +- Hide first N rows (inherits `csv.defaultIgnoreRows`) — `CSV: Hide First N Rows`. An explicit `0` overrides a nonzero default; blank input clears the per-file override. - Frozen leading visible rows/columns — `CSV: Freeze Leading Rows` / `CSV: Freeze Leading Columns`. Enter `0` to unfreeze that axis. The default keeps the first header row frozen; with headers off, no rows freeze until requested. Hidden metadata rows do not count; the virtual append row is never frozen as a leading row. --- diff --git a/media/main.js b/media/main.js index 3feb207..d11a834 100644 --- a/media/main.js +++ b/media/main.js @@ -2102,7 +2102,11 @@ const copySelectionToClipboard = (cut = false) => { window.addEventListener('message', event => { const message = event.data; - if (message.type === 'frozenPanes') { + if (message.type === 'prepareHiddenRowsRefresh') { + if (editingCell) editingCell.blur(); + persistState(); + vscode.postMessage({ type: 'hiddenRowsRefreshReady' }); + } else if (message.type === 'frozenPanes') { frozenRowCount = Number(message.rows); frozenColumnCount = Number(message.columns); window.csvFrozenPanes?.update(frozenRowCount, frozenColumnCount); diff --git a/package.json b/package.json index e787037..b31b643 100644 --- a/package.json +++ b/package.json @@ -217,6 +217,13 @@ "default": 10, "minimum": 0, "description": "Soft max size in MB for opening files in CSV view. 0 disables the limit. When exceeded, CSV prompts to cancel, continue this time, or disable the limit." + }, + "csv.defaultIgnoreRows": { + "type": "integer", + "default": 0, + "minimum": 0, + "scope": "resource", + "description": "Default number of initial metadata rows to hide without deleting them. Per-file Hide First N Rows values override this default; leave that command blank to inherit again." } } }, diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index e13e706..f7b07cc 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -151,6 +151,9 @@ class CsvEditorController { case 'save': await this.handleSave(); break; + case 'hiddenRowsRefreshReady': + this.updateWebviewContent(); + break; case 'freezePanesChanged': await this.setFrozenPanes({ rows: e.rows, columns: e.columns }); break; @@ -422,6 +425,14 @@ class CsvEditorController { ); } + public refreshHiddenRows(): void { + if (this.currentWebviewPanel?.visible) { + this.currentWebviewPanel.webview.postMessage({ type: 'prepareHiddenRowsRefresh' }); + } else { + this.refresh(); + } + } + public getCurrentFrozenPanes(): FrozenPanes { return CsvEditorProvider.getFrozenPanesForUri(this.context, this.document.uri, this.currentHeader); } @@ -2602,18 +2613,28 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { return CsvEditorProvider.currentActive || CsvEditorProvider.editors.find(ed => ed.isActive()); } + public static hasHiddenRowsOverride(context: vscode.ExtensionContext, uri: vscode.Uri): boolean { + const map = context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {}); + const saved = map[uri.toString()]; + return Object.prototype.hasOwnProperty.call(map, uri.toString()) && typeof saved === 'number' && Number.isFinite(saved); + } + public static getHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri): number { const map = context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {}); - const n = map[uri.toString()] ?? 0; - return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; + const saved = map[uri.toString()]; + if (Object.prototype.hasOwnProperty.call(map, uri.toString()) && typeof saved === 'number' && Number.isFinite(saved)) { + return Math.max(0, Math.floor(saved)); + } + const configured = vscode.workspace.getConfiguration('csv', uri).get('defaultIgnoreRows', 0); + return typeof configured === 'number' && Number.isFinite(configured) ? Math.max(0, Math.floor(configured)) : 0; } - public static async setHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri, n: number): Promise { - const map = { ...(context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {})) }; - if (!Number.isFinite(n) || n <= 0) { + public static async setHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri, n: number | undefined): Promise { + const map = { ...context.workspaceState.get>(CsvEditorProvider.hiddenRowsKey, {}) }; + if (n === undefined || !Number.isFinite(n)) { delete map[uri.toString()]; } else { - map[uri.toString()] = Math.floor(n); + map[uri.toString()] = Math.max(0, Math.floor(n)); } await context.workspaceState.update(CsvEditorProvider.hiddenRowsKey, map); } diff --git a/src/commands.ts b/src/commands.ts index ae6f429..9867976 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -133,18 +133,19 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { const uri = active.getDocumentUri(); const current = CsvEditorProvider.getHiddenRowsForUri(context, uri); const input = await vscode.window.showInputBox({ - prompt: 'Hide first N rows (per file)', + prompt: 'Hide first N rows for this file (blank inherits csv.defaultIgnoreRows)', value: String(current), - validateInput: (val: string) => (/^\d+$/.test(val) ? undefined : 'Enter a non-negative integer') + validateInput: (val: string) => (val.trim() === '' || (/^\d+$/.test(val) && Number.isSafeInteger(Number(val))) ? undefined : 'Enter a non-negative integer or leave blank to inherit') }); if (input === undefined) { return; } - const n = parseInt(input, 10); + if (input.trim() !== '' && (!/^\d+$/.test(input) || !Number.isSafeInteger(Number(input)))) { return; } + const n = input.trim() === '' ? undefined : Number(input); await CsvEditorProvider.setHiddenRowsForUri(context, uri, n); // Refresh only editors showing this URI CsvEditorProvider.editors .filter(ed => ed.getDocumentUri().toString() === uri.toString()) - .forEach(ed => ed.refresh()); - vscode.window.showInformationMessage(`CSV: Hiding first ${n} row(s) for this file.`); + .forEach(ed => ed.refreshHiddenRows()); + vscode.window.showInformationMessage(n === undefined ? 'CSV: Hidden rows now inherit csv.defaultIgnoreRows for this file.' : `CSV: Hiding first ${n} row(s) for this file.`); }), vscode.commands.registerCommand('csv.changeEncoding', async () => { const active = CsvEditorProvider.getActiveProvider(); diff --git a/src/extension.ts b/src/extension.ts index 50ec42b..c163cf5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -86,6 +86,12 @@ export function activate(context: vscode.ExtensionContext) { } } + if (e.affectsConfiguration('csv.defaultIgnoreRows')) { + CsvEditorProvider.editors + .filter(editor => !CsvEditorProvider.hasHiddenRowsOverride(context, editor.getDocumentUri()) && e.affectsConfiguration('csv.defaultIgnoreRows', editor.getDocumentUri())) + .forEach(editor => editor.refreshHiddenRows()); + } + const keys = [ 'csv.fontFamily', 'csv.cellPadding', diff --git a/src/test/default-ignore-rows-commands.test.ts b/src/test/default-ignore-rows-commands.test.ts new file mode 100644 index 0000000..00ef684 --- /dev/null +++ b/src/test/default-ignore-rows-commands.test.ts @@ -0,0 +1,54 @@ +import assert from 'assert'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +const commands = new Map Promise>(); +let input: string | undefined; +let prompt: any; +let changedConfiguration: (event: any) => void; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + commands: { registerCommand: (name: string, callback: () => Promise) => { commands.set(name, callback); return { dispose() {} }; } }, + window: { showInputBox: async (options: any) => { prompt = options; return input; }, showInformationMessage() {}, registerCustomEditorProvider: () => ({ dispose() {} }) }, + workspace: { getConfiguration: () => ({ get: (key: string, fallback: any) => key === 'defaultIgnoreRows' ? 7 : fallback }), onDidChangeConfiguration: (listener: any) => { changedConfiguration = listener; return { dispose() {} }; } } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { activate } from '../extension'; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +describe('Hidden-row override controls', () => { + it('uses zero as an explicit override and blank input to inherit again; cancellation keeps the override', async () => { + const values: Record = {}; + const context = { subscriptions: [], workspaceState: { get: (key: string, fallback: any) => values[key] ?? fallback, update: async (key: string, value: any) => { values[key] = value; } } } as any; + activate(context); + const uri = { toString: () => 'file:///sample.csv' } as any; + let refreshed = 0; + const active = { getDocumentUri: () => uri, refreshHiddenRows: () => refreshed++ } as any; + CsvEditorProvider.currentActive = active; + CsvEditorProvider.editors = [active]; + input = '0'; + await commands.get('csv.changeIgnoreRows')!(); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 0); + changedConfiguration!({ affectsConfiguration: (key: string) => key === 'csv.defaultIgnoreRows' }); + assert.strictEqual(refreshed, 1, 'an explicit override must not interrupt editing when defaults change'); + assert.strictEqual(prompt.validateInput(''), undefined); + assert.ok(prompt.validateInput('-1')); + assert.ok(prompt.validateInput('1.5')); + input = undefined; + await commands.get('csv.changeIgnoreRows')!(); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 0); + input = ''; + await commands.get('csv.changeIgnoreRows')!(); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(context, uri), 7); + assert.strictEqual(refreshed, 2); + changedConfiguration!({ affectsConfiguration: (key: string) => key === 'csv.defaultIgnoreRows' }); + assert.strictEqual(refreshed, 3, 'default setting updates use the edit-safe refresh path'); + CsvEditorProvider.currentActive = undefined; + CsvEditorProvider.editors = []; + }); +}); diff --git a/src/test/default-ignore-rows.test.ts b/src/test/default-ignore-rows.test.ts new file mode 100644 index 0000000..44ce8c2 --- /dev/null +++ b/src/test/default-ignore-rows.test.ts @@ -0,0 +1,76 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); + +describe('Default hidden rows', () => { + it('inherits resource-specific defaults while explicit per-file zero wins until reset', async () => { + const host = await createEditorHarness({ defaultIgnoreRows: 1 }); + const uri = host.document.uri as any; + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 1); + assert.ok(host.configurationRequests.includes(uri)); + const other = { toString: () => 'file:///other.csv' } as any; + host.resourceDefaults[other.toString()] = 3; + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, other), 3); + await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, 0); + host.defaultIgnoreRows = 2; + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 0); + await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, 4); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 4); + await CsvEditorProvider.setHiddenRowsForUri(host.context as any, uri, undefined); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, uri), 2); + assert.strictEqual(CsvEditorProvider.getHiddenRowsForUri(host.context as any, other), 3); + }); + + it('hides inherited metadata from the view while preserving source rows and frozen-row identities', async () => { + const rows = [['metadata', 'preserve'], ['Name', 'Value'], ['one', 'two']]; + const host = await createEditorHarness({ rows, defaultIgnoreRows: 1, frozenRows: 2 }); + const view = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(view.cell(0, 0), null); + assert.strictEqual(view.cell(1, 0).tagName, 'TH'); + assert.strictEqual(view.cell(2, 0).textContent, 'one'); + view.window.csvFrozenPanes.layout(); + assert.ok(view.cell(1, 0).classList.contains('csv-frozen-row')); + assert.ok(view.cell(2, 0).classList.contains('csv-frozen-row')); + assert.deepStrictEqual(host.rows(), rows); + } finally { view.close(); } + }); + + it('commits an active multiline draft before refreshing an inherited default', async () => { + const host = await createEditorHarness({ rows: [['metadata', 'keep'], ['Name', 'Value'], ['one', 'two'], ['three', 'four']], defaultIgnoreRows: 1 }); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(2, 0); + view.start(cell); + cell.textContent = 'unsaved\nmultiline draft'; + host.defaultIgnoreRows = 2; + CsvEditorProvider.currentActive!.refreshHiddenRows(); + const prepare = host.messages.slice().reverse().find((message: any) => message.type === 'prepareHiddenRowsRefresh'); + assert.ok(prepare); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: prepare })); + const messages = view.messages.filter((message: any) => ['editCell', 'hiddenRowsRefreshReady'].includes(message.type)); + assert.deepStrictEqual(messages.map((message: any) => message.type), ['editCell', 'hiddenRowsRefreshReady']); + for (const message of messages) { await host.receive(message); } + assert.strictEqual(host.rows()[2][0], 'unsaved\nmultiline draft'); + assert.deepStrictEqual(host.rows()[0], ['metadata', 'keep']); + assert.ok(host.panel.webview.html.includes('data-firstrow="2"')); + assert.ok(host.panel.webview.html.includes('unsaved')); + } finally { view.close(); } + }); + + it('declares one resource-scoped integer setting and documents explicit zero/inherit reset', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); + const setting = manifest.contributes.configuration.properties['csv.defaultIgnoreRows']; + assert.strictEqual(setting.type, 'integer'); + assert.strictEqual(setting.default, 0); + assert.strictEqual(setting.minimum, 0); + assert.strictEqual(setting.scope, 'resource'); + const readme = fs.readFileSync(path.join(process.cwd(), 'README.md'), 'utf8'); + assert.ok(readme.includes('csv.defaultIgnoreRows')); + assert.ok(readme.includes('blank')); + assert.ok(readme.includes('explicit `0`')); + }); +}); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 7d3e171..d0e5894 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -21,7 +21,10 @@ const vscodeStub = { ColorThemeKind: { Dark: 1 }, window: { activeColorTheme: { kind: 1 } }, workspace: { - getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), + getConfiguration: (_section: string, resource?: TestUri) => { + if (activeHarness) { activeHarness.configurationRequests.push(resource); } + return { get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : (key === 'defaultIgnoreRows' ? (activeHarness.resourceDefaults[resource?.toString() ?? ''] ?? activeHarness.defaultIgnoreRows) : fallback) }; + }, applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), onDidChangeTextDocument: (listener: (event: unknown) => void) => { activeHarness.changeListeners.push(listener); @@ -52,6 +55,7 @@ export async function createEditorHarness(options: { frozenRows?: number; frozenColumns?: number; hiddenRows?: number; + defaultIgnoreRows?: number; } = {}) { const separator = options.separator ?? ','; let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); @@ -108,6 +112,9 @@ export async function createEditorHarness(options: { }; const harness = { document, panel, messages, changeListeners, saveListeners, context, + defaultIgnoreRows: options.defaultIgnoreRows ?? 0, + resourceDefaults: {} as Record, + configurationRequests: [] as Array, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, diff --git a/src/test/provider-state.test.ts b/src/test/provider-state.test.ts index 42cd074..82af308 100644 --- a/src/test/provider-state.test.ts +++ b/src/test/provider-state.test.ts @@ -8,7 +8,8 @@ Module.prototype.require = function (id: string) { if (id === 'vscode') { return { window: { activeColorTheme: { kind: 1 } }, - ColorThemeKind: { Dark: 1 } + ColorThemeKind: { Dark: 1 }, + workspace: { getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }) } } as any; } return originalRequire.apply(this, arguments as any); From d657c592ebba89d6da4826ed5540ca8558d7c06e Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:24:23 -0400 Subject: [PATCH 18/83] feat: add bounded read-only preview for large CSV files (#80) --- README.md | 14 +- media/large-preview.js | 30 ++++ package.json | 34 +++++ src/bounded-csv-reader.ts | 220 ++++++++++++++++++++++++++++ src/commands.ts | 2 + src/extension.ts | 5 +- src/large-file-preview.ts | 167 +++++++++++++++++++++ src/test/bounded-csv-reader.test.ts | 157 ++++++++++++++++++++ src/test/edit-integrity-harness.ts | 17 ++- src/test/large-file-preview.test.ts | 175 ++++++++++++++++++++++ 10 files changed, 817 insertions(+), 4 deletions(-) create mode 100644 media/large-preview.js create mode 100644 src/bounded-csv-reader.ts create mode 100644 src/large-file-preview.ts create mode 100644 src/test/bounded-csv-reader.test.ts create mode 100644 src/test/large-file-preview.test.ts diff --git a/README.md b/README.md index f9dee2e..a76b87c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Clickable Links:** URLs in cells are automatically detected and displayed as clickable links. Ctrl/Cmd+click to open them in your browser. - **Preserved CSV Integrity:** All modifications respect CSV formatting—no unwanted extra characters or formatting issues. - **Optimized for Performance:** Designed for medium-sized datasets, ensuring a smooth editing experience without compromising on functionality. -- **Large File Support:** Loads big CSVs in chunks so even large datasets open quickly. +- **Large File Support:** The editable view chunks table rendering; an optional read-only preview bounds source reading and displays one page at a time. - **CSV/TSV/TAB/PSV Support:** `.csv`, `.tsv`, `.tab`, and `.psv` files are recognized automatically. Defaults: comma for CSV, tab for TSV/TAB, pipe for PSV. --- @@ -83,6 +83,7 @@ Cursor (built on VS Code 1.99) and the latest VS Code releases (1.102). Open the Command Palette and search for: - `CSV: Toggle Extension On/Off` (`csv.toggleExtension`) +- `CSV: Open Large File Preview (Read-only)` (`csv.openLargeFilePreview`) - `CSV: Toggle First Row as Header` (`csv.toggleHeader`) - `CSV: Toggle Serial Index Column` (`csv.toggleSerialIndex`) - `CSV: Change CSV Separator` (`csv.changeSeparator`) @@ -112,6 +113,7 @@ Global (Settings UI or `settings.json`): - `csv.defaultSeparator` (string, default `,`): Fallback separator. Use `\\t` in `settings.json` for tabs. - `csv.separatorByExtension` (object): Extension-to-separator mapping (defaults include `.csv`→`,`, `.tsv`/`.tab`→tab, `.psv`→`|`). - `csv.maxFileSizeMB` (number, default `10`): Soft limit for opening files in CSV view. If exceeded, CSV prompts: `Cancel`, `Continue This Time`, or `Ignore Forever` (sets this setting to `0`). +- `csv.largeFilePreviewRows` (integer, default `1000`, range `1`–`10000`): Maximum records per read-only preview page; byte and cell limits can make a page smaller. - Per-file encoding: `CSV: Change File Encoding` opens VS Code's encoding picker. Use **Reopen with Encoding** and select the source file's encoding to correct incorrectly displayed characters before editing or saving. Per-file (stored by the extension; set via commands): @@ -133,6 +135,16 @@ For example, the original attachment in [issue #76](https://github.com/jonarapha --- +## Large File Preview + +To inspect a very large saved file without creating a full VS Code text document, right-click a CSV/TSV/TAB/PSV file in Explorer and choose **CSV: Open Large File Preview (Read-only)**. The same command in the Command Palette opens a file picker. Use this direct path before opening the file in an editable tab; switching an already-open large text document to the preview does not undo memory already allocated by VS Code. + +The preview reads the saved bytes in bounded chunks and displays one page at a time. **Next**, **Previous**, and **Back to Start** navigate complete CSV records, including quoted multiline cells. The first record is displayed as data. It has no editing, global search, sorting, or filtering controls, and never writes to the source. Unsaved edits in another editor are not part of the saved-byte preview. + +`csv.largeFilePreviewRows` limits a page to 1–10,000 records (default 1,000). Independent limits cap a page at 512 KiB of source records and 20,000 cells, one record at 256 KiB, and a record at 1,000 columns. Pages may contain fewer records. Oversized or malformed records produce a visible error instead of being truncated. Only the current page and at most 32 page positions are retained; Previous reaches the retained history, and Back to Start always restarts the file. If the file changes between reads, Back to Start reloads it. + +This preview currently supports **valid UTF-8 and host-local `file:` URIs**. It decodes saved bytes directly in the extension host instead of requesting VS Code's decoded TextDocument. A UTF-8 BOM is accepted; other encodings must use the normal editor's encoding workflow. The extension host can be remote; arbitrary remote filesystem providers are unsupported. Separators follow the per-file override or extension/default settings; automatic content detection falls back to the extension mapping in preview mode. Normal editable CSV views retain their existing full-document behavior. + ## Editing Modes and Shortcuts - Quick edit: diff --git a/media/large-preview.js b/media/large-preview.js new file mode 100644 index 0000000..d758553 --- /dev/null +++ b/media/large-preview.js @@ -0,0 +1,30 @@ +const vscode = acquireVsCodeApi(); +const status = document.getElementById('status'); +const records = document.getElementById('records'); +const error = document.getElementById('error'); +const buttons = ['start', 'previous', 'next'].map(id => document.getElementById(id)); +const busy = () => { + buttons.forEach(button => { button.disabled = true; }); + status.textContent = 'Loading saved file…'; +}; +buttons.forEach(button => { + button.addEventListener('click', () => { + busy(); + vscode.postMessage({ type: button.id }); + }); +}); +window.addEventListener('message', event => { + if (event.data.type !== 'page') return; + const message = event.data; + // Markup is built by the extension from escaped cell values. Replacing the + // page bounds DOM growth regardless of how many pages have been visited. + records.innerHTML = message.html; + records.scrollTop = records.scrollLeft = 0; + status.textContent = message.status; + error.textContent = message.error || ''; + buttons[0].disabled = false; + buttons[1].disabled = !message.canBack; + buttons[2].disabled = !message.canNext; +}); +busy(); +vscode.postMessage({ type: 'ready' }); diff --git a/package.json b/package.json index a1745ea..c11e0ee 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "onLanguage:csv", "onLanguage:tsv", "onCommand:csv.toggleExtension", + "onCommand:csv.openLargeFilePreview", + "onCustomEditor:csv.largeFilePreview", "onCommand:csv.toggleHeader", "onCommand:csv.toggleSerialIndex", "onCommand:csv.changeSeparator", @@ -64,6 +66,10 @@ } ], "commands": [ + { + "command": "csv.openLargeFilePreview", + "title": "CSV: Open Large File Preview (Read-only)" + }, { "command": "csv.toggleExtension", "title": "CSV: Toggle Extension On/Off" @@ -207,10 +213,38 @@ "default": 10, "minimum": 0, "description": "Soft max size in MB for opening files in CSV view. 0 disables the limit. When exceeded, CSV prompts to cancel, continue this time, or disable the limit." + }, + "csv.largeFilePreviewRows": { + "type": "integer", + "default": 1000, + "minimum": 1, + "maximum": 10000, + "scope": "resource", + "description": "Maximum records per read-only Large File Preview page. Byte/cell safety limits can produce smaller pages." } } }, + "menus": { + "explorer/context": [ + { + "command": "csv.openLargeFilePreview", + "when": "resourceScheme == file && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i && config.csv.enabled", + "group": "navigation" + } + ] + }, "customEditors": [ + { + "viewType": "csv.largeFilePreview", + "displayName": "CSV Large File Preview (Read-only)", + "priority": "option", + "selector": [ + { "filenamePattern": "*.csv" }, + { "filenamePattern": "*.tsv" }, + { "filenamePattern": "*.tab" }, + { "filenamePattern": "*.psv" } + ] + }, { "viewType": "csv.editor", "displayName": "CSV", diff --git a/src/bounded-csv-reader.ts b/src/bounded-csv-reader.ts new file mode 100644 index 0000000..494c5a8 --- /dev/null +++ b/src/bounded-csv-reader.ts @@ -0,0 +1,220 @@ +import { promises as fs } from 'fs'; +import { TextDecoder } from 'util'; +import Papa from 'papaparse'; + +export const previewLimits = { + readBytes: 64 * 1024, + pageBytes: 512 * 1024, + recordBytes: 256 * 1024, + pageCells: 20000, + columns: 1000, + history: 32 +}; + +export type CsvPreviewPage = { + rows: string[][]; + offset: number; + nextOffset: number; + done: boolean; + version: string; + bytesRead: number; +}; + +export type CsvPreviewReadOptions = { + delimiter: string; + offset?: number; + maxRows?: number; + expectedVersion?: string; + cancelled?: () => boolean; + // Smaller read buffers make boundary handling deterministic in unit tests. + readBytes?: number; +}; + +function version(stat: { dev: number; ino: number; size: number; mtimeMs: number }): string { + return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; +} + +export async function readCsvPreviewPage(filePath: string, options: CsvPreviewReadOptions): Promise { + if (options.delimiter.length !== 1 || options.delimiter.charCodeAt(0) > 127 || /[\r\n"\0]/.test(options.delimiter)) { + throw new Error('Large File Preview requires a single ASCII separator. Set a separator override or use the editable CSV view.'); + } + const checkCancelled = () => { + if (options.cancelled?.()) { + throw new Error('Large File Preview was cancelled.'); + } + }; + checkCancelled(); + const handle = await fs.open(filePath, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error('Large File Preview can only read regular files.'); + } + const fileVersion = version(stat); + if (options.expectedVersion && options.expectedVersion !== fileVersion) { + throw new Error('The file changed. Choose Back to Start to reload it.'); + } + const offset = options.offset ?? 0; + if (!Number.isSafeInteger(offset) || offset < 0 || offset > stat.size) { + throw new Error('Invalid preview position. Choose Back to Start.'); + } + const requestedRows = Math.trunc(Number(options.maxRows ?? 1000)); + const maxRows = Number.isFinite(requestedRows) ? Math.min(10000, Math.max(1, requestedRows)) : 1000; + const readSize = Math.min(previewLimits.readBytes, Math.max(1, Math.trunc(options.readBytes ?? previewLimits.readBytes))); + const readBuffer = Buffer.alloc(readSize); + const recordBuffer = Buffer.alloc(previewLimits.recordBytes); + const rows: string[][] = []; + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + const delimiter = options.delimiter.charCodeAt(0); + let position = offset; + let recordStart = offset; + let recordLength = 0; + let recordFields = 1; + let usedBytes = 0; + let usedCells = 0; + let bytesRead = 0; + let state: 'start' | 'plain' | 'quoted' | 'closed' = 'start'; + let pendingCr = false; + let stopped = false; + let nextOffset = offset; + + if (offset === 0) { + const signature = Buffer.alloc(3); + const first = await handle.read(signature, 0, 3, 0); + bytesRead += first.bytesRead; + if ((signature[0] === 0xFF && signature[1] === 0xFE) || (signature[0] === 0xFE && signature[1] === 0xFF)) { + throw new Error('Large File Preview supports UTF-8 files only. Use the normal editor and Reopen with Encoding for this file.'); + } + if (first.bytesRead === 3 && signature.equals(Buffer.from([0xEF, 0xBB, 0xBF]))) { + position = recordStart = 3; + } + } + + const finishRecord = (end: number, terminatorBytes: number) => { + if (usedBytes + recordLength > previewLimits.pageBytes && rows.length > 0) { + stopped = true; + nextOffset = recordStart; + return; + } + let text: string; + try { + text = decoder.decode(recordBuffer.subarray(0, recordLength - terminatorBytes)); + } catch { + throw new Error('Large File Preview supports valid UTF-8 only. Reopen the original file with its source encoding in the normal editor.'); + } + if (text.includes('\0')) { + throw new Error('The file contains NUL bytes. Large File Preview supports UTF-8 text files only.'); + } + // Prefix an empty field so Papa does not strip an intentional U+FEFF at + // the start of a later record when parsing records independently. + const parsed = Papa.parse(`${options.delimiter}${text}`, { delimiter: options.delimiter, dynamicTyping: false }); + if (parsed.errors.some(error => error.type === 'Quotes') || parsed.data.length !== 1) { + throw new Error(`Malformed CSV record at byte ${recordStart}. No partial record was displayed.`); + } + const row = parsed.data[0].slice(1); + if (row.length > previewLimits.columns) { + throw new Error(`A record exceeds the ${previewLimits.columns}-column preview limit. No fields were truncated.`); + } + if (usedCells + row.length > previewLimits.pageCells && rows.length > 0) { + stopped = true; + nextOffset = recordStart; + return; + } + rows.push(row); + usedBytes += recordLength; + usedCells += row.length; + nextOffset = recordStart = end; + recordLength = 0; + recordFields = 1; + state = 'start'; + pendingCr = false; + stopped = rows.length >= maxRows || usedBytes >= previewLimits.pageBytes || usedCells >= previewLimits.pageCells; + }; + + while (!stopped && position < stat.size) { + checkCancelled(); + const read = await handle.read(readBuffer, 0, readBuffer.length, position); + bytesRead += read.bytesRead; + if (read.bytesRead === 0) { + break; + } + for (let index = 0; index < read.bytesRead && !stopped; index++) { + const byte = readBuffer[index]; + if (pendingCr && byte !== 10) { + finishRecord(position, 1); + if (stopped) { + break; + } + } + if (recordLength >= recordBuffer.length) { + throw new Error(`A record exceeds the ${previewLimits.recordBytes / 1024} KiB preview limit. No partial record was displayed.`); + } + recordBuffer[recordLength++] = byte; + position++; + if (pendingCr) { + finishRecord(position, 2); + } else if (state === 'quoted') { + if (byte === 34) { + state = 'closed'; + } + } else if (state === 'closed' && byte === 34) { + state = 'quoted'; + } else if (byte === 13) { + pendingCr = true; + } else if (byte === 10) { + finishRecord(position, 1); + } else if (byte === delimiter) { + recordFields++; + if (recordFields > previewLimits.columns) { + throw new Error(`A record exceeds the ${previewLimits.columns}-column preview limit. No fields were truncated.`); + } + state = 'start'; + } else if (state === 'start' && byte === 34) { + state = 'quoted'; + } else if (state === 'closed') { + if (byte !== 32 && byte !== 9) { + throw new Error(`Malformed quoted field at byte ${position - 1}. No partial record was displayed.`); + } + } else { + state = 'plain'; + } + } + } + if (!stopped && recordLength > 0) { + if (state === 'quoted') { + throw new Error(`Unterminated quoted record at byte ${recordStart}. No partial record was displayed.`); + } + finishRecord(position, pendingCr ? 1 : 0); + } + if (rows.length === 0) { + nextOffset = position; + } + checkCancelled(); + if (version(await fs.stat(filePath)) !== fileVersion || version(await handle.stat()) !== fileVersion) { + throw new Error('The file changed while reading. Choose Back to Start to reload it.'); + } + return { rows, offset, nextOffset, done: nextOffset >= stat.size, version: fileVersion, bytesRead }; + } finally { + await handle.close(); + } +} + +export type PreviewPosition = { offset: number; record: number }; + +export class PreviewHistory { + private positions: PreviewPosition[] = [{ offset: 0, record: 0 }]; + private index = 0; + get current(): PreviewPosition { return this.positions[this.index]; } + get canGoBack(): boolean { return this.index > 0; } + get size(): number { return this.positions.length; } + reset(): void { this.positions = [{ offset: 0, record: 0 }]; this.index = 0; } + previous(): void { if (this.canGoBack) { this.index--; } } + next(position: PreviewPosition): void { + this.positions.splice(this.index + 1); + this.positions.push(position); + if (this.positions.length > previewLimits.history) { + this.positions.shift(); + } + this.index = this.positions.length - 1; + } +} diff --git a/src/commands.ts b/src/commands.ts index f0feb8d..2a25700 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,7 @@ import { getFonts } from 'font-list'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; +import { openLargeFilePreview } from './large-file-preview'; function displaySeparator(separator: string): string { return separator === '\t' ? '\\t' : separator; @@ -21,6 +22,7 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref export function registerCsvCommands(context: vscode.ExtensionContext) { context.subscriptions.push( + vscode.commands.registerCommand('csv.openLargeFilePreview', openLargeFilePreview), vscode.commands.registerCommand('csv.toggleExtension', () => toggleBooleanConfig('enabled', true, 'CSV extension') ), diff --git a/src/extension.ts b/src/extension.ts index 50ec42b..68a7213 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; import { registerCsvCommands } from './commands'; +import { LargeFilePreviewProvider } from './large-file-preview'; export function activate(context: vscode.ExtensionContext) { console.log('CSV: Extension activated'); @@ -30,6 +31,7 @@ export function activate(context: vscode.ExtensionContext) { console.log(`[CSV(enable)]: group[${gi}] tab[${ti}] vt=${viewType ?? '(text?)'} uri=${path} active=${tab.isActive} preview=${tab.isPreview}`); if (!input) return; if (viewType === CsvEditorProvider.viewType) return; // already our editor + if (viewType === LargeFilePreviewProvider.viewType) { return; } if (!uri) return; const fsPath = uri.fsPath?.toLowerCase?.() || ''; const isCsvLike = fsPath.endsWith('.csv') || fsPath.endsWith('.tsv') || fsPath.endsWith('.tab') || fsPath.endsWith('.psv'); @@ -68,7 +70,7 @@ export function activate(context: vscode.ExtensionContext) { const vt = inp?.viewType; const u: vscode.Uri | undefined = inp?.uri instanceof vscode.Uri ? (inp.uri as vscode.Uri) : undefined; const key = u?.toString(); - if (key && processed.has(key) && vt !== CsvEditorProvider.viewType) { + if (key && processed.has(key) && vt !== CsvEditorProvider.viewType && vt !== LargeFilePreviewProvider.viewType) { console.log(`[CSV(enable)]: final-sweep closing group[${gi}] tab[${ti}] uri=${u?.fsPath} vt=${vt}`); stale.push(t); } @@ -107,6 +109,7 @@ export function activate(context: vscode.ExtensionContext) { // Register the custom editor provider for CSV/TSV context.subscriptions.push( + vscode.window.registerCustomEditorProvider(LargeFilePreviewProvider.viewType, new LargeFilePreviewProvider(context)), vscode.window.registerCustomEditorProvider( CsvEditorProvider.viewType, new CsvEditorProvider(context), diff --git a/src/large-file-preview.ts b/src/large-file-preview.ts new file mode 100644 index 0000000..a54fc0b --- /dev/null +++ b/src/large-file-preview.ts @@ -0,0 +1,167 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { randomBytes } from 'crypto'; +import { CsvEditorProvider } from './CsvEditorProvider'; +import { CsvPreviewPage, PreviewHistory, readCsvPreviewPage } from './bounded-csv-reader'; + +const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, char => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' +}[char] as string)); + +export function renderPreviewRows(rows: string[][], firstRecord: number): string { + if (rows.length === 0) { + return '

No records in this file.

'; + } + const width = Math.max(...rows.map(row => row.length)); + const headings = Array.from({ length: width }, (_, index) => `Column ${index + 1}`).join(''); + const body = rows.map((row, index) => `${firstRecord + index + 1}${row.map(value => `${escapeHtml(value)}`).join('')}`).join(''); + return `${headings}${body}
Record
`; +} + +class PreviewDocument implements vscode.CustomDocument { + disposed = false; + constructor(readonly uri: vscode.Uri) {} + dispose(): void { this.disposed = true; } +} + +export class LargeFilePreviewProvider implements vscode.CustomReadonlyEditorProvider { + static readonly viewType = 'csv.largeFilePreview'; + constructor(private readonly context: vscode.ExtensionContext) {} + + async openCustomDocument(uri: vscode.Uri): Promise { + if (uri.scheme !== 'file') { + throw new Error('Large File Preview currently supports host-local file URIs only. Use the normal CSV editor for this location.'); + } + return new PreviewDocument(uri); + } + + async resolveCustomEditor(document: PreviewDocument, panel: vscode.WebviewPanel, token: vscode.CancellationToken): Promise { + panel.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'media'))] + }; + panel.webview.html = this.html(panel.webview); + const history = new PreviewHistory(); + let page: CsvPreviewPage | undefined; + let fileVersion: string | undefined; + let selectedDelimiter: string | undefined; + let lastError = ''; + let disposed = false; + let pending = Promise.resolve(); + const cancelled = () => disposed || document.disposed || token.isCancellationRequested; + const send = (error?: string) => { + if (cancelled()) { + return; + } + const first = history.current.record; + void panel.webview.postMessage({ + type: 'page', + html: page ? renderPreviewRows(page.rows, first) : '', + status: page?.rows.length ? `Saved file · Records ${first + 1}–${first + page.rows.length} · Read-only` : 'Saved file · Read-only', + canBack: history.canGoBack, + canNext: !!page && !page.done && !error, + error: error ?? '' + }); + }; + const read = async (reset: boolean) => { + const config = vscode.workspace.getConfiguration('csv', document.uri); + if (!config.get('enabled', true)) { + throw new Error('CSV is disabled. Enable it before opening Large File Preview.'); + } + const settings = CsvEditorProvider.getSeparatorSettings(document.uri); + const delimiter = CsvEditorProvider.getSeparatorForUri(this.context, document.uri) + ?? CsvEditorProvider.resolveInheritedSeparator(document.uri.fsPath, '', settings); + if (!reset && selectedDelimiter !== delimiter) { + throw new Error('The separator changed. Choose Back to Start to reload the preview.'); + } + selectedDelimiter = delimiter; + page = await readCsvPreviewPage(document.uri.fsPath, { + delimiter, + offset: history.current.offset, + maxRows: config.get('largeFilePreviewRows', 1000), + expectedVersion: reset ? undefined : fileVersion, + cancelled + }); + fileVersion = page.version; + lastError = ''; + send(); + }; + panel.webview.onDidReceiveMessage(message => { + pending = pending.then(async () => { + if (cancelled()) { + return; + } + if (message.type === 'ready') { + if (page) { + send(); + } else if (lastError) { + send(lastError); + } else { + await read(true); + } + } else if (message.type === 'start') { + history.reset(); + page = undefined; + fileVersion = undefined; + lastError = ''; + await read(true); + } else if (message.type === 'next' && page && !page.done) { + history.next({ offset: page.nextOffset, record: history.current.record + page.rows.length }); + await read(false); + } else if (message.type === 'previous' && history.canGoBack) { + history.previous(); + await read(false); + } + }).catch(error => { + // Discard stale rows if navigation or source validation fails. The error + // remains visible; Back to Start can always retry a changed file. + page = undefined; + lastError = error instanceof Error ? error.message : 'Unable to read this preview page.'; + send(lastError); + }); + return pending; + }); + panel.onDidDispose(() => { disposed = true; }); + } + + private html(webview: vscode.Webview): string { + const nonce = randomBytes(16).toString('hex'); + const script = webview.asWebviewUri(vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'large-preview.js'))); + return ` + + + + +

Large File Preview · UTF-8 · Read-only. Each page replaces the previous page. The first record is shown as data.

+
+`; + } +} + +export async function openLargeFilePreview(uri?: vscode.Uri): Promise { + if (!vscode.workspace.getConfiguration('csv').get('enabled', true)) { + void vscode.window.showInformationMessage('Enable CSV before opening Large File Preview.'); + return; + } + const target = uri instanceof vscode.Uri ? uri : (await vscode.window.showOpenDialog({ + canSelectMany: false, + openLabel: 'Open Read-only Preview', + filters: { 'CSV/TSV/PSV': ['csv', 'tsv', 'tab', 'psv'] } + }))?.[0]; + if (!target) { + return; + } + if (target.scheme !== 'file') { + void vscode.window.showErrorMessage('Large File Preview currently supports host-local files only. Use the normal CSV editor for this location.'); + return; + } + await vscode.commands.executeCommand('vscode.openWith', target, LargeFilePreviewProvider.viewType, { preview: false }); +} diff --git a/src/test/bounded-csv-reader.test.ts b/src/test/bounded-csv-reader.test.ts new file mode 100644 index 0000000..56bfe9e --- /dev/null +++ b/src/test/bounded-csv-reader.test.ts @@ -0,0 +1,157 @@ +import assert from 'assert'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it } from 'node:test'; +import Papa from 'papaparse'; +import { previewLimits, PreviewHistory, readCsvPreviewPage } from '../bounded-csv-reader'; + +async function fixture(value: string | Buffer, run: (file: string) => Promise) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'csv-bounded-')); + const file = path.join(directory, 'source.csv'); + try { + await fs.writeFile(file, value); + await run(file); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +} + +describe('Bounded CSV reader', () => { + for (const delimiter of [',', '\t', '|']) { + for (const readBytes of [1, 2, 3, 7, 64]) { + it(`preserves UTF-8, CRLF and quoted boundaries with ${JSON.stringify(delimiter)} / ${readBytes}-byte reads`, async () => { + const data = [['name', 'value'], ['éä😀', `inside${delimiter}field`], ['two\r\nlines', 'a"b'], ['\uFEFFintentional', 'end']]; + const source = '\uFEFF' + Papa.unparse(data, { delimiter, newline: '\r\n' }) + '\r\n'; + await fixture(source, async file => { + const actual: string[][] = []; + let offset = 0; + let fileVersion: string | undefined; + let done = false; + while (!done) { + const page = await readCsvPreviewPage(file, { delimiter, offset, readBytes, maxRows: 1, expectedVersion: fileVersion }); + actual.push(...page.rows); + assert.ok(page.nextOffset > offset); + offset = page.nextOffset; + fileVersion = page.version; + done = page.done; + } + assert.deepStrictEqual(actual, data); + assert.strictEqual(offset, Buffer.byteLength(source)); + }); + }); + } + } + + for (const source of ['', '\uFEFF', '\n', '\r\n', 'a,b\r', 'a,b\nc,d', 'a,b\n\n']) { + it(`handles EOF and empty records in ${JSON.stringify(source)}`, async () => { + await fixture(source, async file => { + const page = await readCsvPreviewPage(file, { delimiter: ',', readBytes: 1 }); + const expected = source === '' || source === '\uFEFF' ? [] + : source === '\n' || source === '\r\n' ? [['']] + : source === 'a,b\nc,d' ? [['a', 'b'], ['c', 'd']] + : source === 'a,b\n\n' ? [['a', 'b'], ['']] + : [['a', 'b']]; + assert.deepStrictEqual(page.rows, expected); + assert.ok(page.done); + }); + }); + } + + it('reads only a bounded prefix and never a whole large file', async () => { + const source = 'alpha,beta\n'.repeat(200000); + await fixture(source, async file => { + const page = await readCsvPreviewPage(file, { delimiter: ',', maxRows: 1000 }); + assert.strictEqual(page.rows.length, 1000); + assert.ok(!page.done); + assert.ok(page.bytesRead <= previewLimits.readBytes + 3); + assert.ok(page.bytesRead < Buffer.byteLength(source) / 10); + }); + }); + + it('caps source bytes and cells independently of the requested row count', async () => { + const source = `${'x'.repeat(200000)}\n`.repeat(4); + await fixture(source, async file => { + const page = await readCsvPreviewPage(file, { delimiter: ',', maxRows: 10000 }); + assert.strictEqual(page.rows.length, 2); + assert.strictEqual(page.nextOffset, 400002); + assert.ok(page.bytesRead <= previewLimits.pageBytes + previewLimits.recordBytes + previewLimits.readBytes + 3); + }); + await fixture(`${Array(1000).fill('x').join(',')}\n`.repeat(30), async file => { + const page = await readCsvPreviewPage(file, { delimiter: ',', maxRows: 10000 }); + assert.strictEqual(page.rows.length, 20); + assert.strictEqual(page.rows.flat().length, previewLimits.pageCells); + assert.ok(!page.done); + }); + }); + + for (const [source, error] of [ + ['a,"unfinished\n', /Unterminated quoted record/], + ['a,"closed"junk\n', /Malformed quoted field/], + ['x'.repeat(previewLimits.recordBytes + 1), /No partial record was displayed/], + [Array(1001).fill('x').join(','), /No fields were truncated/] + ] as const) { + it(`rejects malformed/oversized input visibly (${error.source})`, async () => { + await fixture(source, file => assert.rejects(readCsvPreviewPage(file, { delimiter: ',' }), error)); + }); + } + + it('rejects non-UTF8, UTF16 BOMs, NULs and unsupported separators without guessing', async () => { + for (const bytes of [Buffer.from([0x84, 10]), Buffer.from([0xFF, 0xFE, 97, 0]), Buffer.from('a\0b')]) { + await fixture(bytes, file => assert.rejects(readCsvPreviewPage(file, { delimiter: ',' }), /UTF-8/)); + } + await fixture('a,b', file => assert.rejects(readCsvPreviewPage(file, { delimiter: '||' }), /single ASCII separator/)); + }); + + it('detects source changes between pages', async () => { + await fixture('a,b\n1,2\n', async file => { + const first = await readCsvPreviewPage(file, { delimiter: ',', maxRows: 1 }); + await fs.appendFile(file, '3,4\n'); + await assert.rejects(readCsvPreviewPage(file, { delimiter: ',', offset: first.nextOffset, expectedVersion: first.version }), /file changed/); + }); + }); + + it('closes the file descriptor after success, cancellation and malformed records', async () => { + const originalOpen = fs.open; + const handles: Array<{ closed: number }> = []; + (fs as any).open = async (...args: Parameters) => { + const handle = await originalOpen(...args); + const state = { closed: 0 }; + handles.push(state); + return new Proxy(handle, { + get(target, key) { + if (key === 'close') { + return async () => { state.closed++; await target.close(); }; + } + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + } + }); + }; + try { + await fixture('a,b\n1,2\n', async file => { + await readCsvPreviewPage(file, { delimiter: ',' }); + let checks = 0; + await assert.rejects(readCsvPreviewPage(file, { delimiter: ',', readBytes: 1, cancelled: () => ++checks > 2 }), /cancelled/); + }); + await fixture('"unfinished', file => assert.rejects(readCsvPreviewPage(file, { delimiter: ',' }), /Unterminated/)); + assert.deepStrictEqual(handles.map(handle => handle.closed), [1, 1, 1]); + } finally { + fs.open = originalOpen; + } + }); + + it('retains only a bounded navigation history', () => { + const history = new PreviewHistory(); + for (let index = 1; index < 1000; index++) { + history.next({ offset: index * 100, record: index }); + } + assert.strictEqual(history.size, previewLimits.history); + let previous = 0; + while (history.canGoBack) { history.previous(); previous++; } + assert.strictEqual(previous, previewLimits.history - 1); + history.reset(); + assert.deepStrictEqual(history.current, { offset: 0, record: 0 }); + assert.strictEqual(history.size, 1); + }); +}); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 6b1e5b9..c05184c 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -27,8 +27,12 @@ const vscodeStub = { }, showInformationMessage() {}, tabGroups: { - get all() { return [{ tabs: [{ input: { uri: activeHarness.document.uri, viewType: 'csv.editor' } }] }]; }, + get all() { return activeHarness.tabGroups ?? [{ tabs: [{ input: { uri: activeHarness.document.uri, viewType: 'csv.editor' } }] }]; }, close: async () => activeHarness.closeResult + }, + registerCustomEditorProvider: (viewType: string) => { + activeHarness.providers.push(viewType); + return { dispose() {} }; } }, commands: { @@ -47,12 +51,17 @@ const vscodeStub = { } }, workspace: { - getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), + getConfiguration: () => ({ get: (key: string, fallback: unknown) => + key in activeHarness.configuration ? activeHarness.configuration[key] : key === 'maxFileSizeMB' ? 0 : fallback }), applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), onDidChangeTextDocument: (listener: (event: unknown) => void) => { activeHarness.changeListeners.push(listener); return { dispose() {} }; }, + onDidChangeConfiguration: (listener: (event: any) => void) => { + activeHarness.configurationListeners.push(listener); + return { dispose() {} }; + }, onWillSaveTextDocument: (listener: (event: unknown) => void) => { activeHarness.saveListeners.push(listener); return { dispose() {} }; @@ -131,6 +140,10 @@ export async function createEditorHarness(options: { }; const harness = { document, panel, messages, changeListeners, saveListeners, + configuration: {} as Record, + configurationListeners: [] as Array<(event: any) => void>, + providers: [] as string[], + tabGroups: undefined as any[] | undefined, warnings: [] as string[], warningChoice: options.warningChoice, commandError: undefined as Error | undefined, diff --git a/src/test/large-file-preview.test.ts b/src/test/large-file-preview.test.ts new file mode 100644 index 0000000..16a8dfe --- /dev/null +++ b/src/test/large-file-preview.test.ts @@ -0,0 +1,175 @@ +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness } from './edit-integrity-harness'; +import { LargeFilePreviewProvider, renderPreviewRows } from '../large-file-preview'; +import { activate } from '../extension'; +const { JSDOM } = require('jsdom'); + +async function previewFixture(run: (fixture: any) => Promise) { + const host = await createEditorHarness(); + host.configuration.largeFilePreviewRows = 1; + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'csv-preview-')); + const file = path.join(directory, 'preview.csv'); + fs.writeFileSync(file, 'name,value\none,two\nthree,four\n'); + const Uri = host.document.uri.constructor as any; + const provider = new LargeFilePreviewProvider({ extensionPath: process.cwd(), workspaceState: { get: (_key: string, fallback: unknown) => fallback } } as any); + const document = await provider.openCustomDocument(new Uri(file)); + const messages: any[] = []; + let receive!: (message: unknown) => Promise; + let dispose!: () => void; + const panel = { + webview: { + html: '', options: {}, + asWebviewUri: (uri: unknown) => String(uri), + postMessage: (message: unknown) => { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage: (listener: typeof receive) => { receive = listener; } + }, + onDidDispose: (listener: () => void) => { dispose = listener; } + }; + try { + await provider.resolveCustomEditor(document, panel as any, { isCancellationRequested: false } as any); + await run({ host, file, provider, document, panel, messages, receive: (message: unknown) => receive(message), dispose: () => dispose() }); + } finally { + document.dispose(); + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +describe('Read-only large file provider', () => { + it('pages complete records, reports source changes and reloads on explicit Start', async () => { + await previewFixture(async fixture => { + await fixture.receive({ type: 'ready' }); + assert.ok(fixture.messages.at(-1).html.includes('name')); + await fixture.receive({ type: 'next' }); + assert.ok(fixture.messages.at(-1).html.includes('one')); + assert.ok(!fixture.messages.at(-1).html.includes('name')); + fs.appendFileSync(fixture.file, 'changed,row\n'); + await fixture.receive({ type: 'next' }); + assert.match(fixture.messages.at(-1).error, /file changed/); + assert.strictEqual(fixture.messages.at(-1).html, ''); + await fixture.receive({ type: 'previous' }); + assert.match(fixture.messages.at(-1).error, /file changed/); + await fixture.receive({ type: 'start' }); + assert.strictEqual(fixture.messages.at(-1).error, ''); + assert.ok(fixture.messages.at(-1).html.includes('name')); + }); + }); + + it('does not read or post after panel/document disposal and rejects remote file providers', async () => { + await previewFixture(async fixture => { + fixture.dispose(); + await fixture.receive({ type: 'ready' }); + assert.strictEqual(fixture.messages.length, 0); + const Uri = fixture.host.document.uri.constructor; + await assert.rejects(fixture.provider.openCustomDocument(new Uri('/remote/file.csv', 'vscode-remote')), /host-local/); + }); + }); + + it('respects csv.enabled before any preview data read', async () => { + await previewFixture(async fixture => { + fixture.host.configuration.enabled = false; + await fixture.receive({ type: 'ready' }); + assert.match(fixture.messages.at(-1).error, /CSV is disabled/); + assert.strictEqual(fixture.messages.at(-1).html, ''); + }); + }); + + it('opens directly as a readonly custom document and never writes saved bytes', async () => { + await previewFixture(async fixture => { + fixture.host.registerCommands(); + const before = fs.readFileSync(fixture.file); + await fixture.host.commands.get('csv.openLargeFilePreview')(fixture.document.uri); + assert.strictEqual(fixture.host.executedCommands.at(-1).name, 'vscode.openWith'); + assert.strictEqual(fixture.host.executedCommands.at(-1).args[1], LargeFilePreviewProvider.viewType); + await fixture.receive({ type: 'ready' }); + await fixture.receive({ type: 'editCell', row: 0, col: 0, value: 'attempted mutation' }); + await fixture.receive({ type: 'next' }); + assert.deepStrictEqual(fs.readFileSync(fixture.file), before); + }); + }); + + it('keeps a readonly preview from upgrading to a full text document when CSV is enabled', async () => { + await previewFixture(async fixture => { + fixture.host.tabGroups = [{ tabs: [{ input: { uri: fixture.document.uri, viewType: LargeFilePreviewProvider.viewType } }] }]; + activate({ subscriptions: [], extensionPath: process.cwd() } as any); + fixture.host.configurationListeners[0]({ affectsConfiguration: (key: string) => key === 'csv.enabled' }); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(fixture.host.executedCommands.length, 0); + assert.ok(fixture.host.providers.includes(LargeFilePreviewProvider.viewType)); + }); + }); + + it('shows malformed records as errors without displaying partial data', async () => { + await previewFixture(async fixture => { + fs.writeFileSync(fixture.file, 'name,value\n"unfinished'); + await fixture.receive({ type: 'ready' }); + await fixture.receive({ type: 'next' }); + assert.match(fixture.messages.at(-1).error, /Unterminated quoted record/); + assert.strictEqual(fixture.messages.at(-1).html, ''); + assert.strictEqual(fixture.messages.at(-1).canNext, false); + }); + }); + + it('closes an in-flight read when its document is disposed and posts no late data', async () => { + await previewFixture(async fixture => { + const originalOpen = fs.promises.open; + let release!: () => void; + let reading!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { reading = resolve; }); + let closed = 0; + (fs.promises as any).open = async (...args: Parameters) => { + const handle = await originalOpen(...args); + return new Proxy(handle, { + get(target, key) { + if (key === 'read') { + return async (...readArgs: any[]) => { reading(); await gate; return (target.read as any)(...readArgs); }; + } + if (key === 'close') { + return async () => { closed++; await target.close(); }; + } + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + } + }); + }; + try { + const pending = fixture.receive({ type: 'ready' }); + await started; + fixture.document.dispose(); + release(); + await pending; + assert.strictEqual(closed, 1); + assert.strictEqual(fixture.messages.length, 0); + } finally { + release(); + fs.promises.open = originalOpen; + } + }); + }); + + it('renders escaped values and replaces rather than accumulating DOM pages', async () => { + await previewFixture(async fixture => { + const dom = new JSDOM(fixture.panel.webview.html, { runScripts: 'outside-only' }); + const messages: unknown[] = []; + dom.window.acquireVsCodeApi = () => ({ postMessage: (message: unknown) => messages.push(message) }); + dom.window.eval(fs.readFileSync(path.join(process.cwd(), 'media', 'large-preview.js'), 'utf8')); + const payload = ' & "quoted"'; + for (let page = 0; page < 100; page++) { + dom.window.dispatchEvent(new dom.window.MessageEvent('message', { data: { + type: 'page', html: renderPreviewRows([[payload, String(page)]], page), status: 'Read-only', canBack: true, canNext: true + } })); + } + assert.strictEqual(dom.window.document.querySelectorAll('#records tbody tr').length, 1); + assert.strictEqual(dom.window.document.querySelectorAll('#records img, #records script').length, 0); + assert.ok(dom.window.document.getElementById('records').textContent.includes(payload)); + assert.strictEqual(dom.window.document.querySelectorAll('[contenteditable]').length, 0); + dom.window.document.getElementById('next').click(); + assert.deepStrictEqual(messages.map((message: any) => message.type), ['ready', 'next']); + dom.window.close(); + }); + }); +}); From 98bda23d54b8514b4270ba8e7ad8afaf21119708 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:25:16 -0400 Subject: [PATCH 19/83] fix: reject unsupported automatic preview separator detection --- README.md | 2 +- src/large-file-preview.ts | 7 +++++-- src/test/large-file-preview.test.ts | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a76b87c..ee0683c 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ The preview reads the saved bytes in bounded chunks and displays one page at a t `csv.largeFilePreviewRows` limits a page to 1–10,000 records (default 1,000). Independent limits cap a page at 512 KiB of source records and 20,000 cells, one record at 256 KiB, and a record at 1,000 columns. Pages may contain fewer records. Oversized or malformed records produce a visible error instead of being truncated. Only the current page and at most 32 page positions are retained; Previous reaches the retained history, and Back to Start always restarts the file. If the file changes between reads, Back to Start reloads it. -This preview currently supports **valid UTF-8 and host-local `file:` URIs**. It decodes saved bytes directly in the extension host instead of requesting VS Code's decoded TextDocument. A UTF-8 BOM is accepted; other encodings must use the normal editor's encoding workflow. The extension host can be remote; arbitrary remote filesystem providers are unsupported. Separators follow the per-file override or extension/default settings; automatic content detection falls back to the extension mapping in preview mode. Normal editable CSV views retain their existing full-document behavior. +This preview currently supports **valid UTF-8 and host-local `file:` URIs**. It decodes saved bytes directly in the extension host instead of requesting VS Code's decoded TextDocument. A UTF-8 BOM is accepted; other encodings must use the normal editor's encoding workflow. The extension host can be remote; arbitrary remote filesystem providers are unsupported. Separators follow the per-file override or extension/default settings. Automatic separator detection requires an explicit override or a change to `csv.separatorMode`; otherwise the preview shows an actionable error. Normal editable CSV views retain their existing full-document behavior. ## Editing Modes and Shortcuts diff --git a/src/large-file-preview.ts b/src/large-file-preview.ts index a54fc0b..8d63c12 100644 --- a/src/large-file-preview.ts +++ b/src/large-file-preview.ts @@ -69,8 +69,11 @@ export class LargeFilePreviewProvider implements vscode.CustomReadonlyEditorProv throw new Error('CSV is disabled. Enable it before opening Large File Preview.'); } const settings = CsvEditorProvider.getSeparatorSettings(document.uri); - const delimiter = CsvEditorProvider.getSeparatorForUri(this.context, document.uri) - ?? CsvEditorProvider.resolveInheritedSeparator(document.uri.fsPath, '', settings); + const override = CsvEditorProvider.getSeparatorForUri(this.context, document.uri); + if (!override && settings.mode === 'auto') { + throw new Error('Large File Preview does not support automatic separator detection. Set a per-file separator override in the normal editor, or set csv.separatorMode to extension/default, then choose Back to Start.'); + } + const delimiter = override ?? CsvEditorProvider.resolveInheritedSeparator(document.uri.fsPath, '', settings); if (!reset && selectedDelimiter !== delimiter) { throw new Error('The separator changed. Choose Back to Start to reload the preview.'); } diff --git a/src/test/large-file-preview.test.ts b/src/test/large-file-preview.test.ts index 16a8dfe..f9b6d1b 100644 --- a/src/test/large-file-preview.test.ts +++ b/src/test/large-file-preview.test.ts @@ -77,6 +77,20 @@ describe('Read-only large file provider', () => { }); }); + it('rejects unsupported automatic separator mode explicitly rather than displaying wrong columns', async () => { + await previewFixture(async fixture => { + fixture.host.configuration.separatorMode = 'auto'; + await fixture.receive({ type: 'ready' }); + assert.match(fixture.messages.at(-1).error, /does not support automatic separator detection/); + assert.match(fixture.messages.at(-1).error, /csv.separatorMode/); + assert.strictEqual(fixture.messages.at(-1).html, ''); + fixture.host.configuration.separatorMode = 'extension'; + await fixture.receive({ type: 'start' }); + assert.strictEqual(fixture.messages.at(-1).error, ''); + assert.ok(fixture.messages.at(-1).html.includes('name')); + }); + }); + it('opens directly as a readonly custom document and never writes saved bytes', async () => { await previewFixture(async fixture => { fixture.host.registerCommands(); From c09b18c5b400ef92dc61abc6d724aab50c7a2cbc Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:25:23 -0400 Subject: [PATCH 20/83] Add a safe table and text view switch (#84) --- README.md | 2 + package.json | 15 +++ scripts/editor-switch-smoke.cjs | 50 ++++++++ src/commands.ts | 2 + src/editor-switch.ts | 63 ++++++++++ src/test/editor-switch.test.ts | 201 ++++++++++++++++++++++++++++++++ 6 files changed, 333 insertions(+) create mode 100644 scripts/editor-switch-smoke.cjs create mode 100644 src/editor-switch.ts create mode 100644 src/test/editor-switch.test.ts diff --git a/README.md b/README.md index 7dec9d6..8cca5fd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: ## Features - **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Saves on blur. +- **Table/Text Switching:** Use the switch action in the editor toolbar or `CSV: Switch Between Table and Text` in the Command Palette to move between views while retaining unsaved edits. Both representations may remain open; the action reuses the counterpart tab, with at most one of each view in the editor group. The table view requires `csv.enabled`. - **Smart Resizing:** Automatic calculation of column widths for improved readability. - **Dynamic Color Coding:** Visual cues based on data type help you quickly identify numbers, dates, booleans, and more (with opt‑in cool palette and theme-foreground mode settings). - **Sticky Headers:** Keep column titles in view as you scroll through large datasets. @@ -82,6 +83,7 @@ Cursor (built on VS Code 1.99) and the latest VS Code releases (1.102). Open the Command Palette and search for: +- `CSV: Switch Between Table and Text` (`csv.toggleEditor`) - `CSV: Toggle Extension On/Off` (`csv.toggleExtension`) - `CSV: Toggle First Row as Header` (`csv.toggleHeader`) - `CSV: Toggle Serial Index Column` (`csv.toggleSerialIndex`) diff --git a/package.json b/package.json index 31d2bda..65dd935 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "onLanguage:csv", "onLanguage:tsv", "onCommand:csv.toggleExtension", + "onCommand:csv.toggleEditor", "onCommand:csv.toggleHeader", "onCommand:csv.toggleSerialIndex", "onCommand:csv.changeSeparator", @@ -64,6 +65,11 @@ } ], "commands": [ + { + "command": "csv.toggleEditor", + "title": "CSV: Switch Between Table and Text", + "icon": "$(arrow-swap)" + }, { "command": "csv.toggleExtension", "title": "CSV: Toggle Extension On/Off" @@ -101,6 +107,15 @@ "title": "CSV: Toggle Clickable Links" } ], + "menus": { + "editor/title": [ + { + "command": "csv.toggleEditor", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || (activeEditor == workbench.editors.textEditor && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i))", + "group": "navigation" + } + ] + }, "configuration": { "type": "object", "title": "CSV Extension Configuration", diff --git a/scripts/editor-switch-smoke.cjs b/scripts/editor-switch-smoke.cjs new file mode 100644 index 0000000..fe24b44 --- /dev/null +++ b/scripts/editor-switch-smoke.cjs @@ -0,0 +1,50 @@ +// Run in an isolated VS Code development host using --extensionTestsPath. +const assert = require('assert').strict; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const vscode = require('vscode'); + +exports.run = async function () { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'csv-editor-switch-')); + const extension = vscode.extensions.getExtension('ReprEng.csv'); + assert.ok(extension, 'development extension must be loaded'); + await extension.activate(); + try { + for (const [suffix, contents] of [['csv', 'name,value\nfirst,1'], ['tsv', 'name\tvalue\nfirst\t1']]) { + const filename = path.join(directory, `data.${suffix}`); + fs.writeFileSync(filename, contents); + const uri = vscode.Uri.file(filename); + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { preview: false }); + const edit = new vscode.WorkspaceEdit(); + edit.insert(uri, new vscode.Position(1, 0), 'unsaved-'); + assert.equal(await vscode.workspace.applyEdit(edit), true); + for (let round = 0; round < 2; round++) { + for (const viewType of ['csv.editor', 'default']) { + await vscode.commands.executeCommand('csv.toggleEditor'); + const group = vscode.window.tabGroups.activeTabGroup; + const tabs = group.tabs.filter(tab => tab.input.uri?.toString() === uri.toString()); + assert.equal(tabs.length, 2, 'repeated switches must reuse the two existing views'); + assert.equal(tabs.filter(tab => tab.input instanceof vscode.TabInputCustom).length, 1); + assert.equal(tabs.filter(tab => tab.input instanceof vscode.TabInputText).length, 1); + if (viewType === 'csv.editor') { + assert.equal(group.activeTab.input.viewType, viewType); + } else { + assert.ok(group.activeTab.input instanceof vscode.TabInputText); + } + const live = vscode.workspace.textDocuments.find(document => document.uri.toString() === uri.toString()); + assert.ok(live, 'the live document must remain open'); + assert.equal(live.isDirty, true); + assert.ok(live.getText().includes('unsaved-first')); + assert.equal(fs.readFileSync(filename, 'utf8'), contents, 'switching must not save'); + } + } + await vscode.commands.executeCommand('workbench.action.files.revert'); + const tabs = vscode.window.tabGroups.all.flatMap(group => group.tabs).filter(tab => tab.input.uri?.toString() === uri.toString()); + await vscode.window.tabGroups.close(tabs); + } + console.log('CSV_SWITCH_SMOKE_OK: dirty CSV+TSV preserved, disk unchanged, exactly one tab per view across repeated switches'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}; diff --git a/src/commands.ts b/src/commands.ts index 6241b3d..db2d96a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,7 @@ import { getFonts } from 'font-list'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; +import { toggleCsvEditor } from './editor-switch'; function displaySeparator(separator: string): string { return separator === '\t' ? '\\t' : separator; @@ -21,6 +22,7 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref export function registerCsvCommands(context: vscode.ExtensionContext) { context.subscriptions.push( + vscode.commands.registerCommand('csv.toggleEditor', toggleCsvEditor), vscode.commands.registerCommand('csv.toggleExtension', () => toggleBooleanConfig('enabled', true, 'CSV extension') ), diff --git a/src/editor-switch.ts b/src/editor-switch.ts new file mode 100644 index 0000000..84ac6b7 --- /dev/null +++ b/src/editor-switch.ts @@ -0,0 +1,63 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { CsvEditorProvider } from './CsvEditorProvider'; + +const switchingTabs = new Set(); +const supportedExtensions = new Set(['.csv', '.tsv', '.tab', '.psv']); + +function getInputUri(input: unknown, viewType: string): vscode.Uri | undefined { + if (viewType === 'default') { + return input instanceof vscode.TabInputText ? input.uri : undefined; + } + return input instanceof vscode.TabInputCustom && input.viewType === viewType ? input.uri : undefined; +} + +export async function toggleCsvEditor(): Promise { + const group = vscode.window.tabGroups.activeTabGroup; + const tab = group.activeTab; + if (!tab) { + return; + } + const tableUri = getInputUri(tab.input, CsvEditorProvider.viewType); + const uri = tableUri ?? getInputUri(tab.input, 'default'); + if (!uri || !supportedExtensions.has(path.posix.extname(uri.path).toLowerCase())) { + return; + } + const source = tableUri ? CsvEditorProvider.viewType : 'default'; + const target = tableUri ? 'default' : CsvEditorProvider.viewType; + const switchKey = `${group.viewColumn}:${source}:${uri.toString()}`; + if (switchingTabs.has(switchKey)) { + return; + } + if (target === CsvEditorProvider.viewType && !vscode.workspace.getConfiguration('csv', uri).get('enabled', true)) { + vscode.window.showInformationMessage('Enable the CSV extension to switch to the table view.'); + return; + } + + switchingTabs.add(switchKey); + try { + if (tab.isPreview) { + // Keep an active table draft alive when opening its counterpart; a second + // preview could otherwise replace this tab before the draft is committed. + await vscode.commands.executeCommand('workbench.action.keepEditor'); + const current = vscode.window.tabGroups.activeTabGroup; + if (current.viewColumn !== group.viewColumn || + getInputUri(current.activeTab?.input, source)?.toString() !== uri.toString()) { + return; + } + } + // Reuse/open the other representation without closing either view. Closing + // even a shared dirty editor can prompt to save/discard its document, and a + // table may still have an active draft that has not reached the document yet. + await vscode.commands.executeCommand('vscode.openWith', uri, target, { + viewColumn: group.viewColumn, + preserveFocus: false, + preview: false + }); + } catch (error) { + console.error('CSV: could not switch editor', error); + vscode.window.showErrorMessage('CSV: Could not switch between the table and text editor.'); + } finally { + switchingTabs.delete(switchKey); + } +} diff --git a/src/test/editor-switch.test.ts b/src/test/editor-switch.test.ts new file mode 100644 index 0000000..d5a56b7 --- /dev/null +++ b/src/test/editor-switch.test.ts @@ -0,0 +1,201 @@ +import assert from 'assert'; +import fs from 'fs'; +import Module from 'module'; +import path from 'path'; +import { beforeEach, describe, it } from 'node:test'; + +class Uri { + constructor(public scheme: string, public authority: string, public path: string) {} + toString() { return `${this.scheme}://${this.authority}${this.path}`; } +} +class TabInputText { + constructor(public uri: Uri) {} +} +class TabInputCustom { + constructor(public uri: Uri, public viewType: string) {} +} +class TabInputTextDiff { + constructor(public original: Uri, public modified: Uri) {} +} +const state = { + group: { viewColumn: 2, tabs: [] as any[], activeTab: undefined as any }, + enabled: true, + events: [] as any[][], + errors: [] as string[], + information: [] as string[], + registered: new Map any>(), + open: async (_uri: Uri, _viewType: string): Promise => {} +}; +const vscode = { + Uri, TabInputText, TabInputCustom, + window: { + tabGroups: { + get activeTabGroup() { return state.group; }, + close: () => { assert.fail('switching must never close an editor or discard a pending draft'); } + }, + showErrorMessage: (message: string) => { state.errors.push(message); }, + showInformationMessage: (message: string) => { state.information.push(message); } + }, + workspace: { + getConfiguration: () => ({ get: () => state.enabled }) + }, + commands: { + executeCommand: async (command: string, uri?: Uri, viewType?: string, options?: any) => { + if (command === 'workbench.action.keepEditor') { + state.events.push([command]); + state.group.activeTab.isPreview = false; + return; + } + state.events.push([command, uri, viewType, options]); + await state.open(uri!, viewType!); + }, + registerCommand: (name: string, callback: (...args: any[]) => any) => { + state.registered.set(name, callback); + return { dispose() {} }; + } + } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { return vscode; } + if (id === 'font-list') { return { getFonts: async () => [] }; } + return originalRequire.apply(this, arguments as any); +}; +import { registerCsvCommands } from '../commands'; +Module.prototype.require = originalRequire; + +function makeTab(uri: Uri, table: boolean, preview = false) { + return { + input: table ? new TabInputCustom(uri, 'csv.editor') : new TabInputText(uri), + isPreview: preview, + isDirty: true + }; +} +function activateTab(tab: any) { + state.group.tabs = [tab]; + state.group.activeTab = tab; +} +async function toggle() { + await state.registered.get('csv.toggleEditor')!(); +} + +beforeEach(() => { + state.group = { viewColumn: 2, tabs: [], activeTab: undefined }; + state.enabled = true; + state.events = []; + state.errors = []; + state.information = []; + state.registered.clear(); + registerCsvCommands({ subscriptions: [] } as any); + state.open = async (uri, viewType) => { + const table = viewType === 'csv.editor'; + let target = state.group.tabs.find(tab => + tab.input.uri.toString() === uri.toString() && + (table ? tab.input instanceof TabInputCustom : tab.input instanceof TabInputText) + ); + if (!target) { + target = makeTab(uri, table); + state.group.tabs.push(target); + } + state.group.activeTab = target; + }; +}); + +describe('CSV table/text switch command', () => { + for (const scheme of ['file', 'vscode-remote']) { + for (const extension of ['csv', 'tsv', 'tab', 'psv', 'CSV']) { + it(`reuses ${scheme} .${extension} views in both directions without closing either`, async () => { + const uri = new Uri(scheme, scheme === 'file' ? '' : 'wsl+Ubuntu', `/data/example.${extension}`); + const original = makeTab(uri, false); + activateTab(original); + const unrelated = makeTab(new Uri('file', '', '/other.csv'), false); + state.group.tabs.push(unrelated); + await toggle(); + assert.deepStrictEqual(state.events, [['vscode.openWith', uri, 'csv.editor', { viewColumn: 2, preserveFocus: false, preview: false }]]); + assert.strictEqual(state.group.activeTab.input.uri, uri); + assert.strictEqual(state.group.activeTab.isDirty, true); + assert.strictEqual(state.group.viewColumn, 2); + assert.ok(state.group.tabs.includes(unrelated)); + await toggle(); + assert.strictEqual(state.events[1][2], 'default'); + assert.strictEqual(state.group.activeTab.input.uri, uri); + await toggle(); + await toggle(); + assert.strictEqual(state.group.tabs.filter(tab => tab.input.uri.toString() === uri.toString()).length, 2); + assert.strictEqual(state.errors.length, 0); + }); + } + } + + it('pins a preview before opening the counterpart so an active grid draft is retained', async () => { + const uri = new Uri('file', '', '/data.csv'); + const original = makeTab(uri, true, true); + activateTab(original); + await toggle(); + assert.deepStrictEqual(state.events[0], ['workbench.action.keepEditor']); + assert.strictEqual(state.events[1][0], 'vscode.openWith'); + assert.strictEqual(state.events[1][3].preview, false); + assert.ok(state.group.tabs.includes(original)); + assert.strictEqual(original.isPreview, false); + }); + + it('leaves the dirty source intact if opening the counterpart fails', async () => { + const original = makeTab(new Uri('file', '', '/data.csv'), false); + activateTab(original); + state.open = async () => { throw new Error('open failed'); }; + await toggle(); + assert.deepStrictEqual(state.group.tabs, [original]); + assert.strictEqual(original.isDirty, true); + assert.strictEqual(state.errors.length, 1); + }); + + it('coalesces duplicate clicks while opening the same counterpart', async () => { + activateTab(makeTab(new Uri('file', '', '/data.csv'), false)); + let finishOpen!: () => void; + state.open = () => new Promise(resolve => { finishOpen = resolve; }); + const pending = toggle(); + await toggle(); + finishOpen(); + await pending; + assert.strictEqual(state.events.length, 1); + }); + + it('respects disabled state when opening the table without changing configuration', async () => { + state.enabled = false; + activateTab(makeTab(new Uri('file', '', '/data.tsv'), false)); + await toggle(); + assert.strictEqual(state.events.length, 0); + assert.strictEqual(state.enabled, false); + assert.strictEqual(state.information.length, 1); + }); + + it('can leave the table for source text even if the extension has been disabled', async () => { + state.enabled = false; + activateTab(makeTab(new Uri('file', '', '/data.tsv'), true)); + await toggle(); + assert.strictEqual(state.events[0][2], 'default'); + }); + + it('ignores unrelated custom editors, diff tabs, unsupported files, and empty groups', async () => { + const uri = new Uri('file', '', '/data.csv'); + for (const input of [new TabInputCustom(uri, 'another.editor'), new TabInputTextDiff(uri, uri), new TabInputText(new Uri('file', '', '/notes.md'))]) { + activateTab({ input }); + await toggle(); + } + state.group.activeTab = undefined; + await toggle(); + assert.strictEqual(state.events.length, 0); + }); + + it('contributes its command, activation, toolbar action, and documentation together', () => { + const root = path.join(__dirname, '..', '..'); + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const command = manifest.contributes.commands.find((entry: any) => entry.command === 'csv.toggleEditor'); + assert.strictEqual(command.title, 'CSV: Switch Between Table and Text'); + assert.ok(manifest.activationEvents.includes('onCommand:csv.toggleEditor')); + const action = manifest.contributes.menus['editor/title'].find((entry: any) => entry.command === 'csv.toggleEditor'); + assert.strictEqual(action.group, 'navigation'); + assert.ok(action.when.includes('!isInDiffEditor')); + assert.ok(fs.readFileSync(path.join(root, 'README.md'), 'utf8').includes('`csv.toggleEditor`')); + }); +}); From c19fb7b3cdd1b925d6e8237eada4b3a5658ebfea Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:30:22 -0400 Subject: [PATCH 21/83] 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 22/83] 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 {
- +