Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 0 additions & 1 deletion REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
13 changes: 7 additions & 6 deletions media/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down
26 changes: 24 additions & 2 deletions src/CsvEditorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions src/test/provider-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([], ','), '');
});
});
5 changes: 5 additions & 0 deletions src/test/webview-edit-shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'));
});
});