From 2befedc8777b13c3725d97ac731785ee468b2def Mon Sep 17 00:00:00 2001 From: Rick Byers Date: Thu, 3 Sep 2026 20:59:10 -0400 Subject: [PATCH] fix: quote copied cells so values with delimiters survive paste Copying a cell containing the file separator (e.g. `a,b` in a .csv) and pasting it split the value across cells, because the webview joined raw cell text with the separator and never quoted it, while paste parses with RFC 4180 quoting. The webview now sends the selected cells as a matrix and the host serializes it with Papa.unparse using the active delimiter, mirroring how paste already parses on the host. This also preserves trailing empty cells on TSV copies (previously eaten by trimEnd) and uses textContent for consistency with the edit path. Adds round-trip tests for the copy serializer and a webview contract test. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011mdKM44h3kz5FrmrxZT2YN --- README.md | 2 +- REVIEW.md | 1 - media/main.js | 13 ++++++------ src/CsvEditorProvider.ts | 26 +++++++++++++++++++++-- src/test/provider-utils.test.ts | 28 +++++++++++++++++++++++++ src/test/webview-edit-shortcuts.test.ts | 5 +++++ 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7dec9d6..eccd8bd 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Per-file (stored by the extension; set via commands): - Selection improvements: Shift+Click on headers selects column ranges; Shift+Click on the serial index selects row ranges; right‑click preserves current selection. - Batch actions: Context menu adapts to multi‑selection (Add/Delete X Rows/Columns) and performs exact counts in a single operation. - Delete to clear: Press Delete/Backspace to clear contents of selected cells (skips serial index column). -- Copy fidelity: Copies with the active delimiter and skips the serial index column for full‑row copies. +- Copy fidelity: Copies with the active delimiter, quotes values containing the delimiter, quotes, or newlines, and skips the serial index column for full‑row copies. - Encoding: New command “CSV: Change File Encoding” integrates VS Code’s encoding picker and returns to the CSV view. - Enable/disable UX: Toggling the extension on instantly upgrades open CSV/TSV tabs to this view; toggling off reverts immediately to the default view. diff --git a/REVIEW.md b/REVIEW.md index 0589628..909993e 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -12,7 +12,6 @@ P0 (Critical) P1 (High) - State persistence: Ensure scroll + selection restore across config changes and chunk loads; add targeted tests (including very large files and header on/off). - Selection semantics: Preserve selection on right‑click; Shift+Click ranges on headers and serial index; add tests for row/column/rectangular cases. -- Copy fidelity: Confirm delimiter, quoting, and skipped serial index column for whole‑row copies; add tests. P2 (Medium) - CSP tightening: Replace `style-src 'unsafe-inline'` with nonce‑only styles (we already nonce the tag; remove the policy’s unsafe‑inline if feasible without regressions). diff --git a/media/main.js b/media/main.js index 58079a4..5052803 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; @@ -2027,17 +2026,19 @@ const copySelectionToClipboard = () => { 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 = ''; + // Send raw cell values; the host serializes them with the active delimiter + // and quotes values containing the delimiter, quotes, or newlines. + const cells = []; for(let r = minRow; r <= maxRow; r++){ - let rowVals = []; + const 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 : ''); + rowVals.push(cell ? (cell.textContent || '') : ''); } - csv += rowVals.join(CSV_SEPARATOR) + '\n'; + cells.push(rowVals); } - vscode.postMessage({ type: 'copyToClipboard', text: csv.trimEnd() }); + vscode.postMessage({ type: 'copyToClipboard', cells }); }; window.addEventListener('message', event => { diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 9135ca8..aaf7c64 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -141,10 +141,15 @@ class CsvEditorController { case 'save': await this.handleSave(); break; - case 'copyToClipboard': - await vscode.env.clipboard.writeText(e.text); + case 'copyToClipboard': { + if (!Array.isArray(e.cells)) { + break; + } + const text = CsvEditorController.serializeClipboardMatrix(e.cells, this.getSeparator()); + await vscode.env.clipboard.writeText(text); console.log('CSV: Copied to clipboard'); break; + } case 'insertColumn': await this.insertColumn(e.index); break; @@ -528,6 +533,16 @@ class CsvEditorController { } } + private static serializeClipboardMatrix(matrix: string[][], delimiter: string): string { + if (!Array.isArray(matrix) || matrix.length === 0) { + return ''; + } + const rows = matrix.map(row => (Array.isArray(row) ? row : [row]).map(cell => String(cell ?? ''))); + // Papa quotes any field containing the delimiter, a quote, or a newline, so + // copied values survive a round trip through parseClipboardMatrix. + return Papa.unparse(rows, { delimiter, newline: '\n' }); + } + private parseClipboardMatrix(text: string): string[][] { if (!text || text.length === 0) { return []; @@ -2773,6 +2788,13 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { ): string | undefined { return CsvEditorProvider.applyFieldUpdatesPreservingFormat(text, delimiter, updates); }, + serializeClipboardMatrix(matrix: string[][], delimiter: string): string { + return (CsvEditorController as any).serializeClipboardMatrix(matrix, delimiter); + }, + parseClipboardMatrix(text: string): string[][] { + const c: any = new (CsvEditorController as any)({} as any); + return c.parseClipboardMatrix(text); + }, computePastePlan( matrix: string[][], anchorRow: number, diff --git a/src/test/provider-utils.test.ts b/src/test/provider-utils.test.ts index d968cc1..c5b343c 100644 --- a/src/test/provider-utils.test.ts +++ b/src/test/provider-utils.test.ts @@ -274,4 +274,32 @@ describe('CsvEditorProvider utility methods', () => { assert.strictEqual(meta.nextChunkStart, 80); assert.strictEqual(meta.hasRemoteChunks, true); }); + + it('quotes copied cells containing the delimiter so they survive a paste round trip', () => { + const { serializeClipboardMatrix, parseClipboardMatrix } = CsvEditorProvider.__test; + const cases: string[][][] = [ + [['a,b']], + [['say "hi"']], + [['line1\nline2']], + [['a,b', 'c'], ['d', 'e']] + ]; + for (const matrix of cases) { + const text = serializeClipboardMatrix(matrix, ','); + assert.deepStrictEqual(parseClipboardMatrix(text), matrix, `round trip failed for ${JSON.stringify(matrix)}`); + } + }); + + it('serializes copied cells with the active delimiter and only quotes when needed', () => { + const { serializeClipboardMatrix } = CsvEditorProvider.__test; + assert.strictEqual(serializeClipboardMatrix([['x\ty', 'z']], '\t'), '"x\ty"\tz'); + assert.strictEqual(serializeClipboardMatrix([['a,b']], '\t'), 'a,b'); + assert.strictEqual(serializeClipboardMatrix([['a', 'b'], ['c', 'd']], ','), 'a,b\nc,d'); + }); + + it('preserves trailing empty cells and omits a trailing newline when copying', () => { + const { serializeClipboardMatrix } = CsvEditorProvider.__test; + assert.strictEqual(serializeClipboardMatrix([['a', '']], '\t'), 'a\t'); + assert.strictEqual(serializeClipboardMatrix([['a'], ['']], ','), 'a\n'); + assert.strictEqual(serializeClipboardMatrix([], ','), ''); + }); }); diff --git a/src/test/webview-edit-shortcuts.test.ts b/src/test/webview-edit-shortcuts.test.ts index 750f88e..9f69cee 100644 --- a/src/test/webview-edit-shortcuts.test.ts +++ b/src/test/webview-edit-shortcuts.test.ts @@ -55,4 +55,9 @@ describe('Webview edit shortcuts', () => { assert.ok(webviewSource.includes("} else if (message.type === 'pasteApplied') {")); assert.ok(webviewSource.includes("selectRange({ row: startRow, col: startCol }, { row: endRow, col: endCol });")); }); + + it('sends the copied selection as a cell matrix so the host can quote it', () => { + assert.ok(webviewSource.includes("vscode.postMessage({ type: 'copyToClipboard', cells });")); + assert.ok(!webviewSource.includes('rowVals.join(CSV_SEPARATOR)')); + }); });