From 7ae53cdf102cce3f2280387148455edb63698a98 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:04:10 -0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 16531e577db57db8ad30bfd33563981d21c50980 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 01:16:14 -0400 Subject: [PATCH 4/5] Keep ordinary copied cell text unquoted --- README.md | 2 +- src/clipboard.ts | 14 ++++++-- src/test/clipboard.test.ts | 54 +++++++++++++++++++++++++++++ src/test/provider-clipboard.test.ts | 13 +++++++ src/test/webview-clipboard.test.ts | 15 ++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9dc6cd7..93cf1ab 100644 --- a/README.md +++ b/README.md @@ -19,7 +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. +- **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. Ordinary cell text is copied without surrounding quotes. CSV escaping still preserves separators, line breaks, literal quotes, surrounding whitespace, and empty cells. 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/src/clipboard.ts b/src/clipboard.ts index 685fdec..90a1c21 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -1,8 +1,16 @@ import Papa from 'papaparse'; -// Quote every field so even a single empty cell and trailing empty rows survive. +const clipboardDelimiterCandidates = ['\t', ',', ';', '|']; + export function serializeClipboardMatrix(matrix: string[][], delimiter: string): string { - return Papa.unparse(matrix, { delimiter, quotes: true, newline: '\r\n' }); + const escapedCharacters = [delimiter, ...clipboardDelimiterCandidates, '"', '\r', '\n', '\uFEFF']; + // Keep ordinary text plain. Complex ranges retain consistent CSV quoting so + // paste auto-detection preserves their cell boundaries and explicit blanks. + const quoteAll = matrix.some(row => row.some(value => + value === '' || value.startsWith(' ') || value.endsWith(' ') || + escapedCharacters.some(character => value.includes(character)) + )); + return Papa.unparse(matrix, { delimiter, quotes: quoteAll, newline: '\r\n' }); } export function parseClipboardMatrix(text: string, fallbackDelimiter: string): string[][] { @@ -12,7 +20,7 @@ export function parseClipboardMatrix(text: string, fallbackDelimiter: string): s const parsed = Papa.parse(text, { dynamicTyping: false, delimiter: '', - delimitersToGuess: [fallbackDelimiter, '\t', ',', ';', '|'].filter((value, index, all) => all.indexOf(value) === index) + delimitersToGuess: [fallbackDelimiter, ...clipboardDelimiterCandidates].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. diff --git a/src/test/clipboard.test.ts b/src/test/clipboard.test.ts index 47b0a39..a3bc022 100644 --- a/src/test/clipboard.test.ts +++ b/src/test/clipboard.test.ts @@ -5,6 +5,60 @@ import { parseClipboardMatrix, serializeClipboardMatrix } from '../clipboard'; // Independent expectations exercise the same codec used by the extension host. describe('Clipboard matrix fidelity', () => { + it('copies ordinary single-cell strings as plain text for Search and other text inputs', () => { + for (const value of ['hello', 'hello world', 'SKU-123', '00123', 'https://example.test/path?query=one&other=two']) { + assert.strictEqual(serializeClipboardMatrix([[value]], ','), value); + assert.strictEqual(serializeClipboardMatrix([[value]], '\t'), value); + } + }); + + it('does not add quotes to ordinary fields in a copied range', () => { + const matrix = [['name', 'value'], ['alpha', '42']]; + assert.strictEqual(serializeClipboardMatrix(matrix, ','), 'name,value\r\nalpha,42'); + assert.strictEqual(serializeClipboardMatrix(matrix, '\t'), 'name\tvalue\r\nalpha\t42'); + }); + + it('retains consistent escaping for complex ranges', () => { + assert.strictEqual( + serializeClipboardMatrix([['hello', 'with,comma', 'say "hi"', 'line\nvalue']], ','), + '"hello","with,comma","say ""hi""","line\nvalue"' + ); + }); + + it('keeps alternate separators inside one copied cell during automatic paste detection', () => { + for (const delimiter of [',', '\t', '|', ';', '^']) { + for (const value of ['with,comma', 'with\ttab', 'with;semicolon', 'with|pipe']) { + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix([[value]], delimiter), delimiter), [[value]]); + } + } + }); + + it('does not guess separators from inside later quoted fields in a copied range', () => { + for (const delimiter of [',', '\t', '|', ';', '^']) { + for (const value of ['a,b,c', 'a\tb\tc', 'a;;b', 'a||b']) { + const matrix = [['hello', value], ['ordinary text', value]]; + const copied = serializeClipboardMatrix(matrix, delimiter); + assert.deepStrictEqual(Papa.parse(copied, { delimiter }).data, matrix); + assert.deepStrictEqual(parseClipboardMatrix(copied, delimiter), matrix); + } + } + }); + + it('preserves mixed punctuation when copying between CSV and TSV documents', () => { + const matrix = [['a"b', 'a^b'], ['trailing ', ',;|\t']]; + for (const sourceDelimiter of [',', '\t']) { + const copied = serializeClipboardMatrix(matrix, sourceDelimiter); + for (const targetDelimiter of [',', '\t']) { + assert.deepStrictEqual(parseClipboardMatrix(copied, targetDelimiter), matrix); + } + } + }); + + it('preserves blank records at the end of a single copied column', () => { + const matrix = [['hello'], [''], ['']]; + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix(matrix, ','), ','), matrix); + }); + for (const delimiter of [',', '\t', '|', ';', '^']) { it(`preserves cell and record boundaries with delimiter ${JSON.stringify(delimiter)}`, () => { const matrix = [ diff --git a/src/test/provider-clipboard.test.ts b/src/test/provider-clipboard.test.ts index 79acc37..3cbaf8b 100644 --- a/src/test/provider-clipboard.test.ts +++ b/src/test/provider-clipboard.test.ts @@ -61,6 +61,19 @@ async function createEditor(initial: string, extension = 'csv') { } describe('Provider system clipboard integration', () => { + it('writes an ordinary cell without CSV wrapper quotes and pastes it without changing source quoting', async () => { + for (const extension of ['csv', 'tsv']) { + const delimiter = extension === 'csv' ? ',' : '\t'; + const source = `name${delimiter}value\nhello world${delimiter}before`; + const editor = await createEditor(source, extension); + await editor.receive({ type: 'copyCells', matrix: [['hello world']], rowIds: [1], startRow: 1, startCol: 0, cut: false }); + assert.deepStrictEqual(editor.clipboardWrites, ['hello world']); + assert.strictEqual(editor.text, source); + await editor.receive({ type: 'pasteFromClipboard', anchorRow: 1, anchorCol: 1 }); + assert.strictEqual(editor.text, `name${delimiter}value\nhello world${delimiter}hello world`); + } + }); + 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'; diff --git a/src/test/webview-clipboard.test.ts b/src/test/webview-clipboard.test.ts index a81f101..2bf95e7 100644 --- a/src/test/webview-clipboard.test.ts +++ b/src/test/webview-clipboard.test.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; import vm from 'vm'; import { describe, it } from 'node:test'; +import { serializeClipboardMatrix } from '../clipboard'; type Listener = { callback: (event: any) => void; capture: boolean }; @@ -142,6 +143,20 @@ function createWebview() { } describe('Shipped webview clipboard interactions', () => { + it('exports plain single-cell text for native paste into Search', () => { + const view = createWebview(); + const cell = view.cell(1, 0, 'hello world'); + view.select(1, 0); + view.dispatch(cell, 'copy'); + const clipboardText = serializeClipboardMatrix(view.messages[0].matrix, ','); + assert.strictEqual(clipboardText, 'hello world'); + const input = view.byId('findInput'); + input.focus(); + const event = view.dispatch(input, 'paste', { clipboardData: { getData: () => clipboardText } }); + assert.strictEqual(event.defaultPrevented, false, 'Search must retain native text paste'); + assert.strictEqual(view.messages.length, 1, 'Search paste must not become a grid paste'); + }); + it('copies headers and multiline values without serial indices or trimmed blanks', () => { const view = createWebview(); view.cell(0, -1, '#', true); From 3687c06bb409d699f49d219f2c46520c49cec493 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 14:43:16 -0400 Subject: [PATCH 5/5] Fix per-cell clipboard quotes and omit the append row --- README.md | 2 +- media/main.js | 6 +- src/CsvEditorProvider.ts | 8 +- src/clipboard.ts | 71 ++++++++++++-- src/test/clipboard-column.test.ts | 142 ++++++++++++++++++++++++++++ src/test/clipboard.test.ts | 29 +++++- src/test/provider-clipboard.test.ts | 9 ++ src/test/webview-clipboard.test.ts | 1 + 8 files changed, 252 insertions(+), 16 deletions(-) create mode 100644 src/test/clipboard-column.test.ts diff --git a/README.md b/README.md index 93cf1ab..b930b51 100644 --- a/README.md +++ b/README.md @@ -19,7 +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. Ordinary cell text is copied without surrounding quotes. CSV escaping still preserves separators, line breaks, literal quotes, surrounding whitespace, and empty cells. Clipboard shortcuts in Find, Replace, and cell text edits keep their normal text-editing behavior. +- **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. Ordinary cell text is copied without surrounding quotes, even beside values that need escaping. The extra append row is never copied or cut; real empty cells are preserved. CSV escaping still preserves separators, line breaks, literal quotes, surrounding whitespace, and empty cells. 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 8b4678a..5f0477a 100644 --- a/media/main.js +++ b/media/main.js @@ -2047,8 +2047,12 @@ const copySelectionToClipboard = (cut = false) => { const bounds = getDataSelectionBounds(); if (!bounds) return false; const { minRow, maxRow, minCol, maxCol } = bounds; + const realRowCount = Number(root.dataset.realrowcount); + // The append row is an editor affordance, never clipboard data. Consume an + // append-only gesture without a host write or native empty clipboard copy. + if (!Number.isSafeInteger(realRowCount) || realRowCount < 0 || minRow >= realRowCount) { return true; } const matrix = []; - for (let row = minRow; row <= maxRow; row++) { + for (let row = minRow; row <= Math.min(maxRow, realRowCount - 1); row++) { const values = []; for (let col = minCol; col <= maxCol; col++) { const cell = getRenderedCellByCoords(row, col); diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 6e0aab0..b056d36 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -1452,7 +1452,8 @@ class CsvEditorController { nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, - mouseWheelZoomInvert + mouseWheelZoomInvert, + realRowCount: data.length }); } @@ -1705,8 +1706,9 @@ class CsvEditorController { hasRemoteChunks: boolean; mouseWheelZoomEnabled: boolean; mouseWheelZoomInvert: boolean; + realRowCount: number; }): 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, 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( @@ -1918,7 +1920,7 @@ class CsvEditorController { -
+
${tableHtml}
diff --git a/src/clipboard.ts b/src/clipboard.ts index 90a1c21..3c934ae 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -4,23 +4,76 @@ const clipboardDelimiterCandidates = ['\t', ',', ';', '|']; export function serializeClipboardMatrix(matrix: string[][], delimiter: string): string { const escapedCharacters = [delimiter, ...clipboardDelimiterCandidates, '"', '\r', '\n', '\uFEFF']; - // Keep ordinary text plain. Complex ranges retain consistent CSV quoting so - // paste auto-detection preserves their cell boundaries and explicit blanks. - const quoteAll = matrix.some(row => row.some(value => - value === '' || value.startsWith(' ') || value.endsWith(' ') || - escapedCharacters.some(character => value.includes(character)) - )); - return Papa.unparse(matrix, { delimiter, quotes: quoteAll, newline: '\r\n' }); + // Quote each value independently: a complex neighbor must not wrap plain text. + return Papa.unparse(matrix, { + delimiter, + quotes: value => value === '' || value.startsWith(' ') || value.endsWith(' ') || + escapedCharacters.some(character => value.includes(character)), + newline: '\r\n' + }); +} + +// Papa's candidate parsers can mistake separators inside a later quoted field +// for delimiters when the first field is unquoted. Remove candidates that occur +// only inside complete quoted fields, then leave parsing/scoring to Papa. +function clipboardDelimitersOutsideQuotes(text: string, candidates: string[]): string[] { + const tokens = [...candidates].sort((a, b) => b.length - a.length); + const found = new Set(); + let quoted = new Set(); + let inQuotes = false; + let fieldStart = true; + const at = (index: number) => tokens.find(token => text.startsWith(token, index)); + for (let i = 0; i < text.length;) { + const character = text[i]; + if (inQuotes) { + if (character === '"') { + if (text[i + 1] === '"') { i += 2; continue; } + const next = text[i + 1]; + if (next !== undefined && next !== '\r' && next !== '\n' && !at(i + 1)) { + // An incomplete/malformed quoted prefix is ordinary external text. + for (const token of quoted) { found.add(token); } + } + inQuotes = false; + quoted = new Set(); + fieldStart = false; + i++; + } else { + const token = at(i); + if (token) { quoted.add(token); i += token.length; } + else { i++; } + } + continue; + } + if (character === '"' && fieldStart) { + inQuotes = true; + i++; + continue; + } + const token = at(i); + if (token) { + found.add(token); + fieldStart = true; + i += token.length; + } else { + fieldStart = character === '\r' || character === '\n'; + i++; + } + } + // Do not hide genuine separators behind an unterminated literal quote. + if (inQuotes) { for (const token of quoted) { found.add(token); } } + return candidates.filter(candidate => found.has(candidate)); } export function parseClipboardMatrix(text: string, fallbackDelimiter: string): string[][] { if (!text) { return []; } + const candidates = [fallbackDelimiter, ...clipboardDelimiterCandidates].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index); + const available = clipboardDelimitersOutsideQuotes(text, candidates); const parsed = Papa.parse(text, { dynamicTyping: false, - delimiter: '', - delimitersToGuess: [fallbackDelimiter, ...clipboardDelimiterCandidates].filter((value, index, all) => all.indexOf(value) === index) + delimiter: available.length > 1 ? '' : available[0] || fallbackDelimiter, + delimitersToGuess: available }); const matrix = parsed.data.map(row => row.map(value => String(value ?? ''))); // A terminal record separator creates one parser sentinel, not an extra record. diff --git a/src/test/clipboard-column.test.ts b/src/test/clipboard-column.test.ts new file mode 100644 index 0000000..22c8503 --- /dev/null +++ b/src/test/clipboard-column.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +import { parseClipboardMatrix, serializeClipboardMatrix } from '../clipboard'; + +const select = (view: ReturnType, start: any, end = start) => { + start.dispatchEvent(new view.window.MouseEvent('mousedown', { bubbles: true, button: 0, clientX: 100, clientY: 50 })); + if (end !== start) { end.dispatchEvent(new view.window.MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 50 })); } + end.dispatchEvent(new view.window.MouseEvent('mouseup', { bubbles: true, button: 0 })); +}; +const copy = (view: ReturnType, target: any, route: string) => { + if (route === 'copy' || route === 'cut') { + const event = new view.window.Event(route, { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; + } + if (route === 'keyboard-copy' || route === 'keyboard-cut') { + const event = new view.window.KeyboardEvent('keydown', { key: route === 'keyboard-copy' ? 'c' : 'x', metaKey: true, bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; + } + target.dispatchEvent(new view.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 50 })); + const label = route === 'context-copy' ? 'Copy' : 'Cut'; + const item = Array.from(view.window.document.querySelectorAll('#contextMenu div')).find((element: any) => element.textContent === label) as any; + assert.ok(item); + item.click(); +}; + +describe('Whole-column clipboard and the append row', () => { + it('copies ordinary header columns without quotes or the virtual append cell', async () => { + for (const separator of [',', '\t']) { + const host = await createEditorHarness({ rows: [['Name'], ['alpha'], ['beta']], separator }); + const view = createWebviewDom(host.panel.webview.html); + try { + const header = view.cell(0, 0); + select(view, header); + copy(view, header, 'copy'); + const message = view.messages.find(item => item.type === 'copyCells'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(message.matrix)), [['Name'], ['alpha'], ['beta']]); + assert.strictEqual(serializeClipboardMatrix(message.matrix, separator), 'Name\r\nalpha\r\nbeta'); + assert.strictEqual(host.document.getText(), host.diskText); + } finally { view.close(); } + } + }); + + it('quotes only the real complex/empty values in a selected column and preserves every real source row', async () => { + for (const separator of [',', '\t']) { + const host = await createEditorHarness({ rows: [['Name', 'Other'], ['plain', 'value'], ['', 'keep'], ['with,comma', 'value'], ['last', 'value']], separator }); + const view = createWebviewDom(host.panel.webview.html); + try { + const header = view.cell(0, 0); + select(view, header); + copy(view, header, 'keyboard-copy'); + const matrix = view.messages.find(item => item.type === 'copyCells').matrix; + assert.strictEqual(serializeClipboardMatrix(matrix, separator), 'Name\r\nplain\r\n""\r\n"with,comma"\r\nlast'); + assert.deepStrictEqual(parseClipboardMatrix(serializeClipboardMatrix(matrix, separator), separator), [['Name'], ['plain'], [''], ['with,comma'], ['last']]); + assert.strictEqual(view.window.document.querySelector('#csv-root').dataset.realrowcount, '5'); + } finally { view.close(); } + } + }); + + it('excludes ghost rows from range and Select All Copy/Cut routes, and consumes ghost-only copies without touching clipboard', async () => { + for (const route of ['copy', 'cut', 'keyboard-copy', 'keyboard-cut', 'context-copy', 'context-cut']) { + for (const selection of ['range', 'all', 'ghost']) { + const host = await createEditorHarness({ rows: [['Name', 'Other'], ['', ''], ['last', 'value']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + const ghost = view.cell(3, 0); + assert.ok(ghost); + if (selection === 'all') { + view.window.editingTestApi.setSingleSelection(view.cell(1, 0)); + view.key('a', { metaKey: true }); + } else if (selection === 'range') { + select(view, view.cell(1, 0), ghost); + } else { + view.window.editingTestApi.setSingleSelection(ghost); + } + const event = copy(view, ghost, route); + const messages = view.messages.filter(item => item.type === 'copyCells'); + if (selection === 'ghost') { + assert.strictEqual(messages.length, 0, `${route}: do not send a clipboard write for append-row-only selection`); + if (event) { assert.strictEqual(event.defaultPrevented, true, 'native empty copy must not overwrite the existing system clipboard'); } + } else { + assert.strictEqual(messages.length, 1); + const expected = selection === 'all' ? [['Name', 'Other'], ['', ''], ['last', 'value']] : [[''], ['last']]; + assert.deepStrictEqual(JSON.parse(JSON.stringify(messages[0].matrix)), expected, route); + assert.strictEqual(messages[0].cut, route.includes('cut')); + } + assert.strictEqual(host.document.getText(), host.diskText); + } finally { view.close(); } + } + } + }); + it('uses the full source count instead of treating the last loaded chunk row as a ghost', async () => { + const rows = [['Name'], ...Array.from({ length: 1002 }, (_value, index) => [`row-${index}`])]; + const host = await createEditorHarness({ rows, remote: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(view.window.document.querySelector('#csv-root').dataset.realrowcount, '1003'); + const header = view.cell(0, 0); + select(view, header); + copy(view, header, 'copy'); + const first = view.messages.filter(item => item.type === 'copyCells').slice(-1)[0]; + assert.strictEqual(first.matrix.length, 1001); + assert.strictEqual(first.matrix[1000][0], 'row-999', 'the last loaded real source row is retained'); + let cursor = view.messages.length; + view.window.__csvLoadNextChunk(); + for (let guard = 0; guard < 5 && !view.cell(1003, 0); guard++) { + const requests = view.messages.slice(cursor).filter(item => item.type === 'requestChunk'); + cursor = view.messages.length; + for (const request of requests) { + await host.receive(request); + const response = host.messages.filter(item => item.type === 'chunkData' && item.requestId === request.requestId).slice(-1)[0]; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: response })); + view.window.__csvLoadNextChunk(); + } + } + assert.ok(view.cell(1003, 0), 'the remote append row is eventually rendered'); + view.window.editingTestApi.setSingleSelection(view.cell(1, 0)); + select(view, header); + copy(view, header, 'copy'); + const complete = view.messages.filter(item => item.type === 'copyCells').slice(-1)[0]; + assert.strictEqual(complete.matrix.length, 1003); + assert.strictEqual(complete.matrix[1002][0], 'row-1001'); + } finally { view.close(); } + }); + + it('does not copy the only ghost cell in an empty file', async () => { + const host = await createEditorHarness({ rows: [] }); + const view = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(view.window.document.querySelector('#csv-root').dataset.realrowcount, '0'); + const ghost = view.cell(0, 0); + view.window.editingTestApi.setSingleSelection(ghost); + const event = copy(view, ghost, 'cut'); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(view.messages.some(item => item.type === 'copyCells'), false); + assert.strictEqual(host.document.getText(), ''); + } finally { view.close(); } + }); + +}); diff --git a/src/test/clipboard.test.ts b/src/test/clipboard.test.ts index a3bc022..8d2eb36 100644 --- a/src/test/clipboard.test.ts +++ b/src/test/clipboard.test.ts @@ -18,10 +18,10 @@ describe('Clipboard matrix fidelity', () => { assert.strictEqual(serializeClipboardMatrix(matrix, '\t'), 'name\tvalue\r\nalpha\t42'); }); - it('retains consistent escaping for complex ranges', () => { + it('quotes complex fields without wrapping their ordinary neighbors', () => { assert.strictEqual( serializeClipboardMatrix([['hello', 'with,comma', 'say "hi"', 'line\nvalue']], ','), - '"hello","with,comma","say ""hi""","line\nvalue"' + 'hello,"with,comma","say ""hi""","line\nvalue"' ); }); @@ -96,4 +96,29 @@ describe('Clipboard matrix fidelity', () => { assert.deepStrictEqual(parseClipboardMatrix('"\n"', ','), [['\n']]); assert.deepStrictEqual(parseClipboardMatrix('', ','), []); }); + it('keeps external literal quotes and quoted later fields out of delimiter guesses', () => { + assert.deepStrictEqual(parseClipboardMatrix('left"quote\tright\nother\tvalue', ','), [['left"quote', 'right'], ['other', 'value']]); + assert.deepStrictEqual(parseClipboardMatrix('plain\t"a,b,c"\nother\t"d,e,f"', ','), [['plain', 'a,b,c'], ['other', 'd,e,f']]); + assert.deepStrictEqual(parseClipboardMatrix('plain||"a,b,c"\nother||""', '||'), [['plain', 'a,b,c'], ['other', '']]); + }); + + it('round-trips diverse rectangular matrices across common delimiters and within custom formats', () => { + const values = ['plain', '42', '', 'a,b', 'a\tb', 'a;b', 'a|b', 'say "hi"', 'a\nb', 'a\r\nb', ' edge ', '\uFEFFvalue', 'a^b', 'a::b', 'a||b']; + const common = [',', '\t', ';', '|']; + let seed = 1729; + const next = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed; }; + for (let sample = 0; sample < 100; sample++) { + const height = 1 + next() % 4; + const width = 1 + next() % 4; + const matrix = Array.from({ length: height }, () => Array.from({ length: width }, () => values[next() % values.length])); + for (const delimiter of [...common, '^', '||', '::']) { + const text = serializeClipboardMatrix(matrix, delimiter); + assert.deepStrictEqual(Papa.parse(text, { delimiter }).data, matrix); + for (const target of common.includes(delimiter) ? common : [delimiter]) { + assert.deepStrictEqual(parseClipboardMatrix(text, target), matrix, `source ${JSON.stringify(delimiter)}, target ${JSON.stringify(target)}, sample ${sample}`); + } + } + } + }); + }); diff --git a/src/test/provider-clipboard.test.ts b/src/test/provider-clipboard.test.ts index 3cbaf8b..0246c5a 100644 --- a/src/test/provider-clipboard.test.ts +++ b/src/test/provider-clipboard.test.ts @@ -74,6 +74,15 @@ describe('Provider system clipboard integration', () => { } }); + it('writes a mixed column with plain neighbors unquoted and real blanks explicit', async () => { + for (const extension of ['csv', 'tsv']) { + const editor = await createEditor('Name,Other\nplain,1\n"with,comma",2\nlast,3', extension); + await editor.receive({ type: 'copyCells', matrix: [['Name'], ['plain'], ['with,comma'], [''], ['last']], startRow: 0, startCol: 0, cut: false }); + assert.deepStrictEqual(editor.clipboardWrites, ['Name\r\nplain\r\n"with,comma"\r\n""\r\nlast']); + assert.strictEqual(editor.events.includes('edit'), false); + } + }); + 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'; diff --git a/src/test/webview-clipboard.test.ts b/src/test/webview-clipboard.test.ts index 2bf95e7..96eeb09 100644 --- a/src/test/webview-clipboard.test.ts +++ b/src/test/webview-clipboard.test.ts @@ -89,6 +89,7 @@ function createWebview() { }; document.getElementById = byId; const root = byId('csv-root'); + root.dataset.realrowcount = '2'; const table = new Element('TABLE'); const head = new Element('THEAD'); table.appendChild(head);