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)')); + }); });