diff --git a/README.md b/README.md index 94faab5..b3ed2c2 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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`) @@ -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. --- diff --git a/images/frozen-row-dividers.jpg b/images/frozen-row-dividers.jpg new file mode 100644 index 0000000..7ee7641 Binary files /dev/null and b/images/frozen-row-dividers.jpg differ diff --git a/images/frozen-top-edge.jpg b/images/frozen-top-edge.jpg new file mode 100644 index 0000000..d0ef25d Binary files /dev/null and b/images/frozen-top-edge.jpg differ diff --git a/media/frozen-panes.js b/media/frozen-panes.js new file mode 100644 index 0000000..8aeb5a6 --- /dev/null +++ b/media/frozen-panes.js @@ -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(); } + }; +}; diff --git a/media/main.js b/media/main.js index 667db2b..42c0ea2 100644 --- a/media/main.js +++ b/media/main.js @@ -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; @@ -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 => { @@ -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) => { @@ -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); @@ -641,6 +659,7 @@ 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 => { @@ -648,6 +667,7 @@ const applyColumnWidth = (col, widthPx) => { }); }; const resetColumnWidth = col => { + window.csvFrozenPanes?.schedule(); delete columnSizeState[String(col)]; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { const automatic = automaticColumnSizes.get(cell); @@ -655,6 +675,7 @@ const resetColumnWidth = col => { }); }; 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 => { @@ -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 = ''; @@ -758,6 +780,7 @@ const onGlobalDragEnd = () => { resizeState = null; table.style.cursor = ''; persistState(); + window.csvFrozenPanes?.schedule(); } if (!reorderState) return; @@ -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 { @@ -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()) { @@ -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 }); +} diff --git a/package.json b/package.json index 092489b..e787037 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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": { @@ -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": { diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 614055a..53f53bb 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -3,6 +3,8 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { parseClipboardMatrix, serializeClipboardMatrix } from './clipboard'; +type FrozenPanes = { rows: number; columns: number }; + type SeparatorMode = 'extension' | 'auto' | 'default'; type SeparatorSettings = { mode: SeparatorMode; @@ -70,6 +72,7 @@ class CsvEditorController { private document!: vscode.TextDocument; private separatorCache: { version: number; configKey: string; separator: string } | undefined; private isDiffContext = false; + private currentHeader = false; private chunkRenderState: ChunkRenderState | undefined; constructor(private readonly context: vscode.ExtensionContext) {} @@ -148,6 +151,9 @@ class CsvEditorController { case 'save': await this.handleSave(); break; + case 'freezePanesChanged': + await this.setFrozenPanes({ rows: e.rows, columns: e.columns }); + break; case 'columnSizesChanged': await CsvEditorProvider.setColumnSizesForUri(this.context, this.document.uri, e.columnSizes); break; @@ -416,6 +422,15 @@ class CsvEditorController { ); } + public getCurrentFrozenPanes(): FrozenPanes { + return CsvEditorProvider.getFrozenPanesForUri(this.context, this.document.uri, this.currentHeader); + } + + public async setFrozenPanes(change: Partial): Promise { + await CsvEditorProvider.setFrozenPanesForUri(this.context, this.document.uri, change); + this.currentWebviewPanel?.webview.postMessage({ type: 'frozenPanes', ...this.getCurrentFrozenPanes() }); + } + public getDocumentUri(): vscode.Uri { return this.document.uri; } @@ -1413,6 +1428,7 @@ class CsvEditorController { const cellPadding = config.get('cellPadding', 4); const data = this.trimTrailingEmptyRows((parsed.data || []) as string[][]); const treatHeader = this.getEffectiveHeader(data, hiddenRows); + this.currentHeader = treatHeader; const clickableLinks = config.get('clickableLinks', true); const configuredColumnColorMode = config.get('columnColorMode', 'type'); const diffUseThemeForeground = config.get('diffUseThemeForeground', true); @@ -1456,7 +1472,10 @@ class CsvEditorController { hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, - columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri) + columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri), + frozenPanes: this.getCurrentFrozenPanes(), + firstVisibleRow: hiddenRows, + realRowCount: data.length }); } @@ -1714,8 +1733,11 @@ class CsvEditorController { mouseWheelZoomEnabled: boolean; mouseWheelZoomInvert: boolean; columnSizes: Record; + frozenPanes: FrozenPanes; + firstVisibleRow: number; + realRowCount: number; }): string { - const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes } = args; + const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes, frozenPanes, firstVisibleRow, realRowCount } = args; const themeKind = vscode.window.activeColorTheme.kind; const isDark = themeKind === vscode.ColorThemeKind.Dark || themeKind === vscode.ColorThemeKind.HighContrast; // Build script URI using file path for compatibility (older APIs may lack Uri.joinPath) @@ -1723,6 +1745,9 @@ class CsvEditorController { vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'main.js')) ); + const frozenPanesScriptUri = webview.asWebviewUri( + vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'frozen-panes.js')) + ); const contrastStyleUri = webview.asWebviewUri( vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'high-contrast.css')) ); @@ -1766,7 +1791,7 @@ class CsvEditorController { .table-container { overflow: auto; height: 100vh; } table { border-collapse: collapse; width: max-content; } th, td { padding: ${cellPadding}px 8px; border: 1px solid var(--csv-table-border, ${isDark ? '#555' : '#ccc'}); font-size: inherit; } - th { position: sticky; top: 0; background-color: var(--csv-header-background, ${isDark ? '#1e1e1e' : '#ffffff'}); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + th { position: static; background-color: var(--csv-header-background, ${isDark ? '#1e1e1e' : '#ffffff'}); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } td { overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; } td.selected, th.selected { background-color: var(--csv-selection-background, ${isDark ? '#333333' : '#cce0ff'}) !important; } td.editing, th.editing { overflow: visible !important; white-space: pre-wrap !important; overflow-wrap: anywhere !important; max-width: none !important; } @@ -1950,13 +1975,35 @@ class CsvEditorController { #contextMenu div { padding: 4px 12px; cursor: pointer; } #contextMenu div:hover { background: var(--csv-menu-hover-background, ${isDark ? '#3d3d3d' : '#eeeeee'}); } + #csv-root .csv-frozen-row, #csv-root .csv-frozen-column { + position: sticky; + background-color: var(--vscode-editor-background, ${isDark ? '#1e1e1e' : '#ffffff'}) !important; + } + /* Paint collapsed divider seams with the sticky cells instead of the scrolling table. */ + #csv-root .csv-frozen-row::before { + content: ""; + position: absolute; + inset: -0.5px 0; + border-block: 1px solid var(--csv-contrast-border, var(--csv-table-border, ${isDark ? '#555' : '#ccc'})); + box-sizing: border-box; + pointer-events: none; + } + #csv-root .csv-frozen-row.selected, #csv-root .csv-frozen-column.selected { + background-image: linear-gradient(var(--vscode-editor-selectionBackground, ${isDark ? '#333333' : '#cce0ff'}), var(--vscode-editor-selectionBackground, ${isDark ? '#333333' : '#cce0ff'})); + } + #csv-root .csv-frozen-row.highlight, #csv-root .csv-frozen-column.highlight { + background-image: linear-gradient(var(--vscode-editor-findMatchHighlightBackground, transparent), var(--vscode-editor-findMatchHighlightBackground, transparent)); + } + #csv-root .csv-frozen-row.active-match, #csv-root .csv-frozen-column.active-match { + background-image: linear-gradient(var(--vscode-editor-findMatchBackground, transparent), var(--vscode-editor-findMatchBackground, transparent)); + } /* Per-column computed colors */ ${extraColumnColorCss} -
+
${tableHtml}
@@ -2004,6 +2051,7 @@ class CsvEditorController {
+ `; @@ -2267,6 +2315,7 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { public static readonly serialKey = 'csv.serialIndexByUri'; public static readonly sepKey = 'csv.separatorByUri'; public static readonly columnSizesKey = 'csv.columnSizesByUri'; + public static readonly frozenPanesKey = 'csv.frozenPanesByUri'; private static readonly DEFAULT_SEPARATOR = ','; private static readonly DEFAULT_SEPARATOR_MODE: SeparatorMode = 'extension'; private static readonly BUILTIN_SEPARATORS_BY_EXTENSION: Record = { @@ -2637,6 +2686,26 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { await context.workspaceState.update(CsvEditorProvider.serialKey, map); } + public static getFrozenPanesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, header: boolean): FrozenPanes { + const map = context.workspaceState.get>>(CsvEditorProvider.frozenPanesKey, {}); + const saved = map[uri.toString()] ?? {}; + const valid = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + return { rows: valid(saved.rows) ? saved.rows : (header ? 1 : 0), columns: valid(saved.columns) ? saved.columns : 0 }; + } + + public static async setFrozenPanesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, change: Partial): Promise { + const map = { ...context.workspaceState.get>>(CsvEditorProvider.frozenPanesKey, {}) }; + const saved = { ...map[uri.toString()] }; + for (const axis of ['rows', 'columns'] as const) { + const value = change[axis]; + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) { + saved[axis] = value; + } + } + map[uri.toString()] = saved; + await context.workspaceState.update(CsvEditorProvider.frozenPanesKey, map); + } + public static normalizeColumnSizes(raw: unknown): Record { const sizes: Record = {}; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { diff --git a/src/commands.ts b/src/commands.ts index 6241b3d..ae6f429 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,8 +19,27 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref CsvEditorProvider.editors.forEach(ed => ed.refresh()); } +async function changeFrozenPaneCount(axis: 'rows' | 'columns') { + const active = CsvEditorProvider.getActiveProvider(); + if (!active) { + vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); + return; + } + const current = active.getCurrentFrozenPanes()[axis]; + const input = await vscode.window.showInputBox({ + prompt: `Number of leading visible ${axis} to freeze (0 unfreezes)`, + value: String(current), + validateInput: value => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? undefined : 'Enter a non-negative whole number.' + }); + if (input !== undefined && /^\d+$/.test(input) && Number.isSafeInteger(Number(input))) { + await active.setFrozenPanes({ [axis]: Number(input) }); + } +} + export function registerCsvCommands(context: vscode.ExtensionContext) { context.subscriptions.push( + vscode.commands.registerCommand('csv.changeFrozenRows', () => changeFrozenPaneCount('rows')), + vscode.commands.registerCommand('csv.changeFrozenColumns', () => changeFrozenPaneCount('columns')), vscode.commands.registerCommand('csv.toggleExtension', () => toggleBooleanConfig('enabled', true, 'CSV extension') ), diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..7d3e171 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -49,6 +49,9 @@ export async function createEditorHarness(options: { separator?: string; header?: boolean; remote?: boolean; + frozenRows?: number; + frozenColumns?: number; + hiddenRows?: number; } = {}) { const separator = options.separator ?? ','; let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); @@ -84,6 +87,10 @@ export async function createEditorHarness(options: { } }; await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); + if (options.hiddenRows !== undefined) { + await CsvEditorProvider.setHiddenRowsForUri(context as any, uri as any, options.hiddenRows); + } + await CsvEditorProvider.setFrozenPanesForUri(context as any, uri as any, { rows: options.frozenRows, columns: options.frozenColumns }); const panel = { active: true, visible: true, @@ -100,7 +107,7 @@ export async function createEditorHarness(options: { onDidDispose() {} }; const harness = { - document, panel, messages, changeListeners, saveListeners, + document, panel, messages, changeListeners, saveListeners, context, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, @@ -147,6 +154,8 @@ export function createWebviewDom(html: string) { window.HTMLElement.prototype.scrollIntoView = () => {}; window.IntersectionObserver = class { observe() {} }; const source = fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'); + const freezeSource = fs.readFileSync(path.join(process.cwd(), 'media', 'frozen-panes.js'), 'utf8'); + window.eval(freezeSource); window.eval(`${source}\nwindow.editingTestApi = { editCell, setSingleSelection };`); return { window, messages, diff --git a/src/test/frozen-pane-commands.test.ts b/src/test/frozen-pane-commands.test.ts new file mode 100644 index 0000000..0330794 --- /dev/null +++ b/src/test/frozen-pane-commands.test.ts @@ -0,0 +1,44 @@ +import assert from 'assert'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +const commands = new Map Promise>(); +let input: string | undefined; +let prompt: any; +const changes: any[] = []; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + commands: { registerCommand: (name: string, callback: () => Promise) => { commands.set(name, callback); return { dispose() {} }; } }, + window: { showInputBox: async (options: any) => { prompt = options; return input; }, showInformationMessage() {} } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { registerCsvCommands } from '../commands'; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; +registerCsvCommands({ subscriptions: [] } as any); + +describe('Frozen-pane commands', () => { + it('updates the requested axis with validated counts and leaves cancellation unchanged', async () => { + CsvEditorProvider.currentActive = { getCurrentFrozenPanes: () => ({ rows: 3, columns: 1 }), setFrozenPanes: async (change: any) => changes.push(change) } as any; + input = '2'; + await commands.get('csv.changeFrozenRows')!(); + assert.strictEqual(prompt.value, '3'); + assert.strictEqual(prompt.validateInput('0'), undefined); + assert.ok(prompt.validateInput('-1')); + assert.ok(prompt.validateInput('1.5')); + assert.deepStrictEqual(changes, [{ rows: 2 }]); + input = '0'; + await commands.get('csv.changeFrozenColumns')!(); + assert.deepStrictEqual(changes, [{ rows: 2 }, { columns: 0 }]); + input = undefined; + await commands.get('csv.changeFrozenRows')!(); + input = 'invalid'; + await commands.get('csv.changeFrozenRows')!(); + assert.strictEqual(changes.length, 2); + CsvEditorProvider.currentActive = undefined; + }); +}); diff --git a/src/test/frozen-panes.test.ts b/src/test/frozen-panes.test.ts new file mode 100644 index 0000000..bc82ca0 --- /dev/null +++ b/src/test/frozen-panes.test.ts @@ -0,0 +1,353 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); + +function measure(view: ReturnType, heights = [25, 60, 35, 30]) { + for (const row of Array.from(view.window.document.querySelectorAll('tr')) as any[]) { + const index = Number(row.querySelector('[data-row]')?.getAttribute('data-row')); + const height = () => (heights[index] ?? 30) * (parseFloat(view.window.document.body.style.fontSize) / 14 || 1); + row.getBoundingClientRect = () => ({ height: height() }); + for (const cell of Array.from(row.cells) as any[]) { + // JSDOM cannot resolve the color variable in border shorthand. Model the + // browser's fixed 1px width; specific border regressions override it below. + cell.style.borderTopWidth = '1px'; + const col = cell.getAttribute('data-col') === null ? -1 : Number(cell.getAttribute('data-col')); + const width = () => parseFloat(cell.style.width) || (col === -1 ? 30 : (col === 0 ? 90 : 140)); + cell.getBoundingClientRect = () => ({ left: col === -1 ? 0 : 30 + col * 90, right: col === -1 ? 30 : 30 + col * 90 + width(), width: width(), height: height() }); + } + } +} + +// jsdom does not implement pseudo-element computed styles. Match the live +// cell against the real rule's owner selector; browser QA checks raster paint. +function dividerRules(view: ReturnType): any[] { + return (Array.from(view.window.document.styleSheets) as any[]) + .flatMap(sheet => Array.from(sheet.cssRules) as any[]) + .filter(rule => rule.selectorText?.includes('.csv-frozen-row') && rule.selectorText.includes('::before')); +} + +function dividerRule(view: ReturnType, cell: any): any { + return dividerRules(view).find(rule => cell.matches(rule.selectorText.replace(/::before\b/g, ''))); +} + +async function fixture(options: Parameters[0] = {}) { + const host = await createEditorHarness({ rows: [['Name', 'Value'], ['one\nsecond line', 'a'], ['two', 'b'], ['three', 'c']], ...options }); + const view = createWebviewDom(host.panel.webview.html); + measure(view); + return { host, view }; +} + +describe('Frozen panes', () => { + it('paints every frozen row divider, including serial and corner cells, without painting column-only or append cells', async () => { + for (const separator of [',', '\t']) { + for (const header of [true, false]) { + const { host, view } = await fixture({ separator, header, hiddenRows: header ? 0 : 1, frozenRows: header ? 3 : 2, frozenColumns: 1 }); + try { + const document = view.window.document; + const accessibility = document.createElement('style'); + accessibility.textContent = fs.readFileSync(path.join(process.cwd(), 'media', 'high-contrast.css'), 'utf8'); + document.head.append(accessibility); + measure(view, [25.5, 60.25, 35.75, 30]); + view.window.csvFrozenPanes.layout(); + const frozen = Array.from(document.querySelectorAll('.csv-frozen-row')) as any[]; + assert.strictEqual(frozen.length, (header ? 3 : 2) * 3, 'every data and serial cell in every requested row is frozen'); + if (header) { assert.ok(frozen.includes(document.querySelector('thead th')), 'the coordinate-free serial corner is included'); } + const firstRow = header ? 0 : 1; + assert.strictEqual(view.cell(firstRow, 0).style.top, '-0.5px'); + assert.strictEqual(view.cell(2, 0).style.top, header ? '85.25px' : '59.75px', 'fractional measured row spacing is unchanged'); + const geometry = frozen.map(cell => [cell.style.top, cell.style.left, cell.style.height, cell.style.width, cell.getBoundingClientRect().height]); + for (const theme of ['vscode-light', 'vscode-dark', 'vscode-high-contrast', 'vscode-high-contrast-light']) { + document.body.className = theme; + for (const cell of frozen) { + const rule = dividerRule(view, cell); + assert.ok(rule, `${theme}: ${cell.tagName} row ${cell.dataset.row ?? 'corner'} must cover its collapsed divider seam`); + assert.ok(rule.style.getPropertyValue('content'), 'the divider pseudo-element is generated'); + assert.strictEqual(rule.style.getPropertyValue('position'), 'absolute'); + assert.strictEqual(rule.style.getPropertyValue('pointer-events'), 'none', 'divider paint cannot intercept cell selection or editing'); + assert.ok(parseFloat(rule.style.getPropertyValue('inset')) < 0, 'the seam cover extends across the collapsed edge'); + const border = rule.style.getPropertyValue('border-block'); + assert.ok(border.includes('--csv-contrast-border') && border.includes('--csv-table-border'), 'both divider edges follow the existing HC/table border colors'); + assert.strictEqual(rule.style.getPropertyValue('color'), '', 'divider paint does not replace cell foregrounds'); + } + for (const cell of [view.cell(3, 0), view.cell(3, 1), view.cell(4, 0), view.cell(4, -1)]) { + assert.ok(!cell.classList.contains('csv-frozen-row')); + assert.ok(!dividerRule(view, cell), 'column-only, unfrozen and virtual cells do not receive row seam paint'); + } + assert.deepStrictEqual(frozen.map(cell => [cell.style.top, cell.style.left, cell.style.height, cell.style.width, cell.getBoundingClientRect().height]), geometry); + } + view.window.csvFrozenPanes.update(0, 1); + view.window.csvFrozenPanes.layout(); + for (const cell of frozen) { + assert.ok(!cell.classList.contains('csv-frozen-row')); + assert.ok(!dividerRule(view, cell), 'unfreezing removes only the row divider paint'); + assert.strictEqual(cell.style.top, ''); + } + assert.strictEqual(view.cell(firstRow, 0).style.left, '30px', 'column freezing remains intact'); + assert.strictEqual(host.document.version, 1); + assert.strictEqual(host.document.getText(), host.diskText); + } finally { view.close(); } + } + } + }); + + it('keeps frozen selection and Find surfaces, outlines, colors and geometry unchanged by the divider paint rule', async () => { + const { view } = await fixture({ frozenRows: 3, frozenColumns: 1 }); + try { + const document = view.window.document; + const accessibility = document.createElement('style'); + accessibility.textContent = fs.readFileSync(path.join(process.cwd(), 'media', 'high-contrast.css'), 'utf8'); + document.head.append(accessibility); + measure(view, [25.5, 60.25, 35.75, 30]); + view.window.csvFrozenPanes.layout(); + view.cell(0, 0).classList.add('selected'); + view.cell(1, 0).classList.add('highlight'); + view.cell(2, 0).classList.add('active-match'); + const cells = Array.from(document.querySelectorAll('th, td')) as any[]; + const styles = Array.from(document.querySelectorAll('style')) as any[]; + const originalStyles = styles.map(style => style.textContent); + const paintRules = dividerRules(view); + const withoutPaint = styles.map(style => Array.from(style.sheet.cssRules) + .filter((rule: any) => !paintRules.includes(rule)) + .map((rule: any) => rule.cssText).join('\n')); + assert.ok(paintRules.length > 0, 'the actual provider stylesheet supplies the row seam paint'); + const snapshot = () => cells.map(cell => { + const computed = view.window.getComputedStyle(cell); + return ['background-color', 'background-image', 'outline', 'outline-offset', 'color', 'border-top-width', 'padding', 'height', 'width', 'position', 'top', 'left', 'z-index'].map(property => computed.getPropertyValue(property)); + }); + for (const theme of ['vscode-light', 'vscode-dark', 'vscode-high-contrast', 'vscode-high-contrast-light']) { + document.body.className = theme; + const painted = snapshot(); + styles.forEach((style, index) => { style.textContent = withoutPaint[index]; }); + assert.deepStrictEqual(snapshot(), painted, `${theme}: painting the seam changes no existing selection/Find surface, outline, foreground or layout property`); + styles.forEach((style, index) => { style.textContent = originalStyles[index]; }); + assert.deepStrictEqual(snapshot(), painted); + assert.ok(view.cell(0, 0).classList.contains('selected')); + assert.ok(view.cell(1, 0).classList.contains('highlight')); + assert.ok(view.cell(2, 0).classList.contains('active-match')); + } + } finally { view.close(); } + }); + it('keeps measured multiline leading rows and data columns with proper corner stacking', async () => { + const { view } = await fixture({ frozenRows: 3, frozenColumns: 1 }); + try { + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(0, 0).style.top, '-0.5px'); + assert.strictEqual(view.cell(1, 0).style.top, '24.5px'); + assert.strictEqual(view.cell(2, 0).style.top, '84.5px'); + assert.strictEqual(view.cell(1, -1).style.left, '0px'); + assert.strictEqual(view.cell(1, 0).style.left, '30px'); + assert.strictEqual(view.cell(1, 0).style.zIndex, '4'); + assert.strictEqual(view.cell(1, 1).style.zIndex, '3'); + assert.strictEqual(view.cell(3, 0).style.zIndex, '2'); + assert.ok(!view.cell(3, 1).classList.contains('csv-frozen-row')); + assert.ok(!view.cell(4, 0).classList.contains('csv-frozen-row'), 'append row remains virtual'); + } finally { view.close(); } + }); + + it('covers the collapsed top border once without rounding fractional row spacing', async () => { + const { view } = await fixture({ frozenRows: 3, frozenColumns: 1 }); + try { + const table = view.window.document.querySelector('table'); + measure(view, [25.5, 60.25, 35.75, 30]); + for (const [collapse, border, firstTop] of [ + ['collapse', 1, -0.5], + ['collapse', 2, -1], + ['collapse', 0, 0], + ['separate', 1, 0] + ] as const) { + table.style.borderCollapse = collapse; + for (const cell of Array.from(table.querySelectorAll('th, td')) as any[]) { + cell.style.borderTopWidth = `${border}px`; + } + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(0, 0).style.top, `${firstTop}px`); + assert.strictEqual(view.cell(1, 0).style.top, `${firstTop + 25.5}px`); + assert.strictEqual(view.cell(2, 0).style.top, `${firstTop + 85.75}px`); + assert.strictEqual(table.rows[0].cells[0].style.top, `${firstTop}px`, 'serial corner shares the frozen row origin'); + assert.strictEqual(view.cell(0, 0).style.left, '30px', 'horizontal offsets remain measured independently'); + } + } finally { view.close(); } + }); + + it('recomputes measured offsets after zoom and column resizing', async () => { + const { view } = await fixture({ frozenRows: 3, frozenColumns: 2 }); + try { + view.window.csvFrozenPanes.layout(); + view.cell(1, 0).focus(); + view.key('+', { ctrlKey: true }); + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(2, 0).style.top, '93px'); + view.cell(0, 0).style.width = '200px'; + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(1, 1).style.left, '230px'); + } finally { view.close(); } + }); + + it('supports headers off and hidden metadata without freezing the append row', async () => { + const { view } = await fixture({ header: false, frozenRows: 100, hiddenRows: 1 }); + try { + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(1, 0).style.top, '-0.5px'); + assert.strictEqual(view.cell(2, 0).style.top, '59.5px'); + assert.ok(!view.cell(4, 0).classList.contains('csv-frozen-row')); + } finally { view.close(); } + }); + + it('preserves the existing default header freeze and allows explicit zero to unfreeze it', async () => { + const { host, view } = await fixture(); + try { + view.window.csvFrozenPanes.layout(); + assert.ok(view.cell(0, 0).classList.contains('csv-frozen-row')); + await host.receive({ type: 'freezePanesChanged', rows: 0, columns: 0 }); + const update = host.messages.slice().reverse().find((message: any) => message.type === 'frozenPanes'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: update })); + view.window.csvFrozenPanes.layout(); + assert.ok(!view.cell(0, 0).classList.contains('csv-frozen-row')); + assert.strictEqual(view.cell(0, 0).style.top, '', 'unfreezing clears the collapsed-border correction'); + assert.strictEqual(view.cell(1, 0).style.top, ''); + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, host.document.uri as any, true), { rows: 0, columns: 0 }); + } finally { view.close(); } + }); + + it('persists each axis by URI and retains defaults for untouched files/header modes', async () => { + const { host, view } = await fixture(); + try { + const uri = host.document.uri as any; + await CsvEditorProvider.setFrozenPanesForUri(host.context as any, uri, { rows: 3 }); + await CsvEditorProvider.setFrozenPanesForUri(host.context as any, uri, { columns: 2 }); + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, uri, true), { rows: 3, columns: 2 }); + await CsvEditorProvider.setFrozenPanesForUri(host.context as any, uri, { rows: -1, columns: Infinity }); + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, uri, true), { rows: 3, columns: 2 }); + const other = { toString: () => 'file:///other.tsv' } as any; + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, other, true), { rows: 1, columns: 0 }); + assert.deepStrictEqual(CsvEditorProvider.getFrozenPanesForUri(host.context as any, other, false), { rows: 0, columns: 0 }); + } finally { view.close(); } + }); + + it('updates freeze state without replacing an active multiline draft, caret or selection', async () => { + const { host, view } = await fixture(); + try { + const cell = view.cell(1, 0); + view.start(cell); + cell.textContent = 'unsaved\nmultiline draft'; + view.caret(cell.firstChild, 8); + const html = host.panel.webview.html; + const source = host.document.getText(); + const selected = Array.from(view.window.document.querySelectorAll('.selected')); + await host.receive({ type: 'freezePanesChanged', rows: 3, columns: 1 }); + const update = host.messages.slice().reverse().find((message: any) => message.type === 'frozenPanes'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: update })); + view.window.csvFrozenPanes.layout(); + assert.strictEqual(host.panel.webview.html, html); + assert.strictEqual(view.cell(1, 0), cell); + assert.strictEqual(cell.textContent, 'unsaved\nmultiline draft'); + assert.strictEqual(view.window.document.activeElement, cell); + assert.strictEqual(view.window.getSelection().anchorOffset, 8); + assert.deepStrictEqual(Array.from(view.window.document.querySelectorAll('.selected')), selected); + assert.ok(dividerRule(view, cell), 'seam paint also covers the actively edited frozen row'); + assert.strictEqual(host.document.getText(), source); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); } + }); + + it('requests and freezes leading rows extending beyond the initial remote chunk', async () => { + const rows = Array.from({ length: 1100 }, (_, row) => [`row${row}`, 'value']); + const { host, view } = await fixture({ rows, frozenRows: 1002, frozenColumns: 1 }); + try { + view.window.csvFrozenPanes.layout(); + const request = view.messages.find((message: any) => message.type === 'requestChunk'); + assert.ok(request); + await host.receive(request); + const response = host.messages.slice().reverse().find((message: any) => message.type === 'chunkData'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: response })); + view.window.csvFrozenPanes.layout(); + view.window.csvFrozenPanes.layout(); + assert.ok(view.cell(1001, 0).classList.contains('csv-frozen-row')); + assert.ok(dividerRule(view, view.cell(1001, 0)), 'remote rows inherit the same divider paint'); + assert.ok(dividerRule(view, view.cell(1001, -1)), 'remote serial cells are painted too'); + assert.ok(view.cell(1002, 0).classList.contains('csv-frozen-column')); + assert.ok(!dividerRule(view, view.cell(1002, 0))); + const appendRequest = view.messages.slice().reverse().find((message: any) => message.type === 'requestChunk'); + await host.receive(appendRequest); + const appendResponse = host.messages.slice().reverse().find((message: any) => message.type === 'chunkData'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: appendResponse })); + view.window.__csvLoadNextChunk(); + view.window.csvFrozenPanes.layout(); + assert.ok(!view.cell(1100, 0).classList.contains('csv-frozen-row')); + } finally { view.close(); } + }); + + it('freezes through a context-menu target using visible row counts and can unfreeze', async () => { + const { view } = await fixture({ hiddenRows: 1, header: false }); + try { + const target = view.cell(2, 1); + const openMenu = () => { + target.dispatchEvent(new view.window.MouseEvent('mousedown', { button: 2, bubbles: true, cancelable: true })); + target.dispatchEvent(new view.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true })); + }; + const choose = (label: string) => { + const item = Array.from(view.window.document.querySelectorAll('#contextMenu div')).find((element: any) => element.textContent === label) as any; + assert.ok(item, label); + item.click(); + }; + openMenu(); + choose('Freeze rows through this row'); + openMenu(); + choose('Freeze columns through this column'); + view.window.csvFrozenPanes.layout(); + assert.strictEqual(view.cell(2, 1).style.zIndex, '4'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(view.messages.filter((message: any) => message.type === 'freezePanesChanged'))), [ + { type: 'freezePanesChanged', rows: 2, columns: 0 }, { type: 'freezePanesChanged', rows: 2, columns: 2 } + ]); + openMenu(); + choose('Unfreeze rows'); + view.window.csvFrozenPanes.layout(); + assert.ok(!view.cell(2, 1).classList.contains('csv-frozen-row')); + assert.ok(view.cell(2, 1).classList.contains('csv-frozen-column')); + } finally { view.close(); } + }); + + it('observes row-height changes and recalculates sticky offsets on the next frame', async () => { + const { view } = await fixture({ frozenRows: 3 }); + try { + view.window.csvFrozenPanes.dispose(); + let resized = () => {}; + let observed = 0; + let disconnected = false; + view.window.ResizeObserver = class { + constructor(callback: () => void) { resized = callback; } + observe() { observed++; } + disconnect() { disconnected = true; } + }; + const heights = [25, 60, 35, 30]; + measure(view, heights); + const manager = view.window.createFrozenPaneManager({ table: view.window.document.querySelector('table'), firstRow: 0, realRowCount: 4 }); + manager.update(3, 0); + await new Promise(resolve => view.window.requestAnimationFrame(resolve)); + assert.ok(observed > 0); + assert.strictEqual(view.cell(2, 0).style.top, '84.5px'); + heights[1] = 100; + resized(); + await new Promise(resolve => view.window.requestAnimationFrame(resolve)); + assert.strictEqual(view.cell(2, 0).style.top, '124.5px'); + manager.dispose(); + assert.strictEqual(disconnected, true); + } finally { view.close(); } + }); + + it('adds documented command contributions and opaque sticky theme surfaces', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); + const readme = fs.readFileSync(path.join(process.cwd(), 'README.md'), 'utf8'); + for (const command of ['csv.changeFrozenRows', 'csv.changeFrozenColumns']) { + assert.ok(manifest.contributes.commands.some((item: any) => item.command === command)); + assert.ok(manifest.activationEvents.includes(`onCommand:${command}`)); + assert.ok(readme.includes(command)); + } + const provider = fs.readFileSync(path.join(process.cwd(), 'src', 'CsvEditorProvider.ts'), 'utf8'); + assert.ok(provider.includes('background-color: var(--vscode-editor-background,')); + assert.ok(provider.includes('background-image: linear-gradient(var(--vscode-editor-selectionBackground,')); + }); +});