diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f9293..fe9baa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.5.1] - Unreleased + +- Add an icon-only word-wrap action beside the editor title bar’s table/text switch. Default each file to single-line cutoff at the automatic width cap, with Enable/Disable Word Wrap shown on hover. Preserve selection, viewport visibility, live drafts and manual sizes; copying and saving retain complete values. +- Keep fractional column borders resizable without widening the grab area. + ## [1.4.2] - 2026-10-17 This release combines the editing, navigation, and table tools developed since the public 1.3 release. diff --git a/README.md b/README.md index e7c9c10..6b54e6a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Context menus open toward the available space, flip above or left when needed, a - **Serial index:** Right-click an index cell or its top-left corner and choose **Hide serial index column**. Restore it with **CSV: Toggle Serial Index Column**. The numbers identify source rows and can have gaps while filtering. - **Metadata:** **CSV: Hide First N Rows** hides leading records without deleting them. Blank input inherits `csv.defaultIgnoreRows`; an explicit `0` shows them even if the configured default is nonzero. - **Widths:** Automatic column widths account for the longest line in multiline cells. Drag close to a column boundary to resize, including with headers off. Double-click the boundary to restore automatic sizing. Manual widths are remembered per file; narrow columns retain a selectable center. Drag the bottom edge of a serial-index cell to adjust its row height. +- **Word wrap:** Word wrap starts **off** for each file, showing single-line cutoff with an ellipsis. Use the wrap icon beside the table/text switch in the editor title bar to toggle it; hover for **Enable Word Wrap** or **Disable Word Wrap**. Automatic widths remain capped at 100 characters; manual widths and row heights stay intact. Cutoff lets long rows collapse or be resized shorter. The selected cell/range stays selected and its active cell stays in view. Editing always shows the full value, and copying, searching and saving use the complete text. The display choice is remembered with the table's webview state. - **Frozen panes:** Right-click to freeze through a visible row or data column, or use **CSV: Freeze Leading Rows/Columns**. Enter `0` to unfreeze an axis. Counts are remembered per file. By default, the header stays frozen; with headers off, no rows freeze until requested. Hidden metadata and the append row do not count. Frozen rows follow measured multiline/zoomed heights and paint their divider seams; the serial index stays alongside frozen data columns. Header, serial-index, separator, hidden-row, manual-width, and frozen-pane preferences do not change CSV contents and are stored per file URI in VS Code workspace state. Scroll, selection, row heights, and zoom use webview state. Column colors follow the selected datatype palette (`default`, `cool`, or `warm`) or the theme foreground mode. Light, dark, and high-contrast themes retain that choice. CSV's zoom changes the table's text size independently of global editor zoom. @@ -113,6 +114,7 @@ Run these from the Command Palette. Commands that change a view preference targe | Command | Purpose | | --- | --- | | **CSV: Switch Between Table and Text** (`csv.toggleEditor`) | Switch the active supported table/text view. | +| **CSV: Enable/Disable Word Wrap** (`csv.enableWordWrap` / `csv.disableWordWrap`) | Toggle wrapping in the active editable table; also available beside the table/text switch. | | **CSV: Save Untitled Document As…** (`csv.saveUntitled`) | Commit and save the active untitled table through the native owner. | | **CSV: Toggle Extension On/Off** (`csv.toggleExtension`) | Enable/disable CSV and synchronize native editor routing. | | **CSV: Toggle First Row as Header** (`csv.toggleHeader`) | Change the file's header preference. | diff --git a/images/word-wrap-off.png b/images/word-wrap-off.png new file mode 100644 index 0000000..40b5281 Binary files /dev/null and b/images/word-wrap-off.png differ diff --git a/images/word-wrap-on.png b/images/word-wrap-on.png new file mode 100644 index 0000000..8799496 Binary files /dev/null and b/images/word-wrap-on.png differ diff --git a/media/main.js b/media/main.js index 525f3fe..61cc43b 100644 --- a/media/main.js +++ b/media/main.js @@ -68,6 +68,11 @@ let persistedColumnSizes = {}; try { persistedColumnSizes = JSON.parse(root?.dataset?.columnsizes || '{}'); } catch {} let columnSizeState = {}; let rowSizeState = {}; +let wordWrapEnabled = false; +const applyWordWrapMode = () => { + root?.classList.toggle('csv-cutoff', !wordWrapEnabled); + vscode.postMessage({ type: 'wordWrapChanged', enabled: wordWrapEnabled }); +}; const normalizeSizeState = (raw, minSize) => { const out = {}; @@ -176,6 +181,7 @@ const persistState = () => { anchorCol: anchor ? anchor.col : undefined, columnSizes: { ...columnSizeState }, rowSizes: { ...rowSizeState }, + wordWrapEnabled, zoomScale }; vscode.setState(nextState); @@ -248,10 +254,21 @@ const scheduleCellReveal = (cell, center = false) => { }); }; +const setWordWrap = enabled => { + const selectedCell = editingCell || rangeEndCell || anchorCell; + wordWrapEnabled = enabled; + applyWordWrapMode(); + window.csvFrozenPanes?.layout(); + if (selectedCell) scheduleCellReveal(selectedCell); + persistState(); +}; + const restoreState = () => { if (document.activeElement?.closest?.('#filterStatus')) return; try { const st = vscode.getState() || {}; + wordWrapEnabled = st.wordWrapEnabled === true; + applyWordWrapMode(); const restoredZoom = parsePositiveNumber(st.zoomScale); setZoomScale(restoredZoom ?? 1, false); columnSizeState = normalizeSizeState(st.columnSizes ?? persistedColumnSizes, 40); @@ -413,15 +430,20 @@ if (scrollContainer) { } // Persist on blur/visibility change and restore on focus/visibility +const restoreRecreatedView = () => { + // A live DOM already owns its range and caret; a snapshot only has its anchor. + if (anchorCell?.isConnected || editingCell) return; + try { restoreState(); } catch {} +}; window.addEventListener('blur', () => { persistState(); }, { passive: true }); window.addEventListener('focus', () => { - setTimeout(() => { try { restoreState(); } catch {} }, 0); + setTimeout(restoreRecreatedView, 0); }, { passive: true }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { persistState(); } else if (document.visibilityState === 'visible') { - setTimeout(() => { try { restoreState(); } catch {} }, 0); + setTimeout(restoreRecreatedView, 0); } }); @@ -733,7 +755,9 @@ const getResizeEdgeInfo = (target, e) => { 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; + // Collapsed borders can paint almost one CSS pixel into the next cell + // after fractional width rounding. Probe inside the content edge. + if (paintedCellAt(rect.right - Math.min(1.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; @@ -2238,7 +2262,9 @@ const copySelectionToClipboard = (cut = false) => { window.addEventListener('message', event => { const message = event.data; - if (message.type === 'prepareViewRefresh') { + if (message.type === 'setWordWrap') { + if (typeof message.enabled === 'boolean') setWordWrap(message.enabled); + } else if (message.type === 'prepareViewRefresh') { if (editingCell) { editingCell.blur(); } persistState(); vscode.postMessage({ type: 'viewRefreshReady', requestId: message.requestId, session: filterSession, filterDraft: window.csvColumnFilters?.captureDraft() }); diff --git a/package-lock.json b/package-lock.json index fa94b49..ae67513 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "csv", - "version": "1.4.2", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "csv", - "version": "1.4.2", + "version": "1.5.1", "dependencies": { "font-list": "^1.5.1", "papaparse": "^5.5.3", diff --git a/package.json b/package.json index 8b82a6c..4445d03 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "git", "url": "https://github.com/jonaraphael/csv.git" }, - "version": "1.4.2", + "version": "1.5.1", "engines": { "vscode": "^1.70.0", "node": ">=14" @@ -37,7 +37,9 @@ "onCommand:csv.changeEncoding", "onCommand:csv.toggleClickableLinks", "onCommand:csv.changeFrozenRows", - "onCommand:csv.changeFrozenColumns" + "onCommand:csv.changeFrozenColumns", + "onCommand:csv.enableWordWrap", + "onCommand:csv.disableWordWrap" ], "main": "./out/extension.js", "contributes": { @@ -81,6 +83,16 @@ "title": "CSV: Switch Between Table and Text", "icon": "$(arrow-swap)" }, + { + "command": "csv.enableWordWrap", + "title": "CSV: Enable Word Wrap", + "icon": "$(word-wrap)" + }, + { + "command": "csv.disableWordWrap", + "title": "CSV: Disable Word Wrap", + "icon": "$(word-wrap)" + }, { "command": "csv.compareFilesAsTable", "title": "CSV: Compare Files as Table" @@ -141,18 +153,36 @@ { "command": "csv.toggleEditor", "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor || (activeEditor == workbench.editors.files.textFileEditor && resourceScheme != untitled && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i) || (activeEditor == workbench.editors.textResourceEditor && resourceScheme == untitled && editorLangId =~ /^(csv|tsv)$/))", - "group": "navigation" + "group": "navigation@1" + }, + { + "command": "csv.enableWordWrap", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor) && !csv.wordWrapEnabled", + "group": "navigation@2" + }, + { + "command": "csv.disableWordWrap", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor) && csv.wordWrapEnabled", + "group": "navigation@2" }, { "command": "csv.saveUntitled", "when": "activeWebviewPanelId == csv.untitledEditor", - "group": "navigation@1" + "group": "navigation@3" } ], "commandPalette": [ { "command": "csv.saveUntitled", "when": "activeWebviewPanelId == csv.untitledEditor" + }, + { + "command": "csv.enableWordWrap", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor) && !csv.wordWrapEnabled" + }, + { + "command": "csv.disableWordWrap", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor) && csv.wordWrapEnabled" } ], "explorer/context": [ diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 965e5ff..7fabbe3 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -112,6 +112,7 @@ class CsvEditorController { private separatorCache: { version: number; configKey: string; separator: string } | undefined; private isDiffContext = false; private currentHeader = false; + private wordWrapEnabled = false; private chunkRenderState: ChunkRenderState | undefined; private currentFilterView: FilterRender | undefined; private latestFilterRevision = 0; @@ -148,6 +149,7 @@ class CsvEditorController { this.resolveDraftCommitWaiters(false); if (CsvEditorProvider.currentActive === this) { CsvEditorProvider.currentActive = undefined; + void vscode.commands.executeCommand('setContext', 'csv.wordWrapEnabled', false); } }); if (_token.isCancellationRequested) { return; } @@ -180,6 +182,7 @@ class CsvEditorController { if (webviewPanel.active) { CsvEditorProvider.currentActive = this; + this.updateWordWrapContext(); } void this.warnAboutPossibleDecoding().catch(error => { console.warn('CSV: Encoding warning action failed', error); @@ -199,12 +202,21 @@ class CsvEditorController { } e.webviewPanel.webview.postMessage({ type: 'focus' }); CsvEditorProvider.currentActive = this; + this.updateWordWrapContext(); } else if (CsvEditorProvider.currentActive === this) { CsvEditorProvider.currentActive = undefined; + void vscode.commands.executeCommand('setContext', 'csv.wordWrapEnabled', false); } }); webviewPanel.webview.onDidReceiveMessage(e => { + if (e.type === 'wordWrapChanged') { + if (typeof e.enabled === 'boolean') { + this.wordWrapEnabled = e.enabled; + this.updateWordWrapContext(); + } + return; + } if (e.type === 'filterDraftChanged') { if (e.session === this.filterSession) { this.draftActivityRevision += 1; } return; @@ -1020,6 +1032,16 @@ class CsvEditorController { return CsvEditorProvider.getFrozenPanesForUri(this.context, this.document.uri, this.currentHeader); } + private updateWordWrapContext(): void { + if (this.isActive()) { + void vscode.commands.executeCommand('setContext', 'csv.wordWrapEnabled', this.wordWrapEnabled); + } + } + + public setWordWrap(enabled: boolean): void { + void this.currentWebviewPanel?.webview.postMessage({ type: 'setWordWrap', enabled }); + } + public async setFrozenPanes(change: Partial): Promise { await CsvEditorProvider.setFrozenPanesForUri(this.context, this.document.uri, change); this.currentWebviewPanel?.webview.postMessage({ type: 'frozenPanes', ...this.getCurrentFrozenPanes() }); @@ -2536,6 +2558,7 @@ class CsvEditorController { th, td { padding: ${cellPadding}px 8px; border: 1px solid var(--csv-table-border, ${isDark ? '#555' : '#ccc'}); font-size: inherit; } 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; } + #csv-root.csv-cutoff td:not(.editing) { overflow: hidden !important; white-space: nowrap !important; overflow-wrap: normal !important; text-overflow: ellipsis; } 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; } .highlight { background-color: var(--csv-find-highlight-background, ${isDark ? '#2a2a2a' : '#fefefe'}) !important; } @@ -2731,6 +2754,17 @@ class CsvEditorController { box-sizing: border-box; pointer-events: none; } + /* An outline paints the divider outside clipped cell contents. Keep its + specificity below the high-contrast selection and Find outlines. */ + .csv-cutoff .csv-frozen-row { + outline: 2px solid var(--csv-contrast-border, var(--csv-table-border, ${isDark ? '#555' : '#ccc'})); + outline-offset: -1px; + } + /* Retain state outline colors while covering the outer seam in HC themes. */ + .csv-cutoff .csv-frozen-row:is(.selected, .highlight, .active-match) { + outline-width: 2px; + outline-offset: -1px; + } #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'})); } @@ -2757,7 +2791,7 @@ class CsvEditorController { -
+
${tableHtml}
diff --git a/src/commands.ts b/src/commands.ts index e890437..a4ccd68 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -72,6 +72,8 @@ export function registerCsvCommands(context: vscode.ExtensionContext, synchroniz vscode.commands.registerCommand('csv.changeFrozenColumns', () => changeFrozenPaneCount('columns')), vscode.commands.registerCommand('csv.openLargeFilePreview', openLargeFilePreview), vscode.commands.registerCommand('csv.toggleEditor', () => toggleCsvEditor(prepareEditorRouting)), + vscode.commands.registerCommand('csv.enableWordWrap', () => CsvEditorProvider.getActiveProvider()?.setWordWrap(true)), + vscode.commands.registerCommand('csv.disableWordWrap', () => CsvEditorProvider.getActiveProvider()?.setWordWrap(false)), vscode.commands.registerCommand('csv.compareFilesAsTable', (resource?: vscode.Uri, selected?: vscode.Uri[]) => compareFilesAsTable(context, resource, selected)), vscode.commands.registerCommand('csv.saveUntitled', saveActiveUntitledCsvEditor), vscode.commands.registerCommand('csv.toggleExtension', () => { diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 6da5ede..bcae523 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -65,6 +65,10 @@ const vscodeStub = { return { dispose() {} }; }, executeCommand: async (name: string, ...args: unknown[]) => { + if (name === 'setContext') { + activeHarness.contextUpdates.push(args); + return; + } activeHarness.executedCommands.push({ name, args }); activeHarness.recordedCommands.push([name, ...args]); if (activeHarness.commandError) { @@ -250,6 +254,7 @@ export async function createEditorHarness(options: { closeResult: true, commands: new Map unknown>(), executedCommands: [] as Array<{ name: string; args: unknown[] }>, + contextUpdates: [] as unknown[][], registerCommands() { const { registerCsvCommands } = require('../commands') as typeof import('../commands'); registerCsvCommands(context as any); @@ -335,11 +340,11 @@ export async function createEditorHarness(options: { return harness; } -export function createWebviewDom(html: string) { +export function createWebviewDom(html: string, initialState: unknown = {}) { const dom = new JSDOM(html, { runScripts: 'outside-only', pretendToBeVisual: true }); const { window } = dom; const messages: any[] = []; - let state: unknown = {}; + let state: unknown = initialState; window.acquireVsCodeApi = () => ({ postMessage: (message: unknown) => messages.push(message), getState: () => state, diff --git a/src/test/editor-lifecycle.test.ts b/src/test/editor-lifecycle.test.ts index 2d81a67..d5254a0 100644 --- a/src/test/editor-lifecycle.test.ts +++ b/src/test/editor-lifecycle.test.ts @@ -56,6 +56,7 @@ const vscode = { }, commands: { executeCommand: async (...args: any[]) => { + if (args[0] === 'setContext') { return; } state.commands.push(args); await state.executeCommand(...args); } diff --git a/src/test/high-contrast.test.ts b/src/test/high-contrast.test.ts index a017aa4..279df77 100644 --- a/src/test/high-contrast.test.ts +++ b/src/test/high-contrast.test.ts @@ -16,6 +16,7 @@ Module.prototype.require = function (id: string) { if (id === 'vscode') { return { ColorThemeKind: kinds, + commands: { executeCommand: async () => {} }, window: { activeColorTheme: theme }, Uri: { file: (filePath: string) => ({ fsPath: filePath, toString: () => `test-resource:${filePath}` }) }, workspace: { diff --git a/src/test/provider-clipboard.test.ts b/src/test/provider-clipboard.test.ts index ab84de4..1af2c34 100644 --- a/src/test/provider-clipboard.test.ts +++ b/src/test/provider-clipboard.test.ts @@ -18,6 +18,7 @@ Module.prototype.require = function (id: string) { env: { clipboard: { writeText: (text: string) => writeClipboard(text), readText: () => readClipboard() } }, window: { activeColorTheme: { kind: 1 }, showErrorMessage: (message: string) => errors.push(message) }, ColorThemeKind: { Dark: 1 }, + commands: { executeCommand: async () => {} }, workspace: { getConfiguration: () => ({ get: (key: string, fallback: any) => key === 'maxFileSizeMB' ? 0 : fallback }), onDidChangeTextDocument: () => ({ dispose() {} }), diff --git a/src/test/resize-hit-testing.test.ts b/src/test/resize-hit-testing.test.ts index 85349e5..9960c3d 100644 --- a/src/test/resize-hit-testing.test.ts +++ b/src/test/resize-hit-testing.test.ts @@ -125,6 +125,28 @@ describe('Resize boundary event hit testing', () => { } finally { view.close(); } }); + for (const header of [true, false]) { + it(`resizes a cutoff column when fractional border paint rounds into its neighbor (header ${header})`, async () => { + const view = await fixture(header); + try { + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: { type: 'setWordWrap', enabled: false } })); + const first = view.cell(0, 0), next = view.cell(0, 1); + view.fixed.set(first, { left: 30, right: 130.359, top: 0, bottom: 30, width: 100.359, height: 30 }); + view.fixed.set(next, { left: 130.359, right: 147.359, top: 0, bottom: 30, width: 17, height: 30 }); + const hit = view.window.document.elementFromPoint; + view.window.document.elementFromPoint = (x: number, y: number) => + x >= 129.484 && x < 130.359 && y >= 0 && y <= 30 ? next : hit(x, y); + view.mouse(first, 'mousemove', 129.359); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + assert.strictEqual(view.mouse(first, 'mousedown', 129.359).defaultPrevented, true); + view.mouse(first, 'mousemove', 149.359); + view.mouse(first, 'mouseup', 149.359); + assert.strictEqual(first.style.width, '120px'); + assert.strictEqual(next.style.width, ''); + } finally { view.close(); } + }); + } + for (const x of [149, 151]) { it(`shares hover/drag/reset on either side of a partially occluded frozen seam at ${x}`, async () => { const view = await fixture(false); diff --git a/src/test/theme-transition.test.ts b/src/test/theme-transition.test.ts index d1bdf1f..ed21f4d 100644 --- a/src/test/theme-transition.test.ts +++ b/src/test/theme-transition.test.ts @@ -14,6 +14,7 @@ Module.prototype.require = function (id: string) { if (id === 'vscode') { return { ColorThemeKind: kinds, window: { activeColorTheme: theme }, + commands: { executeCommand: async () => {} }, Uri: { file: (filePath: string) => ({ fsPath: filePath, toString: () => `test:${filePath}` }) }, workspace: { getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : (settings[key] ?? fallback) }), diff --git a/src/test/webview-word-wrap.test.ts b/src/test/webview-word-wrap.test.ts new file mode 100644 index 0000000..fabeda0 --- /dev/null +++ b/src/test/webview-word-wrap.test.ts @@ -0,0 +1,168 @@ +import assert from 'assert'; +import fs from 'fs'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +const paint = (view: ReturnType) => + new Promise(resolve => view.window.requestAnimationFrame(() => resolve())); + +const setWrap = (view: ReturnType, enabled: boolean) => + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: { type: 'setWordWrap', enabled } })); + +describe('Word wrap display toggle', () => { + it('contributes mutually exclusive icon actions immediately beside the table/text switch', () => { + const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const actions = manifest.contributes.menus['editor/title']; + const mode = actions.find((entry: any) => entry.command === 'csv.toggleEditor'); + for (const [command, condition] of [['csv.enableWordWrap', '!csv.wordWrapEnabled'], ['csv.disableWordWrap', 'csv.wordWrapEnabled']]) { + const action = actions.find((entry: any) => entry.command === command); + const contribution = manifest.contributes.commands.find((entry: any) => entry.command === command); + assert.strictEqual(action.group, 'navigation@2'); + assert.strictEqual(mode.group, 'navigation@1'); + assert.ok(action.when.endsWith(` && ${condition}`)); + assert.match(action.when, /activeCustomEditorId == csv\.editor/); + assert.match(action.when, /activeWebviewPanelId == csv\.untitledEditor/); + assert.strictEqual(contribution.icon, '$(word-wrap)'); + assert.ok(manifest.activationEvents.includes(`onCommand:${command}`)); + } + }); + + it('routes title actions to the active table and follows restored state across view changes', async () => { + const host = await createEditorHarness(); + try { + host.registerCommands(); + await host.commands.get('csv.enableWordWrap')!(); + assert.deepStrictEqual(host.messages.at(-1), { type: 'setWordWrap', enabled: true }); + await host.receive({ type: 'wordWrapChanged', enabled: true }); + const context = () => host.contextUpdates.at(-1); + assert.deepStrictEqual(context(), ['csv.wordWrapEnabled', true]); + await host.receive({ type: 'wordWrapChanged', enabled: 'false' }); + assert.deepStrictEqual(context(), ['csv.wordWrapEnabled', true]); + await host.view(false, true); + const cursor = host.messages.length; + await host.commands.get('csv.disableWordWrap')!(); + assert.strictEqual(host.messages.length, cursor, 'an inactive table cannot be changed by a title action'); + await host.receive({ type: 'wordWrapChanged', enabled: false }); + await host.view(true, true); + assert.deepStrictEqual(context(), ['csv.wordWrapEnabled', false]); + await host.commands.get('csv.disableWordWrap')!(); + assert.deepStrictEqual(host.messages.at(-1), { type: 'setWordWrap', enabled: false }); + assert.strictEqual(host.document.version, 1); + } finally { host.dispose(); } + }); + + for (const separator of [',', '\t']) { + it(`preserves a selected range and full clipboard values (${JSON.stringify(separator)})`, async () => { + const value = 'complete value\n' + 'long text '.repeat(200); + const host = await createEditorHarness({ rows: [['Name', 'Value'], [value, 'two'], ['three', 'four']], separator }); + const view = createWebviewDom(host.panel.webview.html); + try { + await paint(view); + view.window.editingTestApi.setSingleSelection(view.cell(1, 0)); + view.window.editingTestApi.selectRange({ row: 1, col: 0 }, { row: 2, col: 1 }); + const selection = () => Array.from(view.window.document.querySelectorAll('.selected')); + const before = selection(); + view.window.dispatchEvent(new view.window.Event('focus')); + await paint(view); + assert.deepStrictEqual(selection(), before, 'returning from a native title action must retain the live range'); + assert.ok(view.window.document.getElementById('csv-root').classList.contains('csv-cutoff')); + assert.strictEqual(view.window.document.querySelector('#csvViewToolbar, #toggleWordWrap'), null, 'display control lives in the native title bar'); + setWrap(view, true); + setWrap(view, false); + assert.strictEqual((view.state() as any).wordWrapEnabled, false); + assert.deepStrictEqual(selection(), before); + assert.strictEqual(view.window.document.activeElement, view.cell(1, 0)); + assert.strictEqual(view.cell(1, 0).textContent, value); + view.cell(1, 0).dispatchEvent(new view.window.Event('copy', { bubbles: true, cancelable: true })); + const copied = JSON.parse(JSON.stringify(view.messages.find(message => message.type === 'copyCells').matrix)); + assert.deepStrictEqual(copied, [[value, 'two'], ['three', 'four']]); + setWrap(view, true); + assert.strictEqual((view.state() as any).wordWrapEnabled, true); + assert.deepStrictEqual(selection(), before); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); host.dispose(); } + }); + } + + it('does not blur, commit, replace or move the caret in a live cell draft', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + await paint(view); + const cell = view.cell(); + view.start(cell); + cell.textContent = 'unsaved draft\nwith a second line'; + view.caret(cell.firstChild, 4); + const cursor = view.messages.length; + setWrap(view, true); + await paint(view); + assert.strictEqual(view.window.document.activeElement, cell); + assert.ok(cell.classList.contains('editing')); + assert.strictEqual(view.window.getSelection().anchorNode, cell.firstChild); + assert.strictEqual(view.window.getSelection().anchorOffset, 4); + assert.strictEqual(cell.textContent, 'unsaved draft\nwith a second line'); + assert.strictEqual(view.messages.slice(cursor).some(message => message.type === 'editCell'), false); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); host.dispose(); } + }); + + it('restores a file’s wrap choice and manual sizes', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html, { + wordWrapEnabled: true, columnSizes: { 0: 150 }, rowSizes: { 1: 32 }, anchorRow: 1, anchorCol: 0 + }); + try { + await paint(view); + assert.ok(!view.window.document.getElementById('csv-root').classList.contains('csv-cutoff')); + const cell = view.cell(); + assert.strictEqual(cell.style.width, '150px'); + assert.strictEqual(cell.style.height, '32px'); + setWrap(view, false); + const state = view.state() as any; + assert.strictEqual(state.wordWrapEnabled, false); + assert.strictEqual(state.columnSizes[0], 150); + assert.strictEqual(state.rowSizes[1], 32); + } finally { view.close(); host.dispose(); } + }); + + it('reveals the same selected cell after row geometry shrinks or expands beneath frozen headers', async () => { + const rows = [['Name', 'Value'], ...Array.from({ length: 60 }, (_, i) => [`row ${i}\nmultiline`, String(i)])]; + const host = await createEditorHarness({ rows }); + const view = createWebviewDom(host.panel.webview.html); + try { + const root = view.window.document.getElementById('csv-root'); + const table = root.querySelector('table'); + let top = 0; + Object.defineProperties(root, { + clientWidth: { value: 300 }, clientHeight: { value: 180 }, clientTop: { value: 0 }, clientLeft: { value: 0 }, + scrollTop: { get: () => top, set: (value: number) => { top = Math.max(0, value); } } + }); + root.getBoundingClientRect = () => ({ top: 32, bottom: 212, left: 0, right: 300, width: 300, height: 180 }); + root.scrollTo = (position: any) => { top = Math.max(0, position.top); }; + const height = () => root.classList.contains('csv-cutoff') ? 24 : 80; + for (const row of Array.from(table.rows) as any[]) { + const index = Number(row.cells[0].getAttribute('data-row')); + row.getBoundingClientRect = () => ({ height: index === 0 ? 24 : height() }); + for (const cell of Array.from(row.cells) as any[]) { + cell.getBoundingClientRect = () => { + const h = index === 0 ? 24 : height(); + const y = index === 0 ? 32 : 56 + (index - 1) * height() - top; + return { top: y, bottom: y + h, left: 40, right: 140, width: 100, height: h }; + }; + } + } + await paint(view); + const target = view.cell(40, 0); + view.window.editingTestApi.setSingleSelection(target); + await paint(view); + for (const enabled of [true, false]) { + setWrap(view, enabled); + await paint(view); + const rect = target.getBoundingClientRect(); + assert.ok(target.classList.contains('selected')); + assert.strictEqual(view.window.document.activeElement, target); + assert.ok(rect.top >= 56 && rect.bottom <= 212, 'selected cell stays inside the usable viewport'); + } + } finally { view.close(); host.dispose(); } + }); +});