Skip to content

Commit 4b795a4

Browse files
Robin-Reicheclaude
andcommitted
Release v1.10.0: freeze multiple rows and columns, keep freezes stable across edits
- #9 Freeze multiple rows at once (multi-line headers), additive and in freeze order, with unfreeze-one and unfreeze-all - Freeze multiple columns at once via Shift-selected headers - Track frozen columns in state (pinnedCols) and re-apply on every grid rebuild, track frozen rows by reference with position re-anchoring - Preserve freezes across row/column delete and insert, undo/redo, save, delimiter change and external reload (fixes seven freeze-loss regressions) - Suppress the file-watcher self-write echo so saving no longer re-parses and wipes in-memory view state - Add unit tests for multi-row freeze partition, column index remap and re-anchor Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b63bebb commit 4b795a4

18 files changed

Lines changed: 404 additions & 133 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to CSV Grid Editor are documented here.
44

5+
## [1.10.0] - 2026-06-12
6+
7+
### Added
8+
- **Freeze multiple rows** - You can now pin more than one row to the top at once, which makes multi-line headers (a group row plus a unit row, for example) stay readable while you scroll (requested in [#9](https://github.com/Robin-Reiche/csv-grid-editor/issues/9)). Select several rows (drag or `Shift`+click the `#` gutter) and choose **Freeze N rows**, or keep adding rows one at a time, freezing is additive and the rows stay in the order you froze them. Right-click a pinned row to **Unfreeze** just that one, or **Unfreeze all rows**. This also covers the multi-level-header use case from [#8](https://github.com/Robin-Reiche/csv-grid-editor/issues/8) without changing the CSV.
9+
- **Freeze multiple columns at once** - `Shift`+click several column headers and choose **Freeze N columns** to pin them all in one go, the companion to multi-row freeze.
10+
11+
### Fixed
12+
- **Freezes were lost during normal editing** - Frozen rows and columns are now preserved across deleting and inserting rows or columns, undo and redo, saving, and changing the delimiter, and frozen rows survive an external reload. Previously any of these could silently clear the freeze because the grid rebuild or re-parse lost track of the pinned rows and columns.
13+
514
## [1.9.0] - 2026-06-12
615

716
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,10 @@ For files larger than 10 MB you get a quick menu to open the full file, preview
9797
- **Manual Override** - Click the delimiter badge in the toolbar to change the delimiter on the fly (comma `,`, semicolon `;`, tab, pipe `|`). The grid re-parses the file immediately.
9898

9999
### Column Freeze
100-
- **Freeze / Unfreeze** - Right-click any column header to pin it to the left side of the grid, right-click again to unfreeze. Frozen columns show a 📌 marker before the column name.
100+
- **Freeze / Unfreeze** - Right-click any column header to pin it to the left side of the grid, right-click again to unfreeze. `Shift`+click several headers first to **Freeze N columns** at once. Frozen columns show a 📌 marker before the column name.
101101

102102
### Row Freeze
103-
- **Freeze / Unfreeze** - Right-click any row and choose **Freeze row** to pin it to the top of the grid as an always-visible reference while you scroll, sort and filter. Right-click the pinned row to unfreeze. One row can be frozen at a time. A 📌 marker on the pinned row shows its original row number, and the row stays visible regardless of any active filter.
103+
- **Freeze / Unfreeze** - Right-click any row and choose **Freeze row** to pin it to the top of the grid as an always-visible reference while you scroll, sort and filter. Select several rows first (drag or `Shift`+click the `#` gutter) and choose **Freeze N rows** to pin them all at once, handy for multi-line headers. Freezing is additive and the rows stay in the order you froze them. Right-click a pinned row to **Unfreeze** just that one, or **Unfreeze all rows**. A 📌 marker on each pinned row shows its original row number, and pinned rows stay visible regardless of any active filter.
104104

105105
### Rename Columns
106106
- **Rename** - Double-click a column header (or right-click it → **Rename column**) to rename it. The new name is written to the CSV header row and is fully undoable.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "csv-grid-editor",
33
"displayName": "CSV Grid Editor: CSV & TSV Viewer",
44
"description": "Open, view and edit CSV and TSV files in a fast sortable filterable grid. Inline editing, find and replace, freeze rows, column stats, Excel copy and paste and export to JSON. Free.",
5-
"version": "1.9.0",
5+
"version": "1.10.0",
66
"publisher": "RobinReiche",
77
"license": "MIT",
88
"icon": "icon.png",

src/csvEditorProvider.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,15 @@ export class CsvEditorProvider implements vscode.CustomEditorProvider<CsvDocumen
200200
watcher.onDidChange(async () => {
201201
try {
202202
const raw = await vscode.workspace.fs.readFile(document.uri);
203-
document.content = new TextDecoder().decode(raw);
203+
const text = new TextDecoder().decode(raw);
204+
// Ignore our own writes. saveCustomDocument writes document.content
205+
// verbatim, so a watcher event whose content equals what we already
206+
// hold is the echo of our own save, not an external edit. Reloading
207+
// on it would re-parse the CSV into fresh arrays and wipe in-memory
208+
// view state (frozen rows, in particular). Only genuinely external
209+
// changes differ from document.content.
210+
if (text === document.content) return;
211+
document.content = text;
204212
webviewPanel.webview.postMessage({
205213
type: 'update',
206214
text: document.content,

src/webview/features/delete-row-col.ts

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
deleteRowsFromData,
99
insertRowsIntoData,
1010
insertColumnsIntoData,
11+
shiftIndicesAfterDelete,
12+
shiftIndicesAfterInsert,
1113
} from '../grid/mutations';
1214
import {
1315
copySelection,
@@ -16,7 +18,7 @@ import {
1618
getSelectedRowDisplayIndices,
1719
getSelectedColIndices,
1820
} from './range-select';
19-
import { freezeRow, unfreezeRow, isRowFrozen } from './freeze-rows';
21+
import { freezeRows, unfreezeRow, unfreezeAllRows, frozenRowCount } from './freeze-rows';
2022

2123
// ── Data mutations ────────────────────────────────────────────────────────────
2224

@@ -33,11 +35,12 @@ function deleteColumns(colIndices: number[]): void {
3335
const indices = colIndices.filter(c => Number.isInteger(c) && c >= 0);
3436
if (indices.length === 0) return;
3537
pushUndo();
36-
// map() rebuilds every row array, replacing the frozen row's reference
37-
// re-anchor it to the new array at the same position so the freeze survives.
38-
const frozenIdx = state.frozenRowRef ? state.data.indexOf(state.frozenRowRef) : -1;
38+
// map() rebuilds every row array, replacing the frozen rows' references
39+
// re-anchor them by position (row count is unchanged) so the freezes survive.
40+
const frozenIdxs = state.frozenRowRefs.map(r => state.data.indexOf(r)).filter(i => i >= 0);
3941
state.data = deleteColumnsFromData(state.data, indices);
40-
if (frozenIdx >= 0) state.frozenRowRef = state.data[frozenIdx];
42+
state.frozenRowRefs = frozenIdxs.map(i => state.data[i]);
43+
state.pinnedCols = shiftIndicesAfterDelete(state.pinnedCols, indices); // keep frozen columns frozen
4144
state.hiddenCols.clear(); // column indices shifted — drop index-based hide state
4245
state.isAutoFitted = false;
4346
state.autoFitCache = null;
@@ -144,11 +147,12 @@ function insertColumns(baseIndex: number, position: 'left' | 'right', count: num
144147

145148
pushUndo();
146149
// map() (inside insertColumnsIntoData) rebuilds every row array — re-anchor the
147-
// frozen row afterwards so the freeze survives a column insert (row positions
148-
// are unchanged).
149-
const frozenIdx = state.frozenRowRef ? state.data.indexOf(state.frozenRowRef) : -1;
150+
// frozen rows by position afterwards so the freezes survive a column insert (row
151+
// positions are unchanged).
152+
const frozenIdxs = state.frozenRowRefs.map(r => state.data.indexOf(r)).filter(i => i >= 0);
150153
state.data = insertColumnsIntoData(state.data, insertAt, count);
151-
if (frozenIdx >= 0) state.frozenRowRef = state.data[frozenIdx];
154+
state.frozenRowRefs = frozenIdxs.map(i => state.data[i]);
155+
state.pinnedCols = shiftIndicesAfterInsert(state.pinnedCols, insertAt, count); // keep frozen columns frozen
152156
state.hiddenCols.clear(); // column indices shifted — drop index-based hide state
153157
state.isAutoFitted = false;
154158
state.autoFitCache = null;
@@ -164,7 +168,7 @@ function hideMenu(): void {
164168
document.getElementById('row-context-menu')?.classList.add('hidden');
165169
}
166170

167-
function showContextMenu(x: number, y: number, rowIndex: number | null, colId: string | null, isPinnedRow = false): void {
171+
function showContextMenu(x: number, y: number, rowIndex: number | null, colId: string | null, isPinnedRow = false, pinnedOrig: number | null = null): void {
168172
const menu = document.getElementById('row-context-menu') as HTMLElement | null;
169173
if (!menu) return;
170174

@@ -217,36 +221,54 @@ function showContextMenu(x: number, y: number, rowIndex: number | null, colId: s
217221
menu.appendChild(sep);
218222
}
219223

220-
// ── Freeze / Unfreeze row ─────────────────────────────────────────────────
224+
// ── Freeze / Unfreeze row(s) ──────────────────────────────────────────────
221225
// A pure view aid (available in preview too). Suppressed while duplicate
222226
// detection is active, since that mode drives the grid's rowData itself —
223227
// the two are mutually exclusive (runDetect() drops any frozen row).
224228
if (state.dupRowSet.size === 0 && !state.dupShowOnly) {
225-
if (isPinnedRow && state.frozenRowRef !== null) {
226-
// Right-clicked the pinned band — it can only be the single frozen row,
227-
// so offer Unfreeze directly without depending on a (possibly absent)
228-
// body row index.
229-
const item = document.createElement('div');
230-
item.className = 'row-ctx-item';
231-
item.textContent = '📌 Unfreeze row';
232-
item.addEventListener('click', () => { unfreezeRow(); hideMenu(); });
233-
menu.appendChild(item);
234-
235-
const sep = document.createElement('div');
236-
sep.className = 'col-ctx-separator';
237-
menu.appendChild(sep);
238-
} else if (rowIndex !== null) {
239-
const node = resolveNode();
240-
const clickedOrig = node?.data?._origIndex != null ? Number(node.data._origIndex) : null;
229+
if (isPinnedRow) {
230+
// Right-clicked a pinned (frozen) row. pinnedOrig is resolved from the
231+
// DOM by the contextmenu handler. Only show the per-row item when it
232+
// resolved, so an unresolved click can NEVER fall back to clearing all
233+
// freezes. "Unfreeze all rows" is a separate, explicit action.
234+
const clickedOrig = pinnedOrig;
241235
if (clickedOrig != null) {
242-
const frozen = isRowFrozen(clickedOrig);
243236
const item = document.createElement('div');
244237
item.className = 'row-ctx-item';
245-
item.textContent = frozen ? '📌 Unfreeze row' : '📌 Freeze row';
246-
item.addEventListener('click', () => {
247-
if (frozen) unfreezeRow(); else freezeRow(clickedOrig);
248-
hideMenu();
249-
});
238+
item.textContent = '📌 Unfreeze row';
239+
item.addEventListener('click', () => { unfreezeRow(clickedOrig); hideMenu(); });
240+
menu.appendChild(item);
241+
}
242+
if (frozenRowCount() > 1) {
243+
const all = document.createElement('div');
244+
all.className = 'row-ctx-item';
245+
all.textContent = '📌 Unfreeze all rows';
246+
all.addEventListener('click', () => { unfreezeAllRows(); hideMenu(); });
247+
menu.appendChild(all);
248+
}
249+
if (clickedOrig != null || frozenRowCount() > 1) {
250+
const sep = document.createElement('div');
251+
sep.className = 'col-ctx-separator';
252+
menu.appendChild(sep);
253+
}
254+
} else if (rowIndex !== null) {
255+
// Body rows are never frozen themselves (a frozen row moves to the
256+
// pinned band), so the body menu only offers Freeze. With a multi-row
257+
// selection that includes the clicked row, freeze them all at once.
258+
const selectedRows = getSelectedRowDisplayIndices();
259+
const inSel = selectedRows.length > 1 && selectedRows.includes(rowIndex);
260+
const displayRows = inSel ? selectedRows : [rowIndex];
261+
const origs: number[] = [];
262+
for (const di of displayRows) {
263+
const oi = state.gridApi?.getDisplayedRowAtIndex(di)?.data?._origIndex;
264+
if (oi != null) origs.push(Number(oi));
265+
}
266+
267+
if (origs.length > 0) {
268+
const item = document.createElement('div');
269+
item.className = 'row-ctx-item';
270+
item.textContent = origs.length > 1 ? `📌 Freeze ${origs.length} rows` : '📌 Freeze row';
271+
item.addEventListener('click', () => { freezeRows(origs); hideMenu(); });
250272
menu.appendChild(item);
251273

252274
const sep = document.createElement('div');
@@ -408,6 +430,17 @@ export function setupDeleteRowCol(): void {
408430
const riStr = agRow?.getAttribute('row-index');
409431
const rowIndex = riStr != null ? parseInt(riStr, 10) : null;
410432

411-
showContextMenu(e.clientX, e.clientY, rowIndex, colId, isPinnedRow);
433+
// For a pinned (frozen) row, resolve which row it is independently of AG
434+
// Grid's pinned-row index scheme: the '#' gutter cell renders "📌<origIndex>"
435+
// (builder.ts valueGetter), so read that from the matching row in the band.
436+
let pinnedOrig: number | null = null;
437+
if (isPinnedRow && agRow && riStr != null) {
438+
const ft = agRow.closest('.ag-floating-top');
439+
const gutter = ft?.querySelector(`.ag-row[row-index="${riStr}"] [col-id="row-index"]`);
440+
const m = (gutter?.textContent ?? '').match(/\d+/);
441+
pinnedOrig = m ? parseInt(m[0], 10) : null;
442+
}
443+
444+
showContextMenu(e.clientX, e.clientY, rowIndex, colId, isPinnedRow, pinnedOrig);
412445
});
413446
}

src/webview/features/delimiter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { state } from '../state';
22
import { parseCsv } from '../utils/csv';
33
import { buildGrid } from '../grid/builder';
4+
import { frozenRowPositions, reanchorFrozenRows } from './freeze-rows';
45

56
export function updateDelimiterBadge(delimiter: string): void {
67
const badge = document.getElementById('delim-badge');
@@ -26,7 +27,12 @@ export function setupDelimiterBadge(): void {
2627
state.currentDelimiter = raw === '\\t' ? '\t' : raw;
2728
updateDelimiterBadge(state.currentDelimiter);
2829
dropdown.classList.add('hidden');
30+
// A different delimiter re-splits the same lines into different columns,
31+
// so the rows are the same rows at the same positions — re-anchor the
32+
// frozen rows across the re-parse instead of losing them.
33+
const frozen = frozenRowPositions();
2934
state.data = parseCsv(state.rawCsvText, state.currentDelimiter);
35+
reanchorFrozenRows(frozen);
3036
state.hiddenCols.clear(); // re-parse may change the column set — drop index-based hide state
3137
state.autoFitCache = null;
3238
state.colTypes = [];

src/webview/features/duplicates.ts

6 Bytes
Binary file not shown.

src/webview/features/freeze-columns.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,32 @@ export function setupFreezeColumns(): void {
99
const menu = document.getElementById('col-context-menu') as HTMLElement | null;
1010
if (!menu) return;
1111

12+
// If the right-clicked column is part of a multi-column selection, freeze /
13+
// unfreeze applies to all selected columns, otherwise just the clicked one.
14+
const targetColIds = (colId: string): string[] => {
15+
const colIndex = parseInt(colId.replace('col_', ''), 10);
16+
const sel = getSelectedColIndices();
17+
return sel.length > 1 && sel.includes(colIndex) ? sel.map(i => 'col_' + i) : [colId];
18+
};
19+
1220
document.getElementById('col-ctx-freeze')?.addEventListener('click', () => {
1321
const colId = menu.dataset.colId;
14-
if (colId && state.gridApi) {
15-
state.gridApi.applyColumnState({ state: [{ colId, pinned: 'left' }] });
16-
}
1722
menu.classList.add('hidden');
23+
if (!colId || !state.gridApi) return;
24+
const ids = targetColIds(colId);
25+
// Track the freeze in state so it survives a buildGrid rebuild, then apply
26+
// it live without a rebuild.
27+
for (const id of ids) { const ci = parseInt(id.slice(4), 10); if (!isNaN(ci)) state.pinnedCols.add(ci); }
28+
state.gridApi.applyColumnState({ state: ids.map(id => ({ colId: id, pinned: 'left' })) });
1829
});
1930

2031
document.getElementById('col-ctx-unfreeze')?.addEventListener('click', () => {
2132
const colId = menu.dataset.colId;
22-
if (colId && state.gridApi) {
23-
state.gridApi.applyColumnState({ state: [{ colId, pinned: null }] });
24-
}
2533
menu.classList.add('hidden');
34+
if (!colId || !state.gridApi) return;
35+
const ids = targetColIds(colId);
36+
for (const id of ids) { const ci = parseInt(id.slice(4), 10); state.pinnedCols.delete(ci); }
37+
state.gridApi.applyColumnState({ state: ids.map(id => ({ colId: id, pinned: null })) });
2638
});
2739

2840
document.addEventListener('click', () => menu.classList.add('hidden'));
@@ -48,18 +60,24 @@ export function setupFreezeColumns(): void {
4860
const col = colStateArr.find((s: any) => s.colId === colId);
4961
const isPinned = col?.pinned === 'left';
5062

51-
const freezeEl = document.getElementById('col-ctx-freeze');
52-
const unfreezeEl = document.getElementById('col-ctx-unfreeze');
53-
if (freezeEl) freezeEl.style.display = isPinned ? 'none' : 'block';
54-
if (unfreezeEl) unfreezeEl.style.display = isPinned ? 'block' : 'none';
55-
56-
// Reflect a multi-column selection in the insert/delete labels so the user
57-
// sees "Insert/Delete N columns" before clicking (matches the data-cell menu
58-
// and Google Sheets). N inserts happen anchored to the selection edge.
63+
// Reflect a multi-column selection in every action label so the user sees
64+
// "Freeze/Unfreeze/Insert/Delete N columns" before clicking (matches the
65+
// data-cell menu and Google Sheets). N inserts anchor to the selection edge.
5966
const colIndex = parseInt(colId.replace('col_', ''), 10);
6067
const selectedCols = getSelectedColIndices();
6168
const n = selectedCols.length > 1 && selectedCols.includes(colIndex) ? selectedCols.length : 1;
6269

70+
const freezeEl = document.getElementById('col-ctx-freeze');
71+
const unfreezeEl = document.getElementById('col-ctx-unfreeze');
72+
if (freezeEl) {
73+
freezeEl.style.display = isPinned ? 'none' : 'block';
74+
freezeEl.textContent = n > 1 ? `📌 Freeze ${n} columns` : '📌 Freeze column';
75+
}
76+
if (unfreezeEl) {
77+
unfreezeEl.style.display = isPinned ? 'block' : 'none';
78+
unfreezeEl.textContent = n > 1 ? `📌 Unfreeze ${n} columns` : '📌 Unfreeze column';
79+
}
80+
6381
const delEl = document.getElementById('col-ctx-delete');
6482
if (delEl) delEl.textContent = n > 1 ? `✕ Delete ${n} columns` : '✕ Delete column';
6583
const insL = document.getElementById('col-ctx-insert-left');

0 commit comments

Comments
 (0)