Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.
Expand Down
80 changes: 54 additions & 26 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 @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2013,27 +2043,25 @@ 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 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 <= Math.min(maxRow, realRowCount - 1); 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 => {
Expand Down
65 changes: 34 additions & 31 deletions src/CsvEditorProvider.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -147,9 +148,11 @@
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);
Expand Down Expand Up @@ -329,7 +332,7 @@
}

private forceReload() {
if (!this.currentWebviewPanel) return;

Check warning on line 335 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
const panel = this.currentWebviewPanel;
// First, blank the DOM to ensure a full script/style reinit on next set
panel.webview.html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body></body></html>';
Expand Down Expand Up @@ -551,34 +554,32 @@
}
}

private parseClipboardMatrix(text: string): string[][] {
if (!text || text.length === 0) {
return [];
private async copyCells(rawMatrix: unknown, rawStartRow: unknown, rawStartCol: unknown, cut: boolean): Promise<void> {
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 {
Expand Down Expand Up @@ -721,7 +722,7 @@
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;
}
Expand Down Expand Up @@ -849,7 +850,7 @@
}

private buildFindRegex(query: string, options: { regex: boolean; wholeWord: boolean; matchCase: boolean }): RegExp | undefined {
if (!query) return undefined;

Check warning on line 853 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
const useRegex = !!options.regex;
const wholeWord = !!options.wholeWord;
const matchCase = !!options.matchCase;
Expand Down Expand Up @@ -941,17 +942,17 @@
}
data[row][col] = '';
} else {
while (data.length <= row) data.push([]);

Check warning on line 945 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
while (data[row].length <= col) data[row].push('');

Check warning on line 946 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
data[row][col] = value;
}

let trimmed = false;
if (wasEditingLastRow) {
const isRowEmpty = (arr: string[] | undefined) => {
if (!arr || arr.length === 0) return true;

Check warning on line 953 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
for (let i = 0; i < arr.length; i++) {
if ((arr[i] ?? '') !== '') return false;

Check warning on line 955 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
}
return true;
};
Expand Down Expand Up @@ -989,7 +990,7 @@
const data = result.data as string[][];
for (const row of data) {
if (index > row.length) {
while (row.length < index) row.push('');

Check warning on line 993 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
}
row.splice(index, 0, '');
}
Expand All @@ -1007,7 +1008,7 @@
}

private async insertColumns(index: number, count: number) {
if (count <= 0) return;

Check warning on line 1011 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
this.isUpdatingDocument = true;
const separator = this.getSeparator();
const text = this.document.getText();
Expand All @@ -1016,7 +1017,7 @@
for (let k = 0; k < count; k++) {
for (const row of data) {
if (index > row.length) {
while (row.length < index) row.push('');

Check warning on line 1020 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'while' condition
}
row.splice(index, 0, '');
}
Expand Down Expand Up @@ -1059,7 +1060,7 @@
}

private async deleteColumns(indices: number[]) {
if (!indices || !indices.length) return;

Check warning on line 1063 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
this.isUpdatingDocument = true;
const separator = this.getSeparator();
const text = this.document.getText();
Expand Down Expand Up @@ -1451,7 +1452,8 @@
nextChunkStart,
hasRemoteChunks,
mouseWheelZoomEnabled,
mouseWheelZoomInvert
mouseWheelZoomInvert,
realRowCount: data.length
});
}

Expand Down Expand Up @@ -1704,8 +1706,9 @@
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(
Expand Down Expand Up @@ -1917,7 +1920,7 @@
</style>
</head>
<body>
<div id="csv-root" class="table-container" data-sepcode="${sepCode}" data-fontsize="${fontSize}" data-wheelzoomenabled="${mouseWheelZoomEnabled ? '1' : '0'}" data-wheelzoominvert="${mouseWheelZoomInvert ? '1' : '0'}" data-nextchunkstart="${nextChunkStart >= 0 ? nextChunkStart : ''}" data-hasmorechunks="${hasRemoteChunks ? '1' : '0'}">
<div id="csv-root" data-realrowcount="${realRowCount}" class="table-container" data-sepcode="${sepCode}" data-fontsize="${fontSize}" data-wheelzoomenabled="${mouseWheelZoomEnabled ? '1' : '0'}" data-wheelzoominvert="${mouseWheelZoomInvert ? '1' : '0'}" data-nextchunkstart="${nextChunkStart >= 0 ? nextChunkStart : ''}" data-hasmorechunks="${hasRemoteChunks ? '1' : '0'}">
${tableHtml}
</div>

Expand Down
86 changes: 86 additions & 0 deletions src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Papa from 'papaparse';

const clipboardDelimiterCandidates = ['\t', ',', ';', '|'];

export function serializeClipboardMatrix(matrix: string[][], delimiter: string): string {
const escapedCharacters = [delimiter, ...clipboardDelimiterCandidates, '"', '\r', '\n', '\uFEFF'];
// 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<string>();
let quoted = new Set<string>();
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<string[]>(text, {
dynamicTyping: false,
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.
// 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('')]);
}
Loading
Loading