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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get:
- **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 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.
- **Frozen Rows and Columns:** Keep any number of leading visible rows and data columns in view. Right-click a cell to freeze through its row/column or unfreeze; counts are remembered per file. Frozen rows use measured heights, including multiline cells and zoom, and paint divider-colored edges on every frozen row so scrolling text cannot show through the seams. The serial index stays visible alongside frozen data columns.
- **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.
- **Advanced Multi-Cell Selection:** Easily select and copy blocks of data, then paste them elsewhere as properly formatted CSV.
- **Add/Delete Columns:** Right-click any cell to add a column left or right, or remove the selected column.
Expand Down Expand Up @@ -85,6 +85,8 @@ Open the Command Palette and search for:

- `CSV: Toggle Extension On/Off` (`csv.toggleExtension`)
- `CSV: Toggle First Row as Header` (`csv.toggleHeader`)
- `CSV: Freeze Leading Rows` (`csv.changeFrozenRows`)
- `CSV: Freeze Leading Columns` (`csv.changeFrozenColumns`)
- `CSV: Toggle Serial Index Column` (`csv.toggleSerialIndex`)
- `CSV: Change CSV Separator` (`csv.changeSeparator`)
- `CSV: Reset CSV Separator (Inherit)` (`csv.resetSeparator`)
Expand Down Expand Up @@ -121,6 +123,7 @@ Per-file (stored by the extension; set via commands):
- Serial index column (default `true`) — `CSV: Toggle Serial Index Column`
- CSV separator override — `CSV: Change CSV Separator` (or clear it with `CSV: Reset CSV Separator (Inherit)`)
- Hide first N rows (default `0`) — `CSV: Hide First N Rows`
- Frozen leading visible rows/columns — `CSV: Freeze Leading Rows` / `CSV: Freeze Leading Columns`. Enter `0` to unfreeze that axis. The default keeps the first header row frozen; with headers off, no rows freeze until requested. Hidden metadata rows do not count; the virtual append row is never frozen as a leading row.

---

Expand Down
Binary file added images/frozen-row-dividers.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/frozen-top-edge.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions media/frozen-panes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Layout uses measured dimensions so multiline cells, manual sizes and zoom agree.
window.createFrozenPaneManager = ({ table, firstRow, realRowCount, onNeedRows }) => {
let rows = 0;
let columns = 0;
let scheduled = false;
let disposed = false;
const count = value => Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : 0;
const renderedRows = () => Array.from(table.rows).map(row => {
const dataCell = Array.from(row.cells).find(cell => cell.hasAttribute('data-row'));
return { element: row, index: Number(dataCell?.getAttribute('data-row')) };
}).filter(row => Number.isInteger(row.index));
const schedule = () => {
if (scheduled || disposed) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; if (!disposed) layout(); });
};
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null;
const observedRows = new WeakSet();
const layout = () => {
const rendered = renderedRows();
for (const { element } of rendered) {
if (!observedRows.has(element)) {
observedRows.add(element);
observer?.observe(element);
}
}
table.querySelectorAll('.csv-frozen-row, .csv-frozen-column').forEach(cell => {
cell.classList.remove('csv-frozen-row', 'csv-frozen-column');
cell.style.top = '';
cell.style.left = '';
cell.style.zIndex = '';
});
const representative = rendered[0]?.element;
const leftByColumn = new Map();
let left = 0;
if (columns && representative) {
for (const cell of Array.from(representative.cells)) {
const attr = cell.getAttribute('data-col');
const col = attr === null ? -1 : Number(attr);
if (col >= columns) break;
leftByColumn.set(col, left);
left += cell.getBoundingClientRect().width;
}
}
const lastFrozenRow = Math.min(firstRow + rows, realRowCount) - 1;
const firstFrozenCell = rendered.find(({ index }) => index >= firstRow && index <= lastFrozenRow)?.element.cells[0];
// A collapsed border straddles the sticky edge. Cover its inner half once,
// keeping the measured spacing between frozen rows unchanged.
const topBorder = firstFrozenCell && getComputedStyle(table).borderCollapse === 'collapse'
? parseFloat(getComputedStyle(firstFrozenCell).borderTopWidth) || 0
: 0;
let top = -topBorder / 2;
for (const { element, index } of rendered) {
const frozenRow = index >= firstRow && index <= lastFrozenRow;
const height = element.getBoundingClientRect().height || Math.max(0, ...Array.from(element.cells).map(cell => cell.getBoundingClientRect().height));
for (const cell of Array.from(element.cells)) {
const attr = cell.getAttribute('data-col');
const col = attr === null ? -1 : Number(attr);
const frozenColumn = leftByColumn.has(col);
if (frozenRow) {
cell.classList.add('csv-frozen-row');
cell.style.top = `${top}px`;
}
if (frozenColumn) {
cell.classList.add('csv-frozen-column');
cell.style.left = `${leftByColumn.get(col)}px`;
}
if (frozenRow || frozenColumn) {
cell.style.zIndex = frozenRow && frozenColumn ? '4' : (frozenRow ? '3' : '2');
}
}
if (frozenRow) top += height;
}
if (lastFrozenRow >= firstRow && (rendered[rendered.length - 1]?.index ?? -1) < lastFrozenRow) {
onNeedRows?.(lastFrozenRow);
}
};
return {
schedule,
layout,
update(nextRows, nextColumns) {
rows = count(nextRows);
columns = count(nextColumns);
schedule();
},
dispose() { disposed = true; observer?.disconnect(); }
};
};
44 changes: 43 additions & 1 deletion media/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ try { document.body.focus({ preventScroll: true }); } catch { try { document.bod
const vscode = acquireVsCodeApi();

const root = document.getElementById('csv-root');
let frozenRowCount = Number(root?.dataset?.frozenrows || '0');
let frozenColumnCount = Number(root?.dataset?.frozencolumns || '0');
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 @@ -78,6 +80,7 @@ const persistColumnSizes = () => {
vscode.postMessage({ type: 'columnSizesChanged', columnSizes: { ...columnSizeState } });
};
const applySizeStateToRenderedCells = () => {
window.csvFrozenPanes?.schedule();
for (const [col, width] of Object.entries(columnSizeState)) {
const px = Math.max(40, Math.round(Number(width)));
table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => {
Expand Down Expand Up @@ -386,6 +389,12 @@ const clearSelection = () => { currentSelection.forEach(c => c.classList.remove(
const contextMenu = document.getElementById('contextMenu');

/* ──────── UPDATED showContextMenu ──────── */
const changeFrozenPanes = change => {
frozenRowCount = change.rows ?? frozenRowCount;
frozenColumnCount = change.columns ?? frozenColumnCount;
window.csvFrozenPanes?.update(frozenRowCount, frozenColumnCount);
vscode.postMessage({ type: 'freezePanesChanged', rows: frozenRowCount, columns: frozenColumnCount });
};
const showContextMenu = (x, y, row, col) => {
contextMenu.innerHTML = '';
const item = (label, cb) => {
Expand All @@ -404,6 +413,15 @@ const showContextMenu = (x, y, row, col) => {
item('Copy', () => copySelectionToClipboard());
item('Paste', () => pasteFromClipboard());
divider();
if (Number.isInteger(row) && row >= Number(root?.dataset?.firstrow || '0') && row < Number(root?.dataset?.realrowcount || '0')) {
item('Freeze rows through this row', () => changeFrozenPanes({ rows: row - Number(root?.dataset?.firstrow || '0') + 1 }));
}
if (Number.isInteger(col) && col >= 0) {
item('Freeze columns through this column', () => changeFrozenPanes({ columns: col + 1 }));
}
if (frozenRowCount > 0) item('Unfreeze rows', () => changeFrozenPanes({ rows: 0 }));
if (frozenColumnCount > 0) item('Unfreeze columns', () => changeFrozenPanes({ columns: 0 }));
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 @@ -641,20 +659,23 @@ const getResizeEdgeInfo = (target, e) => {
return null;
};
const applyColumnWidth = (col, widthPx) => {
window.csvFrozenPanes?.schedule();
const width = Math.max(40, Math.round(widthPx));
columnSizeState[String(col)] = width;
table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => {
setRenderedColumnWidth(cell, width);
});
};
const resetColumnWidth = col => {
window.csvFrozenPanes?.schedule();
delete columnSizeState[String(col)];
table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => {
const automatic = automaticColumnSizes.get(cell);
if (automatic) Object.assign(cell.style, automatic);
});
};
const applyRowHeight = (row, heightPx) => {
window.csvFrozenPanes?.schedule();
const height = Math.max(getMinRowHeight(), Math.round(heightPx));
rowSizeState[String(row)] = height;
table.querySelectorAll(`[data-row="${row}"]`).forEach(cell => {
Expand All @@ -663,6 +684,7 @@ const applyRowHeight = (row, heightPx) => {
});
};
const resetRowHeight = row => {
window.csvFrozenPanes?.schedule();
delete rowSizeState[String(row)];
table.querySelectorAll(`[data-row="${row}"]`).forEach(cell => {
cell.style.height = '';
Expand Down Expand Up @@ -758,6 +780,7 @@ const onGlobalDragEnd = () => {
resizeState = null;
table.style.cursor = '';
persistState();
window.csvFrozenPanes?.schedule();
}
if (!reorderState) return;

Expand Down Expand Up @@ -2114,7 +2137,11 @@ const copySelectionToClipboard = (cut = false) => {

window.addEventListener('message', event => {
const message = event.data;
if(message.type === 'focus'){
if (message.type === 'frozenPanes') {
frozenRowCount = Number(message.rows);
frozenColumnCount = Number(message.columns);
window.csvFrozenPanes?.update(frozenRowCount, frozenColumnCount);
} else if(message.type === 'focus'){
if (anchorCell) {
try { anchorCell.focus({ preventScroll: true }); } catch { try { anchorCell.focus(); } catch {} }
} else {
Expand Down Expand Up @@ -2146,6 +2173,7 @@ window.addEventListener('message', event => {
remoteNextChunkStart = -1;
remoteHasMoreChunks = false;
}
window.csvFrozenPanes?.schedule();
if (csvChunks.length && pendingEnsureTarget) {
ensureTargetStep();
} else if (csvChunks.length && nearBottom()) {
Expand Down Expand Up @@ -2272,3 +2300,17 @@ document.addEventListener('keydown', e => {
clearSelection();
}
});

// Freeze layout never replaces cells or moves focus, including during a live edit.
if (typeof window.createFrozenPaneManager === 'function') {
window.csvFrozenPanes = window.createFrozenPaneManager({
table,
firstRow: Number(root?.dataset?.firstrow || '0'),
realRowCount: Number(root?.dataset?.realrowcount || '0'),
onNeedRows: () => loadNextChunk()
});
window.csvFrozenPanes.update(frozenRowCount, frozenColumnCount);
window.addEventListener('csvChunkLoaded', () => window.csvFrozenPanes.schedule());
window.addEventListener('resize', () => window.csvFrozenPanes.schedule());
window.addEventListener('pagehide', () => window.csvFrozenPanes.dispose(), { once: true });
}
14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"onCommand:csv.changeFontFamily",
"onCommand:csv.changeIgnoreRows",
"onCommand:csv.changeEncoding",
"onCommand:csv.toggleClickableLinks"
"onCommand:csv.toggleClickableLinks",
"onCommand:csv.changeFrozenRows",
"onCommand:csv.changeFrozenColumns"
],
"main": "./out/extension.js",
"contributes": {
Expand Down Expand Up @@ -99,6 +101,14 @@
{
"command": "csv.toggleClickableLinks",
"title": "CSV: Toggle Clickable Links"
},
{
"command": "csv.changeFrozenRows",
"title": "CSV: Freeze Leading Rows"
},
{
"command": "csv.changeFrozenColumns",
"title": "CSV: Freeze Leading Columns"
}
],
"configuration": {
Expand All @@ -113,7 +123,7 @@
"csv.fontFamily": {
"type": "string",
"default": "",
"description": "Font family used by the CSV custom editor. Leave empty to inherit ‘editor.fontFamily’.",
"description": "Font family used by the CSV custom editor. Leave empty to inherit \u2018editor.fontFamily\u2019.",
"scope": "application"
},
"csv.fontSize": {
Expand Down
Loading
Loading