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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get:
## Features

- **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs.
- **Smart Resizing:** Automatic calculation of column widths for improved readability.
- **Smart Resizing:** Automatic widths follow the longest line in multiline cells. Drag close to a column boundary to resize; the narrow grab area leaves small columns selectable. Widths are remembered per file across closing and reopening. Double-click a column boundary to restore automatic sizing. Resizing also works with the header row hidden.
- **Dynamic Color Coding:** Visual cues based on data type help you quickly identify numbers, dates, booleans, and more (with opt‑in cool palette and theme-foreground mode settings).
- **Sticky Headers:** Keep column titles in view as you scroll through large datasets.
- **Enhanced Keyboard Navigation:** Navigate cells with arrows and Tab/Shift+Tab; quick edits can commit with arrow keys; `Ctrl/Cmd + A` selects all; `Ctrl/Cmd + C` copies selection.
Expand Down
Binary file added images/resize-hit-area.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
106 changes: 79 additions & 27 deletions media/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ let editingCell = null, originalCellValue = "";
let editMode = null; // 'quick' | 'detail' | null
const DRAG_THRESHOLD_PX = 4;
const RESIZE_HANDLE_PX = 6;
const COLUMN_RESIZE_HANDLE_PX = 4;
let resizeState = null;
let reorderState = null;

Expand All @@ -45,6 +46,9 @@ dragIndicator.style.zIndex = '20000';
dragIndicator.style.background = '#0a84ff';
dragIndicator.style.display = 'none';
document.body.appendChild(dragIndicator);
const automaticColumnSizes = new WeakMap();
let persistedColumnSizes = {};
try { persistedColumnSizes = JSON.parse(root?.dataset?.columnsizes || '{}'); } catch {}
let columnSizeState = {};
let rowSizeState = {};

Expand All @@ -61,13 +65,23 @@ const normalizeSizeState = (raw, minSize) => {
return out;
};

const setRenderedColumnWidth = (cell, px) => {
if (!automaticColumnSizes.has(cell)) {
automaticColumnSizes.set(cell, { width: cell.style.width, minWidth: cell.style.minWidth, maxWidth: cell.style.maxWidth, boxSizing: cell.style.boxSizing });
}
cell.style.boxSizing = 'border-box';
cell.style.width = `${px}px`;
cell.style.minWidth = `${px}px`;
cell.style.maxWidth = `${px}px`;
};
const persistColumnSizes = () => {
vscode.postMessage({ type: 'columnSizesChanged', columnSizes: { ...columnSizeState } });
};
const applySizeStateToRenderedCells = () => {
for (const [col, width] of Object.entries(columnSizeState)) {
const px = Math.max(40, Math.round(Number(width)));
table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => {
cell.style.width = `${px}px`;
cell.style.minWidth = `${px}px`;
cell.style.maxWidth = `${px}px`;
setRenderedColumnWidth(cell, px);
});
}
for (const [row, height] of Object.entries(rowSizeState)) {
Expand Down Expand Up @@ -153,7 +167,7 @@ const restoreState = () => {
const st = vscode.getState() || {};
const restoredZoom = parsePositiveNumber(st.zoomScale);
setZoomScale(restoredZoom ?? 1, false);
columnSizeState = normalizeSizeState(st.columnSizes, 40);
columnSizeState = normalizeSizeState(st.columnSizes ?? persistedColumnSizes, 40);
rowSizeState = normalizeSizeState(st.rowSizes, getMinRowHeight());
applySizeStateToRenderedCells();
if (typeof st.scrollX === 'number' && scrollContainer) {
Expand Down Expand Up @@ -560,17 +574,60 @@ const getRowDropTarget = clientY => {
return { beforeIndex, indicatorY };
};
const getResizeEdgeInfo = (target, e) => {
if (!target) return null;
if (isColumnHeaderCell(target)) {
const col = parseInt(target.getAttribute('data-col') || 'NaN', 10);
if (!Number.isNaN(col)) {
const rect = target.getBoundingClientRect();
const edgeDelta = rect.right - e.clientX;
if (edgeDelta >= 0 && edgeDelta <= RESIZE_HANDLE_PX) {
return { axis: 'column', index: col, rect };
}
if (editingCell || !Number.isFinite(e.clientX) || !Number.isFinite(e.clientY)) return null;
const element = getElementTarget(e.target);
if (element?.closest('a, .csv-link, input, textarea, select, button, [contenteditable="true"], [role="button"]')) return null;
const paintedCellAt = (x, y) => {
const hit = document.elementFromPoint(x, y);
return hit && table.contains(hit) ? getCellTarget(hit) : null;
};
const candidates = new Set();
const add = cell => { if (cell && table.contains(cell)) candidates.add(cell); };
if (target) {
add(target);
// A sticky seam can lie inside a partially covered scrolling cell's rect.
add(paintedCellAt(e.clientX - COLUMN_RESIZE_HANDLE_PX - 0.5, e.clientY));
const rect = target.getBoundingClientRect();
if (rect.width > 0 && Math.abs(e.clientX - rect.left) <= COLUMN_RESIZE_HANDLE_PX) {
// Hit the painted neighbor, which can differ from col-1 at a frozen seam.
add(paintedCellAt(rect.left - 0.5, e.clientY));
add(paintedCellAt(rect.left - 1, e.clientY));
add(target.previousElementSibling);
}
} else {
// Collapsed/fractional borders can target TR/TABLE rather than either cell.
for (const delta of [0, -0.5, 0.5, -1, 1]) {
add(paintedCellAt(e.clientX + delta, e.clientY));
}
}
let closest = null;
let closestDistance = Infinity;
for (const cell of candidates) {
const coords = getDataCellCoords(cell);
if (!coords) continue;
const rect = cell.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0 || e.clientY < rect.top - 0.5 || e.clientY > rect.bottom + 0.5 || Math.abs(e.clientX - rect.right) > COLUMN_RESIZE_HANDLE_PX + 0.75) continue;
const sampleY = clamp(e.clientY, rect.top + Math.min(0.5, rect.height / 4), rect.bottom - Math.min(0.5, rect.height / 4));
// A scrolled cell can share the frozen column's x coordinate while covered.
if (paintedCellAt(rect.right - Math.min(0.5, rect.width / 4), sampleY) !== cell) continue;
const neighbor = paintedCellAt(rect.right + 0.5, sampleY);
const next = neighbor && neighbor !== cell && neighbor.parentElement === cell.parentElement ? neighbor : cell.nextElementSibling;
const nextRect = next && getDataCellCoords(next) ? next.getBoundingClientRect() : null;
const adjacent = nextRect && nextRect.width > 0 && nextRect.left <= rect.right + 1.5 && nextRect.right > rect.right;
const contiguous = adjacent && Math.abs(nextRect.left - rect.right) <= 1.5;
const boundary = contiguous ? (rect.right + nextRect.left) / 2 : rect.right;
const leftCover = paintedCellAt(rect.left + Math.min(0.5, rect.width / 4), sampleY);
const visibleLeft = leftCover && leftCover !== cell ? Math.min(rect.right, Math.max(rect.left, leftCover.getBoundingClientRect().right)) : rect.left;
const nextWidth = adjacent ? nextRect.right - Math.max(nextRect.left, rect.right) : Infinity;
// Use the painted span at sticky seams so even partially covered cells retain a center.
const band = Math.min(COLUMN_RESIZE_HANDLE_PX, (rect.right - visibleLeft) / 4, nextWidth / 4);
const distance = Math.abs(e.clientX - boundary);
if (distance <= band && distance < closestDistance) {
closestDistance = distance;
closest = { axis: 'column', index: coords.col, rect };
}
}
if (closest) return closest;
if (isRowIndexCell(target)) {
const row = parseInt(target.getAttribute('data-row') || 'NaN', 10);
if (!Number.isNaN(row)) {
Expand All @@ -587,17 +644,14 @@ const applyColumnWidth = (col, widthPx) => {
const width = Math.max(40, Math.round(widthPx));
columnSizeState[String(col)] = width;
table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => {
cell.style.width = `${width}px`;
cell.style.minWidth = `${width}px`;
cell.style.maxWidth = `${width}px`;
setRenderedColumnWidth(cell, width);
});
};
const resetColumnWidth = col => {
delete columnSizeState[String(col)];
table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => {
cell.style.width = '';
cell.style.minWidth = '';
cell.style.maxWidth = '';
const automatic = automaticColumnSizes.get(cell);
if (automatic) Object.assign(cell.style, automatic);
});
};
const applyRowHeight = (row, heightPx) => {
Expand Down Expand Up @@ -700,6 +754,7 @@ const onGlobalDragMove = e => {
};
const onGlobalDragEnd = () => {
if (resizeState) {
if (resizeState.axis === 'column') persistColumnSizes();
resizeState = null;
table.style.cursor = '';
persistState();
Expand Down Expand Up @@ -769,6 +824,10 @@ table.addEventListener('mousedown', e => {
return;
}
const target = getCellTarget(e.target);
if (e.button === 0 && startResizeDrag(target, e)) {
e.preventDefault();
return;
}
if (!target) return;

// Preserve selection on right-click; select target if outside current selection
Expand All @@ -787,10 +846,6 @@ table.addEventListener('mousedown', e => {
return; // do not start drag selection on right-click
}
if (e.button !== 0) return;
if (!editingCell && startResizeDrag(target, e)) {
e.preventDefault();
return;
}
if (!editingCell && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && startReorderDrag(target, e)) {
e.preventDefault();
return;
Expand Down Expand Up @@ -897,10 +952,6 @@ table.addEventListener('mousemove', e => {
return;
}
const target = getCellTarget(e.target);
if (!target) {
table.style.cursor = '';
return;
}
const edge = getResizeEdgeInfo(target, e);
if (edge) {
table.style.cursor = edge.axis === 'column' ? 'col-resize' : 'row-resize';
Expand Down Expand Up @@ -2022,6 +2073,7 @@ table.addEventListener('dblclick', e => {
e.stopPropagation();
if (edge.axis === 'column') {
resetColumnWidth(edge.index);
persistColumnSizes();
} else {
resetRowHeight(edge.index);
}
Expand Down
48 changes: 44 additions & 4 deletions src/CsvEditorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@
case 'save':
await this.handleSave();
break;
case 'columnSizesChanged':
await CsvEditorProvider.setColumnSizesForUri(this.context, this.document.uri, e.columnSizes);
break;
case 'copyCells':
await this.copyCells(e.matrix, e.startRow, e.startCol, e.cut === true);
break;
Expand Down Expand Up @@ -332,7 +335,7 @@
}

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

Check warning on line 338 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 @@ -850,7 +853,7 @@
}

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

Check warning on line 856 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 @@ -942,17 +945,17 @@
}
data[row][col] = '';
} else {
while (data.length <= row) data.push([]);

Check warning on line 948 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 949 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 956 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 958 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
}
return true;
};
Expand Down Expand Up @@ -990,7 +993,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 996 in src/CsvEditorProvider.ts

View workflow job for this annotation

GitHub Actions / build

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

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

Check warning on line 1014 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 @@ -1017,7 +1020,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 1023 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 @@ -1060,7 +1063,7 @@
}

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

Check warning on line 1066 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 @@ -1452,7 +1455,8 @@
nextChunkStart,
hasRemoteChunks,
mouseWheelZoomEnabled,
mouseWheelZoomInvert
mouseWheelZoomInvert,
columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri)
});
}

Expand Down Expand Up @@ -1705,8 +1709,9 @@
hasRemoteChunks: boolean;
mouseWheelZoomEnabled: boolean;
mouseWheelZoomInvert: boolean;
columnSizes: Record<string, 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, columnSizes } = 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 @@ -1918,7 +1923,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-columnsizes="${this.escapeHtml(JSON.stringify(columnSizes))}" 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 Expand Up @@ -1978,7 +1983,9 @@
const widths = Array(numColumns).fill(0);
for (const row of data) {
for (let i = 0; i < numColumns; i++){
widths[i] = Math.max(widths[i], (row[i] || '').length);
for (const line of (row[i] || '').split(/\r\n|[\r\n]/)) {
widths[i] = Math.max(widths[i], line.length);
}
}
}
console.log(`CSV: Column widths: ${widths}`);
Expand Down Expand Up @@ -2226,6 +2233,7 @@
public static readonly headerKey = 'csv.headerByUri';
public static readonly serialKey = 'csv.serialIndexByUri';
public static readonly sepKey = 'csv.separatorByUri';
public static readonly columnSizesKey = 'csv.columnSizesByUri';
private static readonly DEFAULT_SEPARATOR = ',';
private static readonly DEFAULT_SEPARATOR_MODE: SeparatorMode = 'extension';
private static readonly BUILTIN_SEPARATORS_BY_EXTENSION: Record<string, string> = {
Expand Down Expand Up @@ -2596,6 +2604,38 @@
await context.workspaceState.update(CsvEditorProvider.serialKey, map);
}

public static normalizeColumnSizes(raw: unknown): Record<string, number> {
const sizes: Record<string, number> = {};
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return sizes;
}
for (const [key, value] of Object.entries(raw)) {
const col = Number(key);
const width = Number(value);
if (!Number.isInteger(col) || col < 0 || !Number.isFinite(width) || width < 40) {
continue;
}
sizes[String(col)] = Math.round(width);
}
return sizes;
}

public static getColumnSizesForUri(context: vscode.ExtensionContext, uri: vscode.Uri): Record<string, number> {
const map = context.workspaceState.get<Record<string, unknown>>(CsvEditorProvider.columnSizesKey, {});
return CsvEditorProvider.normalizeColumnSizes(map[uri.toString()]);
}

public static async setColumnSizesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, raw: unknown): Promise<void> {
const map = { ...context.workspaceState.get<Record<string, unknown>>(CsvEditorProvider.columnSizesKey, {}) };
const sizes = CsvEditorProvider.normalizeColumnSizes(raw);
if (Object.keys(sizes).length) {
map[uri.toString()] = sizes;
} else {
delete map[uri.toString()];
}
await context.workspaceState.update(CsvEditorProvider.columnSizesKey, map);
}

public static getSeparatorForUri(context: vscode.ExtensionContext, uri: vscode.Uri): string | undefined {
const map = context.workspaceState.get<Record<string, string>>(CsvEditorProvider.sepKey, {});
return map[uri.toString()];
Expand Down
Loading
Loading