From c09b18c5b400ef92dc61abc6d724aab50c7a2cbc Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:25:23 -0400 Subject: [PATCH 1/5] Add a safe table and text view switch (#84) --- README.md | 2 + package.json | 15 +++ scripts/editor-switch-smoke.cjs | 50 ++++++++ src/commands.ts | 2 + src/editor-switch.ts | 63 ++++++++++ src/test/editor-switch.test.ts | 201 ++++++++++++++++++++++++++++++++ 6 files changed, 333 insertions(+) create mode 100644 scripts/editor-switch-smoke.cjs create mode 100644 src/editor-switch.ts create mode 100644 src/test/editor-switch.test.ts diff --git a/README.md b/README.md index 7dec9d6..8cca5fd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: ## Features - **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Saves on blur. +- **Table/Text Switching:** Use the switch action in the editor toolbar or `CSV: Switch Between Table and Text` in the Command Palette to move between views while retaining unsaved edits. Both representations may remain open; the action reuses the counterpart tab, with at most one of each view in the editor group. The table view requires `csv.enabled`. - **Smart Resizing:** Automatic calculation of column widths for improved readability. - **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. @@ -82,6 +83,7 @@ Cursor (built on VS Code 1.99) and the latest VS Code releases (1.102). Open the Command Palette and search for: +- `CSV: Switch Between Table and Text` (`csv.toggleEditor`) - `CSV: Toggle Extension On/Off` (`csv.toggleExtension`) - `CSV: Toggle First Row as Header` (`csv.toggleHeader`) - `CSV: Toggle Serial Index Column` (`csv.toggleSerialIndex`) diff --git a/package.json b/package.json index 31d2bda..65dd935 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "onLanguage:csv", "onLanguage:tsv", "onCommand:csv.toggleExtension", + "onCommand:csv.toggleEditor", "onCommand:csv.toggleHeader", "onCommand:csv.toggleSerialIndex", "onCommand:csv.changeSeparator", @@ -64,6 +65,11 @@ } ], "commands": [ + { + "command": "csv.toggleEditor", + "title": "CSV: Switch Between Table and Text", + "icon": "$(arrow-swap)" + }, { "command": "csv.toggleExtension", "title": "CSV: Toggle Extension On/Off" @@ -101,6 +107,15 @@ "title": "CSV: Toggle Clickable Links" } ], + "menus": { + "editor/title": [ + { + "command": "csv.toggleEditor", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || (activeEditor == workbench.editors.textEditor && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i))", + "group": "navigation" + } + ] + }, "configuration": { "type": "object", "title": "CSV Extension Configuration", diff --git a/scripts/editor-switch-smoke.cjs b/scripts/editor-switch-smoke.cjs new file mode 100644 index 0000000..fe24b44 --- /dev/null +++ b/scripts/editor-switch-smoke.cjs @@ -0,0 +1,50 @@ +// Run in an isolated VS Code development host using --extensionTestsPath. +const assert = require('assert').strict; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const vscode = require('vscode'); + +exports.run = async function () { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'csv-editor-switch-')); + const extension = vscode.extensions.getExtension('ReprEng.csv'); + assert.ok(extension, 'development extension must be loaded'); + await extension.activate(); + try { + for (const [suffix, contents] of [['csv', 'name,value\nfirst,1'], ['tsv', 'name\tvalue\nfirst\t1']]) { + const filename = path.join(directory, `data.${suffix}`); + fs.writeFileSync(filename, contents); + const uri = vscode.Uri.file(filename); + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { preview: false }); + const edit = new vscode.WorkspaceEdit(); + edit.insert(uri, new vscode.Position(1, 0), 'unsaved-'); + assert.equal(await vscode.workspace.applyEdit(edit), true); + for (let round = 0; round < 2; round++) { + for (const viewType of ['csv.editor', 'default']) { + await vscode.commands.executeCommand('csv.toggleEditor'); + const group = vscode.window.tabGroups.activeTabGroup; + const tabs = group.tabs.filter(tab => tab.input.uri?.toString() === uri.toString()); + assert.equal(tabs.length, 2, 'repeated switches must reuse the two existing views'); + assert.equal(tabs.filter(tab => tab.input instanceof vscode.TabInputCustom).length, 1); + assert.equal(tabs.filter(tab => tab.input instanceof vscode.TabInputText).length, 1); + if (viewType === 'csv.editor') { + assert.equal(group.activeTab.input.viewType, viewType); + } else { + assert.ok(group.activeTab.input instanceof vscode.TabInputText); + } + const live = vscode.workspace.textDocuments.find(document => document.uri.toString() === uri.toString()); + assert.ok(live, 'the live document must remain open'); + assert.equal(live.isDirty, true); + assert.ok(live.getText().includes('unsaved-first')); + assert.equal(fs.readFileSync(filename, 'utf8'), contents, 'switching must not save'); + } + } + await vscode.commands.executeCommand('workbench.action.files.revert'); + const tabs = vscode.window.tabGroups.all.flatMap(group => group.tabs).filter(tab => tab.input.uri?.toString() === uri.toString()); + await vscode.window.tabGroups.close(tabs); + } + console.log('CSV_SWITCH_SMOKE_OK: dirty CSV+TSV preserved, disk unchanged, exactly one tab per view across repeated switches'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}; diff --git a/src/commands.ts b/src/commands.ts index 6241b3d..db2d96a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,7 @@ import { getFonts } from 'font-list'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; +import { toggleCsvEditor } from './editor-switch'; function displaySeparator(separator: string): string { return separator === '\t' ? '\\t' : separator; @@ -21,6 +22,7 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref export function registerCsvCommands(context: vscode.ExtensionContext) { context.subscriptions.push( + vscode.commands.registerCommand('csv.toggleEditor', toggleCsvEditor), vscode.commands.registerCommand('csv.toggleExtension', () => toggleBooleanConfig('enabled', true, 'CSV extension') ), diff --git a/src/editor-switch.ts b/src/editor-switch.ts new file mode 100644 index 0000000..84ac6b7 --- /dev/null +++ b/src/editor-switch.ts @@ -0,0 +1,63 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { CsvEditorProvider } from './CsvEditorProvider'; + +const switchingTabs = new Set(); +const supportedExtensions = new Set(['.csv', '.tsv', '.tab', '.psv']); + +function getInputUri(input: unknown, viewType: string): vscode.Uri | undefined { + if (viewType === 'default') { + return input instanceof vscode.TabInputText ? input.uri : undefined; + } + return input instanceof vscode.TabInputCustom && input.viewType === viewType ? input.uri : undefined; +} + +export async function toggleCsvEditor(): Promise { + const group = vscode.window.tabGroups.activeTabGroup; + const tab = group.activeTab; + if (!tab) { + return; + } + const tableUri = getInputUri(tab.input, CsvEditorProvider.viewType); + const uri = tableUri ?? getInputUri(tab.input, 'default'); + if (!uri || !supportedExtensions.has(path.posix.extname(uri.path).toLowerCase())) { + return; + } + const source = tableUri ? CsvEditorProvider.viewType : 'default'; + const target = tableUri ? 'default' : CsvEditorProvider.viewType; + const switchKey = `${group.viewColumn}:${source}:${uri.toString()}`; + if (switchingTabs.has(switchKey)) { + return; + } + if (target === CsvEditorProvider.viewType && !vscode.workspace.getConfiguration('csv', uri).get('enabled', true)) { + vscode.window.showInformationMessage('Enable the CSV extension to switch to the table view.'); + return; + } + + switchingTabs.add(switchKey); + try { + if (tab.isPreview) { + // Keep an active table draft alive when opening its counterpart; a second + // preview could otherwise replace this tab before the draft is committed. + await vscode.commands.executeCommand('workbench.action.keepEditor'); + const current = vscode.window.tabGroups.activeTabGroup; + if (current.viewColumn !== group.viewColumn || + getInputUri(current.activeTab?.input, source)?.toString() !== uri.toString()) { + return; + } + } + // Reuse/open the other representation without closing either view. Closing + // even a shared dirty editor can prompt to save/discard its document, and a + // table may still have an active draft that has not reached the document yet. + await vscode.commands.executeCommand('vscode.openWith', uri, target, { + viewColumn: group.viewColumn, + preserveFocus: false, + preview: false + }); + } catch (error) { + console.error('CSV: could not switch editor', error); + vscode.window.showErrorMessage('CSV: Could not switch between the table and text editor.'); + } finally { + switchingTabs.delete(switchKey); + } +} diff --git a/src/test/editor-switch.test.ts b/src/test/editor-switch.test.ts new file mode 100644 index 0000000..d5a56b7 --- /dev/null +++ b/src/test/editor-switch.test.ts @@ -0,0 +1,201 @@ +import assert from 'assert'; +import fs from 'fs'; +import Module from 'module'; +import path from 'path'; +import { beforeEach, describe, it } from 'node:test'; + +class Uri { + constructor(public scheme: string, public authority: string, public path: string) {} + toString() { return `${this.scheme}://${this.authority}${this.path}`; } +} +class TabInputText { + constructor(public uri: Uri) {} +} +class TabInputCustom { + constructor(public uri: Uri, public viewType: string) {} +} +class TabInputTextDiff { + constructor(public original: Uri, public modified: Uri) {} +} +const state = { + group: { viewColumn: 2, tabs: [] as any[], activeTab: undefined as any }, + enabled: true, + events: [] as any[][], + errors: [] as string[], + information: [] as string[], + registered: new Map any>(), + open: async (_uri: Uri, _viewType: string): Promise => {} +}; +const vscode = { + Uri, TabInputText, TabInputCustom, + window: { + tabGroups: { + get activeTabGroup() { return state.group; }, + close: () => { assert.fail('switching must never close an editor or discard a pending draft'); } + }, + showErrorMessage: (message: string) => { state.errors.push(message); }, + showInformationMessage: (message: string) => { state.information.push(message); } + }, + workspace: { + getConfiguration: () => ({ get: () => state.enabled }) + }, + commands: { + executeCommand: async (command: string, uri?: Uri, viewType?: string, options?: any) => { + if (command === 'workbench.action.keepEditor') { + state.events.push([command]); + state.group.activeTab.isPreview = false; + return; + } + state.events.push([command, uri, viewType, options]); + await state.open(uri!, viewType!); + }, + registerCommand: (name: string, callback: (...args: any[]) => any) => { + state.registered.set(name, callback); + return { dispose() {} }; + } + } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { return vscode; } + if (id === 'font-list') { return { getFonts: async () => [] }; } + return originalRequire.apply(this, arguments as any); +}; +import { registerCsvCommands } from '../commands'; +Module.prototype.require = originalRequire; + +function makeTab(uri: Uri, table: boolean, preview = false) { + return { + input: table ? new TabInputCustom(uri, 'csv.editor') : new TabInputText(uri), + isPreview: preview, + isDirty: true + }; +} +function activateTab(tab: any) { + state.group.tabs = [tab]; + state.group.activeTab = tab; +} +async function toggle() { + await state.registered.get('csv.toggleEditor')!(); +} + +beforeEach(() => { + state.group = { viewColumn: 2, tabs: [], activeTab: undefined }; + state.enabled = true; + state.events = []; + state.errors = []; + state.information = []; + state.registered.clear(); + registerCsvCommands({ subscriptions: [] } as any); + state.open = async (uri, viewType) => { + const table = viewType === 'csv.editor'; + let target = state.group.tabs.find(tab => + tab.input.uri.toString() === uri.toString() && + (table ? tab.input instanceof TabInputCustom : tab.input instanceof TabInputText) + ); + if (!target) { + target = makeTab(uri, table); + state.group.tabs.push(target); + } + state.group.activeTab = target; + }; +}); + +describe('CSV table/text switch command', () => { + for (const scheme of ['file', 'vscode-remote']) { + for (const extension of ['csv', 'tsv', 'tab', 'psv', 'CSV']) { + it(`reuses ${scheme} .${extension} views in both directions without closing either`, async () => { + const uri = new Uri(scheme, scheme === 'file' ? '' : 'wsl+Ubuntu', `/data/example.${extension}`); + const original = makeTab(uri, false); + activateTab(original); + const unrelated = makeTab(new Uri('file', '', '/other.csv'), false); + state.group.tabs.push(unrelated); + await toggle(); + assert.deepStrictEqual(state.events, [['vscode.openWith', uri, 'csv.editor', { viewColumn: 2, preserveFocus: false, preview: false }]]); + assert.strictEqual(state.group.activeTab.input.uri, uri); + assert.strictEqual(state.group.activeTab.isDirty, true); + assert.strictEqual(state.group.viewColumn, 2); + assert.ok(state.group.tabs.includes(unrelated)); + await toggle(); + assert.strictEqual(state.events[1][2], 'default'); + assert.strictEqual(state.group.activeTab.input.uri, uri); + await toggle(); + await toggle(); + assert.strictEqual(state.group.tabs.filter(tab => tab.input.uri.toString() === uri.toString()).length, 2); + assert.strictEqual(state.errors.length, 0); + }); + } + } + + it('pins a preview before opening the counterpart so an active grid draft is retained', async () => { + const uri = new Uri('file', '', '/data.csv'); + const original = makeTab(uri, true, true); + activateTab(original); + await toggle(); + assert.deepStrictEqual(state.events[0], ['workbench.action.keepEditor']); + assert.strictEqual(state.events[1][0], 'vscode.openWith'); + assert.strictEqual(state.events[1][3].preview, false); + assert.ok(state.group.tabs.includes(original)); + assert.strictEqual(original.isPreview, false); + }); + + it('leaves the dirty source intact if opening the counterpart fails', async () => { + const original = makeTab(new Uri('file', '', '/data.csv'), false); + activateTab(original); + state.open = async () => { throw new Error('open failed'); }; + await toggle(); + assert.deepStrictEqual(state.group.tabs, [original]); + assert.strictEqual(original.isDirty, true); + assert.strictEqual(state.errors.length, 1); + }); + + it('coalesces duplicate clicks while opening the same counterpart', async () => { + activateTab(makeTab(new Uri('file', '', '/data.csv'), false)); + let finishOpen!: () => void; + state.open = () => new Promise(resolve => { finishOpen = resolve; }); + const pending = toggle(); + await toggle(); + finishOpen(); + await pending; + assert.strictEqual(state.events.length, 1); + }); + + it('respects disabled state when opening the table without changing configuration', async () => { + state.enabled = false; + activateTab(makeTab(new Uri('file', '', '/data.tsv'), false)); + await toggle(); + assert.strictEqual(state.events.length, 0); + assert.strictEqual(state.enabled, false); + assert.strictEqual(state.information.length, 1); + }); + + it('can leave the table for source text even if the extension has been disabled', async () => { + state.enabled = false; + activateTab(makeTab(new Uri('file', '', '/data.tsv'), true)); + await toggle(); + assert.strictEqual(state.events[0][2], 'default'); + }); + + it('ignores unrelated custom editors, diff tabs, unsupported files, and empty groups', async () => { + const uri = new Uri('file', '', '/data.csv'); + for (const input of [new TabInputCustom(uri, 'another.editor'), new TabInputTextDiff(uri, uri), new TabInputText(new Uri('file', '', '/notes.md'))]) { + activateTab({ input }); + await toggle(); + } + state.group.activeTab = undefined; + await toggle(); + assert.strictEqual(state.events.length, 0); + }); + + it('contributes its command, activation, toolbar action, and documentation together', () => { + const root = path.join(__dirname, '..', '..'); + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const command = manifest.contributes.commands.find((entry: any) => entry.command === 'csv.toggleEditor'); + assert.strictEqual(command.title, 'CSV: Switch Between Table and Text'); + assert.ok(manifest.activationEvents.includes('onCommand:csv.toggleEditor')); + const action = manifest.contributes.menus['editor/title'].find((entry: any) => entry.command === 'csv.toggleEditor'); + assert.strictEqual(action.group, 'navigation'); + assert.ok(action.when.includes('!isInDiffEditor')); + assert.ok(fs.readFileSync(path.join(root, 'README.md'), 'utf8').includes('`csv.toggleEditor`')); + }); +}); From 5cb6a6cf872b961cd55aa848d87c7cb75a9f7c27 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:02:55 -0400 Subject: [PATCH 2/5] Replace table and text views while preserving unsaved edits --- README.md | 4 +- media/main.js | 32 +++- package.json | 2 +- scripts/editor-switch-smoke.cjs | 103 +++++++--- src/CsvEditorProvider.ts | 127 +++++++++++- src/editor-switch.ts | 124 ++++++++++-- src/test/edit-integrity-harness.ts | 17 +- src/test/editor-switch-draft.test.ts | 94 +++++++++ src/test/editor-switch.test.ts | 276 ++++++++++++++++----------- 9 files changed, 614 insertions(+), 165 deletions(-) create mode 100644 src/test/editor-switch-draft.test.ts diff --git a/README.md b/README.md index b2d028c..214a86c 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: ## Features -- **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs. -- **Table/Text Switching:** Use the switch action in the editor toolbar or `CSV: Switch Between Table and Text` in the Command Palette to move between views while retaining unsaved edits. Both representations may remain open; the action reuses the counterpart tab, with at most one of each view in the editor group. The table view requires `csv.enabled`. +- **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. If a cell edit fails, retry it or press Escape to discard its draft before changing the table. +- **Table/Text Switching:** Use the switch action in the editor toolbar or `CSV: Switch Between Table and Text` in the Command Palette to move between views while retaining unsaved edits. The target opens before the original view is replaced, so one representation remains in that editor group. An active cell commits before switching, and unsaved document edits remain unsaved. Other tabs and groups are unaffected. A table already open for this file in another group must be switched from that group. The table view requires `csv.enabled`. This action requires VS Code 1.100 or newer for clean documents and 1.120 or newer for unsaved documents; older hosts show update guidance. Existing extension features continue to support the declared VS Code minimum. - **Smart Resizing:** Automatic calculation of column widths for improved readability. - **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. diff --git a/media/main.js b/media/main.js index 04d192d..234cdc8 100644 --- a/media/main.js +++ b/media/main.js @@ -28,6 +28,8 @@ let lastContextIsHeader = false; // remembers whether we right-clicked a let isUpdating = false, isSelecting = false, anchorCell = null, rangeEndCell = null, currentSelection = []; let startCell = null, endCell = null, selectionMode = "cell"; let editingCell = null, originalCellValue = ""; +const failedCellDrafts = new Set(); +let discardingFailedDraft = false; // Edit mode: // - 'quick': started by typing a character (not Enter) // - 'detail': started by Enter or double-click @@ -1819,7 +1821,17 @@ document.addEventListener('keydown', e => { } } if (editingCell && e.key === 'Escape') { - e.preventDefault(); editingCell.textContent = originalCellValue; editingCell.blur(); + e.preventDefault(); + const { row, col } = getCellCoords(editingCell); + if (failedCellDrafts.has(`${row}:${col}`)) { + discardingFailedDraft = true; + editingCell.blur(); + discardingFailedDraft = false; + vscode.postMessage({ type: 'discardCellDraft', row, col }); + } else { + editingCell.textContent = originalCellValue; + editingCell.blur(); + } } if (!editingCell && e.key === 'Escape') { clearSelection(); @@ -1973,7 +1985,9 @@ const editCell = (cell, event, mode = 'detail') => { removeNewlineSentinels(cell); const value = cell.textContent; const coords = getCellCoords(cell); - vscode.postMessage({ type: 'editCell', row: coords.row, col: coords.col, value: value }); + if (!discardingFailedDraft) { + vscode.postMessage({ type: 'editCell', row: coords.row, col: coords.col, value: value }); + } cell.removeAttribute('contenteditable'); cell.classList.remove('editing'); editingCell = null; @@ -2038,7 +2052,15 @@ const copySelectionToClipboard = () => { window.addEventListener('message', event => { const message = event.data; - if(message.type === 'focus'){ + if (message.type === 'prepareViewRefresh') { + if (editingCell) { editingCell.blur(); } + persistState(); + vscode.postMessage({ type: 'viewRefreshReady', requestId: message.requestId }); + } else if (message.type === 'editCellFailed') { + failedCellDrafts.add(`${message.row}:${message.col}`); + const cell = table.querySelector(`[data-row="${message.row}"][data-col="${message.col}"]`); + if (message.retry && cell && !editingCell) { editCell(cell, undefined, 'detail'); } + } else if(message.type === 'focus'){ if (anchorCell) { try { anchorCell.focus({ preventScroll: true }); } catch { try { anchorCell.focus(); } catch {} } } else { @@ -2078,6 +2100,7 @@ window.addEventListener('message', event => { } else if(message.type === 'updateCell'){ isUpdating = true; const { row, col, value, rendered } = message; + failedCellDrafts.delete(`${row}:${col}`); const cell = table.querySelector('td[data-row="'+row+'"][data-col="'+col+'"], th[data-row="'+row+'"][data-col="'+col+'"]'); if (cell && cell !== editingCell) { if (typeof rendered === 'string') { @@ -2196,3 +2219,6 @@ document.addEventListener('keydown', e => { clearSelection(); } }); + +// Retry a host commit request that arrived before these listeners loaded. +vscode.postMessage({ type: 'webviewReady' }); diff --git a/package.json b/package.json index 07ea3c3..1d2484a 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "editor/title": [ { "command": "csv.toggleEditor", - "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || (activeEditor == workbench.editors.textEditor && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i))", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || (activeEditor == workbench.editors.files.textFileEditor && resourceScheme != untitled && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i))", "group": "navigation" } ] diff --git a/scripts/editor-switch-smoke.cjs b/scripts/editor-switch-smoke.cjs index fe24b44..3cd8964 100644 --- a/scripts/editor-switch-smoke.cjs +++ b/scripts/editor-switch-smoke.cjs @@ -4,47 +4,96 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const vscode = require('vscode'); - +const { CsvEditorProvider } = require('../out/CsvEditorProvider'); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +function tabsFor(uri, column) { + return vscode.window.tabGroups.all.find(group => group.viewColumn === column).tabs.filter(tab => tab.input.uri?.toString() === uri.toString()); +} +function snapshotOtherGroups(column) { + return vscode.window.tabGroups.all.filter(group => group.viewColumn !== column).map(group => ({ + column: group.viewColumn, + tabs: group.tabs.map(tab => ({ uri: tab.input.uri?.toString(), type: tab.input.viewType || 'text', dirty: tab.isDirty })) + })); +} exports.run = async function () { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'csv-editor-switch-')); - const extension = vscode.extensions.getExtension('ReprEng.csv'); - assert.ok(extension, 'development extension must be loaded'); - await extension.activate(); + await vscode.extensions.getExtension('ReprEng.csv').activate(); try { - for (const [suffix, contents] of [['csv', 'name,value\nfirst,1'], ['tsv', 'name\tvalue\nfirst\t1']]) { + const unrelatedPath = path.join(directory, 'unrelated.csv'); + const otherPath = path.join(directory, 'other-group.csv'); + fs.writeFileSync(unrelatedPath, 'leave,this\nalone,1'); + fs.writeFileSync(otherPath, 'other,group\nkeep,2'); + const unrelated = vscode.Uri.file(unrelatedPath); + await vscode.commands.executeCommand('vscode.openWith', unrelated, 'default', { preview: false }); + const sourceColumn = vscode.window.tabGroups.activeTabGroup.viewColumn; + await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.file(otherPath), 'default', { viewColumn: vscode.ViewColumn.Beside, preview: false }); + const otherSnapshot = snapshotOtherGroups(sourceColumn); + + for (const [suffix, separator] of [['csv', ','], ['tsv', '\t'], ['tab', '\t'], ['psv', '|']]) { const filename = path.join(directory, `data.${suffix}`); + const contents = `name${separator}value\nfirst${separator}1`; + let expected = contents.replace('first', 'unsaved-first'); fs.writeFileSync(filename, contents); const uri = vscode.Uri.file(filename); - await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { preview: false }); - const edit = new vscode.WorkspaceEdit(); - edit.insert(uri, new vscode.Position(1, 0), 'unsaved-'); + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { viewColumn: sourceColumn, preview: false }); + const edit = new vscode.WorkspaceEdit(); edit.insert(uri, new vscode.Position(1, 0), 'unsaved-'); assert.equal(await vscode.workspace.applyEdit(edit), true); for (let round = 0; round < 2; round++) { for (const viewType of ['csv.editor', 'default']) { await vscode.commands.executeCommand('csv.toggleEditor'); const group = vscode.window.tabGroups.activeTabGroup; - const tabs = group.tabs.filter(tab => tab.input.uri?.toString() === uri.toString()); - assert.equal(tabs.length, 2, 'repeated switches must reuse the two existing views'); - assert.equal(tabs.filter(tab => tab.input instanceof vscode.TabInputCustom).length, 1); - assert.equal(tabs.filter(tab => tab.input instanceof vscode.TabInputText).length, 1); - if (viewType === 'csv.editor') { - assert.equal(group.activeTab.input.viewType, viewType); - } else { - assert.ok(group.activeTab.input instanceof vscode.TabInputText); - } + const tabs = tabsFor(uri, sourceColumn); + assert.equal(group.viewColumn, sourceColumn); + assert.equal(tabs.length, 1, 'only one representation may remain after a switch'); + if (viewType === 'csv.editor') { assert.equal(group.activeTab.input.viewType, viewType); } + else { assert.ok(group.activeTab.input instanceof vscode.TabInputText); } const live = vscode.workspace.textDocuments.find(document => document.uri.toString() === uri.toString()); - assert.ok(live, 'the live document must remain open'); - assert.equal(live.isDirty, true); - assert.ok(live.getText().includes('unsaved-first')); + assert.ok(live); assert.equal(live.isDirty, true); assert.equal(live.getText(), expected); assert.equal(fs.readFileSync(filename, 'utf8'), contents, 'switching must not save'); + assert.deepEqual(snapshotOtherGroups(sourceColumn), otherSnapshot); + const untouched = tabsFor(unrelated, sourceColumn); + assert.equal(untouched.length, 1); assert.ok(untouched[0].input instanceof vscode.TabInputText, 'normalization must leave unrelated tabs alone'); + if (viewType === 'csv.editor' && round === 0) { + const controller = CsvEditorProvider.getActiveProvider(); + void controller.enqueueOperation(() => controller.updateDocument(1, 1, '1\nqueued draft')); + expected = `name${separator}value\nunsaved-first${separator}"1\nqueued draft"`; + } } } - await vscode.commands.executeCommand('workbench.action.files.revert'); - const tabs = vscode.window.tabGroups.all.flatMap(group => group.tabs).filter(tab => tab.input.uri?.toString() === uri.toString()); - await vscode.window.tabGroups.close(tabs); + if (suffix === 'csv') { + // Recreate the previous draft's two-view state, then ensure replacement + // reuses its existing counterpart and removes only the original source. + await vscode.commands.executeCommand('vscode.openWith', uri, 'csv.editor', { viewColumn: sourceColumn, preview: false }); + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { viewColumn: sourceColumn, preview: false }); + assert.equal(tabsFor(uri, sourceColumn).length, 2); + await vscode.commands.executeCommand('csv.toggleEditor'); + assert.equal(tabsFor(uri, sourceColumn).length, 1); + assert.equal(vscode.workspace.textDocuments.find(document => document.uri.toString() === uri.toString()).getText(), expected); + assert.equal(fs.readFileSync(filename, 'utf8'), contents); + assert.deepEqual(snapshotOtherGroups(sourceColumn), otherSnapshot); + } } - console.log('CSV_SWITCH_SMOKE_OK: dirty CSV+TSV preserved, disk unchanged, exactly one tab per view across repeated switches'); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } + + const cancelPath = path.join(directory, 'cancel.csv'); + fs.writeFileSync(cancelPath, 'name,value\noriginal,1'); + const cancelUri = vscode.Uri.file(cancelPath); + await vscode.commands.executeCommand('vscode.openWith', cancelUri, 'default', { viewColumn: sourceColumn, preview: false }); + const edit = new vscode.WorkspaceEdit(); edit.insert(cancelUri, new vscode.Position(1, 0), 'unsaved-'); + await vscode.workspace.applyEdit(edit); + await vscode.workspace.getConfiguration('csv').update('maxFileSizeMB', 0.000001, vscode.ConfigurationTarget.Global); + // Return the same result as pressing Cancel in the large-file prompt; the + // extension-test host refuses modal dialogs instead of displaying them. + const showWarning = vscode.window.showWarningMessage; + vscode.window.showWarningMessage = async () => undefined; + try { await vscode.commands.executeCommand('csv.toggleEditor'); } + finally { vscode.window.showWarningMessage = showWarning; } + await sleep(200); + const live = vscode.workspace.textDocuments.find(document => document.uri.toString() === cancelUri.toString()); + assert.ok(live && live.isDirty, 'cancelled target initialization must retain the dirty owner'); + assert.equal(live.getText(), 'name,value\nunsaved-original,1'); + assert.equal(tabsFor(cancelUri, sourceColumn).length, 1); + assert.ok(tabsFor(cancelUri, sourceColumn)[0].input instanceof vscode.TabInputText, 'cancellation keeps the source'); + assert.equal(fs.readFileSync(cancelPath, 'utf8'), 'name,value\noriginal,1'); + console.log('CSV_SWITCH_SMOKE_OK: dirty CSV/TSV/TAB/PSV, queued multiline edits, one representation, counterpart reuse, unrelated tabs unchanged, other groups unchanged, target cancellation retained'); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } }; diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 2f1babf..87696d3 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -64,6 +64,15 @@ class CsvEditorController { private isUpdatingDocument = false; private isSaving = false; + private draftCommitSequence = 0; + private renderedSeparator: string | undefined; + private refreshAfterDraftCommit = false; + private failedCellEdits = new Map(); + private pendingDraftCommit: { + requestId: number; + finish: (committed: boolean) => void; + timer: ReturnType; + } | undefined; private pendingOperation: Promise = Promise.resolve(); private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; @@ -128,10 +137,29 @@ class CsvEditorController { }); webviewPanel.webview.onDidReceiveMessage(e => this.enqueueOperation(async () => { + const mutatesTable = ['replaceCells', 'pasteCells', 'insertColumn', 'insertColumns', 'deleteColumn', 'deleteColumns', + 'insertRow', 'insertRows', 'deleteRow', 'deleteRows', 'reorderColumns', 'reorderRows', 'sortColumn'].includes(e.type); + if (mutatesTable && this.failedCellEdits.size) { + const failed = this.failedCellEdits.values().next().value!; + void webviewPanel.webview.postMessage({ type: 'editCellFailed', row: failed.row, col: failed.col, retry: true }); + void vscode.window.showErrorMessage('CSV: Retry the failed cell edit or press Escape to discard it before changing the table.'); + return; + } switch (e.type) { + case 'webviewReady': + this.postDraftCommitRequest(); + break; + case 'viewRefreshReady': + if (e.requestId === this.pendingDraftCommit?.requestId) { + this.finishDraftCommit(true); + } + break; case 'editCell': await this.updateDocument(e.row, e.col, e.value); break; + case 'discardCellDraft': + this.discardFailedCellDraft(e.row, e.col); + break; case 'replaceCells': await this.replaceCells(e.replacements); break; @@ -213,6 +241,7 @@ class CsvEditorController { saveDocumentSubscription.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); this.currentWebviewPanel = undefined; + this.finishDraftCommit(false); }); } @@ -225,6 +254,50 @@ class CsvEditorController { return this.pendingOperation; } + public getViewColumn(): vscode.ViewColumn | undefined { + return this.currentWebviewPanel?.viewColumn; + } + + public commitPendingDraft(): Promise { + const panel = this.currentWebviewPanel; + if (!panel || this.pendingDraftCommit) { + return Promise.resolve(false); + } + if (!panel.visible) { + return this.enqueueOperation(() => {}).then(() => !!this.currentWebviewPanel && this.failedCellEdits.size === 0); + } + return new Promise(resolve => { + this.pendingDraftCommit = { + requestId: ++this.draftCommitSequence, + finish: resolve, + // An unresponsive webview must leave its document and tab intact. + timer: setTimeout(() => this.finishDraftCommit(false), 5000) + }; + this.postDraftCommitRequest(); + }); + } + + private postDraftCommitRequest(): void { + if (this.pendingDraftCommit) { + void this.currentWebviewPanel?.webview.postMessage({ + type: 'prepareViewRefresh', requestId: this.pendingDraftCommit.requestId + }); + } + } + + private finishDraftCommit(committed: boolean): void { + const pending = this.pendingDraftCommit; + this.pendingDraftCommit = undefined; + if (pending) { + clearTimeout(pending.timer); + if (committed && !this.failedCellEdits.size && this.refreshAfterDraftCommit) { + this.refreshAfterDraftCommit = false; + this.updateWebviewContent(); + } + pending.finish(committed && this.failedCellEdits.size === 0); + } + } + private getMaxFileSizeLimitMb(config: vscode.WorkspaceConfiguration): number { const raw = Number(config.get('maxFileSizeMB', CsvEditorController.DEFAULT_MAX_FILE_SIZE_MB)); if (!Number.isFinite(raw) || raw <= 0) { @@ -317,6 +390,9 @@ class CsvEditorController { } public refresh() { + if (this.pendingDraftCommit || this.failedCellEdits.size) { + return; + } const config = vscode.workspace.getConfiguration('csv', this.document.uri); if (!config.get('enabled', true)) { this.currentWebviewPanel?.dispose(); @@ -420,6 +496,38 @@ class CsvEditorController { // ───────────── Document Editing Methods ───────────── + private reportFailedCellEdit(row: number, col: number): void { + this.failedCellEdits.set(`${row}:${col}`, { row, col }); + void this.currentWebviewPanel?.webview.postMessage({ + type: 'editCellFailed', row, col, retry: !!this.pendingDraftCommit + }); + } + + private postCommittedCell(row: number, col: number, value: string): void { + const clickableLinks = vscode.workspace.getConfiguration('csv', this.document.uri).get('clickableLinks', true); + void this.currentWebviewPanel?.webview.postMessage({ + type: 'updateCell', row, col, value, rendered: this.formatCellContent(value, clickableLinks) + }); + } + + private clearFailedCellEdit(row: number, col: number, value: string): void { + if (this.failedCellEdits.delete(`${row}:${col}`)) { + this.postCommittedCell(row, col, value); + if (!this.failedCellEdits.size) { this.updateWebviewContent(); } + } + } + + private discardFailedCellDraft(row: number, col: number): void { + const failed = this.failedCellEdits.get(`${row}:${col}`); + if (!failed) { + return; + } + // Escape discards the failed DOM draft, never overwrites the current owner. + const data = Papa.parse(this.document.getText(), { delimiter: this.getSeparator() }).data; + this.clearFailedCellEdit(row, col, String(data[row]?.[col] ?? '')); + + } + private async updateDocument(row: number, col: number, value: string) { this.isUpdatingDocument = true; let structuralChange = false; @@ -440,6 +548,7 @@ class CsvEditorController { structuralChange = !!(trimmed || createdRow || createdCol || row >= hadRows || col >= hadColsAtRow); if (!structuralChange && previousValue === value) { + this.clearFailedCellEdit(row, col, value); return; } @@ -456,6 +565,7 @@ class CsvEditorController { } if (newCsvText === oldText) { + this.clearFailedCellEdit(row, col, value); return; } @@ -467,13 +577,18 @@ class CsvEditorController { const edit = new vscode.WorkspaceEdit(); edit.replace(this.document.uri, fullRange, newCsvText); applied = await vscode.workspace.applyEdit(edit); + } catch (error) { + this.reportFailedCellEdit(row, col); + throw error; } finally { this.isUpdatingDocument = false; } if (!applied) { + this.reportFailedCellEdit(row, col); return; } + const recoveredDraft = this.failedCellEdits.delete(`${row}:${col}`); console.log(`CSV: Updated row ${row + 1}, column ${col + 1} to "${value}"`); const config = vscode.workspace.getConfiguration('csv', this.document.uri); @@ -482,7 +597,7 @@ class CsvEditorController { this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value, rendered }); // Trigger a full re-render if structure may have changed (new row/col created) - if (structuralChange) { + if (structuralChange || (recoveredDraft && !this.failedCellEdits.size)) { try { this.updateWebviewContent(); } catch (e) { console.error('CSV: refresh failed after structural edit', e); } } } @@ -1381,12 +1496,17 @@ class CsvEditorController { // ───────────── Webview Rendering ───────────── private updateWebviewContent() { - if (!this.currentWebviewPanel) return; + if (!this.currentWebviewPanel || this.failedCellEdits.size) return; + if (this.pendingDraftCommit) { + this.refreshAfterDraftCommit = true; + return; + } const webview = this.currentWebviewPanel.webview; const config = vscode.workspace.getConfiguration('csv', this.document.uri); const addSerialIndex = CsvEditorProvider.getSerialIndexForUri(this.context, this.document.uri); const separator = this.getSeparator(); + this.renderedSeparator = separator; const hiddenRows = this.getHiddenRows(); let parsed; @@ -1985,6 +2105,9 @@ class CsvEditorController { } private getSeparator(): string { + if (this.failedCellEdits.size && this.renderedSeparator !== undefined) { + return this.renderedSeparator; + } const stored = CsvEditorProvider.getSeparatorForUri(this.context, this.document.uri); if (stored && stored.length) return stored; diff --git a/src/editor-switch.ts b/src/editor-switch.ts index 84ac6b7..8b5a9e6 100644 --- a/src/editor-switch.ts +++ b/src/editor-switch.ts @@ -12,6 +12,20 @@ function getInputUri(input: unknown, viewType: string): vscode.Uri | undefined { return input instanceof vscode.TabInputCustom && input.viewType === viewType ? input.uri : undefined; } +export function supportsEditorReplacement(version: string, dirty: boolean): boolean { + const match = /^(\d+)\.(\d+)\./.exec(version); + if (!match) { + return false; + } + return Number(match[1]) > 1 || (Number(match[1]) === 1 && Number(match[2]) >= (dirty ? 120 : 100)); +} + +function showUpdateGuidance(dirty: boolean): void { + void vscode.window.showInformationMessage( + `CSV: Switching ${dirty ? 'an unsaved document' : 'between table and text'} requires VS Code ${dirty ? '1.120' : '1.100'} or newer. Update VS Code to use this action.` + ); +} + export async function toggleCsvEditor(): Promise { const group = vscode.window.tabGroups.activeTabGroup; const tab = group.activeTab; @@ -20,43 +34,121 @@ export async function toggleCsvEditor(): Promise { } const tableUri = getInputUri(tab.input, CsvEditorProvider.viewType); const uri = tableUri ?? getInputUri(tab.input, 'default'); - if (!uri || !supportedExtensions.has(path.posix.extname(uri.path).toLowerCase())) { + if (!uri || uri.scheme === 'untitled' || !supportedExtensions.has(path.posix.extname(uri.path).toLowerCase())) { return; } const source = tableUri ? CsvEditorProvider.viewType : 'default'; const target = tableUri ? 'default' : CsvEditorProvider.viewType; - const switchKey = `${group.viewColumn}:${source}:${uri.toString()}`; + const switchKey = `${group.viewColumn}:${uri.toString()}`; if (switchingTabs.has(switchKey)) { return; } if (target === CsvEditorProvider.viewType && !vscode.workspace.getConfiguration('csv', uri).get('enabled', true)) { - vscode.window.showInformationMessage('Enable the CSV extension to switch to the table view.'); + void vscode.window.showInformationMessage('Enable the CSV extension to switch to the table view.'); + return; + } + if (!supportsEditorReplacement(vscode.version, false)) { + showUpdateGuidance(false); return; } switchingTabs.add(switchKey); try { - if (tab.isPreview) { - // Keep an active table draft alive when opening its counterpart; a second - // preview could otherwise replace this tab before the draft is committed. + if (!(await vscode.commands.getCommands(true)).includes('reopenActiveEditorWith')) { + showUpdateGuidance(false); + return; + } + if (tableUri) { + const controller = CsvEditorProvider.editors.find(candidate => + candidate.getDocumentUri().toString() === uri.toString() && candidate.getViewColumn() === group.viewColumn + ); + if (!controller || !(await controller.commitPendingDraft())) { + void vscode.window.showErrorMessage('CSV: Could not commit the active cell. The current view has been kept open.'); + return; + } + } + // The user may have changed tabs while the webview committed its draft. + const current = vscode.window.tabGroups.activeTabGroup; + if (current.viewColumn !== group.viewColumn || getInputUri(current.activeTab?.input, source)?.toString() !== uri.toString()) { + return; + } + const dirty = current.activeTab!.isDirty || vscode.workspace.textDocuments.some(document => + document.uri.toString() === uri.toString() && document.isDirty + ); + if (!supportsEditorReplacement(vscode.version, dirty)) { + showUpdateGuidance(dirty); + return; + } + const sameFileTableElsewhere = target === CsvEditorProvider.viewType && vscode.window.tabGroups.all.some(other => + other.viewColumn !== group.viewColumn && other.tabs.some(otherTab => + getInputUri(otherTab.input, target)?.toString() === uri.toString() + ) + ); + if (sameFileTableElsewhere) { + void vscode.window.showInformationMessage('CSV: This file already has a table in another editor group. Switch from that group to keep both groups intact.'); + return; + } + if (current.activeTab!.isPreview) { await vscode.commands.executeCommand('workbench.action.keepEditor'); - const current = vscode.window.tabGroups.activeTabGroup; - if (current.viewColumn !== group.viewColumn || - getInputUri(current.activeTab?.input, source)?.toString() !== uri.toString()) { + const pinned = vscode.window.tabGroups.activeTabGroup; + if (pinned.viewColumn !== group.viewColumn || getInputUri(pinned.activeTab?.input, source)?.toString() !== uri.toString()) { return; } } - // Reuse/open the other representation without closing either view. Closing - // even a shared dirty editor can prompt to save/discard its document, and a - // table may still have an active draft that has not reached the document yet. + // Resolve the target while the original is still pinned and owns its edits. + // Provider cancellation or initialization failure must not remove the source. await vscode.commands.executeCommand('vscode.openWith', uri, target, { - viewColumn: group.viewColumn, - preserveFocus: false, - preview: false + viewColumn: group.viewColumn, preserveFocus: false, preview: false + }); + const targetGroup = vscode.window.tabGroups.activeTabGroup; + let targetReady = targetGroup.viewColumn === group.viewColumn && + getInputUri(targetGroup.activeTab?.input, target)?.toString() === uri.toString(); + if (targetReady && target === CsvEditorProvider.viewType) { + const controller = CsvEditorProvider.editors.find(candidate => + candidate.getDocumentUri().toString() === uri.toString() && candidate.getViewColumn() === group.viewColumn + ); + targetReady = !!controller && await controller.commitPendingDraft(); + } else if (targetReady) { + targetReady = vscode.window.activeTextEditor?.document.uri.toString() === uri.toString(); + } + const afterReady = vscode.window.tabGroups.activeTabGroup; + if (afterReady.viewColumn !== group.viewColumn || + (targetReady && getInputUri(afterReady.activeTab?.input, target)?.toString() !== uri.toString())) { + return; + } + if (!targetReady) { + const original = vscode.window.tabGroups.all.find(candidate => candidate.viewColumn === group.viewColumn); + if (original?.tabs.some(candidate => getInputUri(candidate.input, source)?.toString() === uri.toString()) && + afterReady.viewColumn === group.viewColumn && + (!afterReady.activeTab || getInputUri(afterReady.activeTab.input, target)?.toString() === uri.toString())) { + await vscode.commands.executeCommand('vscode.openWith', uri, source, { + viewColumn: group.viewColumn, preserveFocus: false, preview: false + }); + } + return; + } + // Reopening acts on selected tabs. Revealing this exact source through the + // ordinary open path narrows selection before replacing just this tab. + await vscode.commands.executeCommand('vscode.openWith', uri, source, { + viewColumn: group.viewColumn, preserveFocus: false, preview: false }); + const revealed = vscode.window.tabGroups.activeTabGroup; + if (revealed.viewColumn !== group.viewColumn || getInputUri(revealed.activeTab?.input, source)?.toString() !== uri.toString()) { + return; + } + const dirtyAfterOpen = revealed.activeTab!.isDirty || vscode.workspace.textDocuments.some(document => + document.uri.toString() === uri.toString() && document.isDirty + ); + if (!supportsEditorReplacement(vscode.version, dirtyAfterOpen)) { + showUpdateGuidance(dirtyAfterOpen); + return; + } + // Native replacement opens the target before removing the source and retains + // the shared dirty text working copy. A separate tab close can prompt/discard. + await vscode.commands.executeCommand('reopenActiveEditorWith', target); } catch (error) { console.error('CSV: could not switch editor', error); - vscode.window.showErrorMessage('CSV: Could not switch between the table and text editor.'); + void vscode.window.showErrorMessage('CSV: Could not switch between the table and text editor.'); } finally { switchingTabs.delete(switchKey); } diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..368cdae 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -19,7 +19,10 @@ const vscodeStub = { replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } }, ColorThemeKind: { Dark: 1 }, - window: { activeColorTheme: { kind: 1 } }, + window: { + activeColorTheme: { kind: 1 }, + showErrorMessage(message: string) { activeHarness.errors.push(message); } + }, workspace: { getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), @@ -56,10 +59,12 @@ export async function createEditorHarness(options: { let messageListener: (message: any) => Promise; const changeListeners: Array<(event: unknown) => void> = []; const viewListeners: Array<(event: unknown) => void> = []; + const disposeListeners: Array<() => void> = []; const saveListeners: Array<(event: unknown) => void> = []; const messages: any[] = []; const delayedEdits: Array<() => void> = []; let deferEdits = false; + let editFailure: 'false' | 'throw' | undefined; const uri = new TestUri(`/test/data.${separator === '\t' ? 'tsv' : 'csv'}`, options.remote ? 'vscode-remote' : 'file'); const document = { uri, @@ -88,6 +93,7 @@ export async function createEditorHarness(options: { active: true, visible: true, title: 'data.csv', + viewColumn: 2, webview: { html: '', options: {}, @@ -97,15 +103,22 @@ export async function createEditorHarness(options: { onDidReceiveMessage: (listener: typeof messageListener) => { messageListener = listener; } }, onDidChangeViewState: (listener: (event: unknown) => void) => { viewListeners.push(listener); }, - onDidDispose() {} + onDidDispose(listener: () => void) { disposeListeners.push(listener); }, + dispose() { disposeListeners.forEach(listener => listener()); } }; const harness = { document, panel, messages, changeListeners, saveListeners, + errors: [] as string[], + async setSeparator(separator: string) { await CsvEditorProvider.setSeparatorForUri(context as any, uri as any, separator); }, + commitPendingDraft() { return CsvEditorProvider.editors.find(controller => controller.getDocumentUri() === uri as any)!.commitPendingDraft(); }, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, + failEdits(mode: 'false' | 'throw' | undefined) { editFailure = mode; }, releaseEdit() { delayedEdits.shift()?.(); }, async applyEdit(nextText: string): Promise { + if (editFailure === 'false') { return false; } + if (editFailure === 'throw') { throw new Error('applyEdit rejected'); } return new Promise(resolve => { const apply = () => { text = nextText; diff --git a/src/test/editor-switch-draft.test.ts b/src/test/editor-switch-draft.test.ts new file mode 100644 index 0000000..710583f --- /dev/null +++ b/src/test/editor-switch-draft.test.ts @@ -0,0 +1,94 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +describe('table switching commits the actual webview draft before replacement', () => { + for (const separator of [',', '\t']) { + it(`waits for a delayed multiline edit before acknowledging (${JSON.stringify(separator)})`, async () => { + const host = await createEditorHarness({ separator }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.key('Enter', { shiftKey: true }); view.type('switch draft'); + host.deferEdits(); let committed = false; + const commit = host.commitPendingDraft().then(result => { committed = result; }); + const request = host.messages.find(message => message.type === 'prepareViewRefresh'); + assert.ok(request); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + const messages = view.messages.filter(message => message.type === 'editCell' || message.type === 'viewRefreshReady'); + assert.deepStrictEqual(messages.map(message => message.type), ['editCell', 'viewRefreshReady']); + const operations = messages.map(message => host.receive(message)); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); assert.strictEqual(committed, false); + host.releaseEdit(); await Promise.all(operations); await commit; + assert.strictEqual(committed, true); + assert.strictEqual(host.rows()[1][0], 'one\nswitch draft'); + assert.ok(!host.diskText.includes('switch draft'), 'switching must not save'); + } finally { view.close(); } + }); + } + + for (const failure of ['false', 'throw'] as const) { + it(`keeps and can retry the live draft when applyEdit returns ${failure}`, async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type('failure draft'); + host.failEdits(failure); + const html = host.panel.webview.html; + const pending = host.commitPendingDraft(); + const request = host.messages.at(-1); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + await Promise.all(view.messages.filter(message => ['editCell', 'viewRefreshReady'].includes(message.type)).map(message => host.receive(message))); + assert.strictEqual(await pending, false); + assert.strictEqual(host.panel.webview.html, html); + assert.strictEqual(host.rows()[1][0], 'one'); + assert.ok(view.cell().textContent.includes('failure draft')); + const failed = host.messages.find(message => message.type === 'editCellFailed'); + assert.ok(failed); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: failed })); + assert.strictEqual(view.cell().getAttribute('contenteditable'), 'true', 'retry keeps the failed draft editable'); + host.failEdits(undefined); view.messages.length = 0; + const retry = host.commitPendingDraft(); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: host.messages.at(-1) })); + await Promise.all(view.messages.filter(message => ['editCell', 'viewRefreshReady'].includes(message.type)).map(message => host.receive(message))); + assert.strictEqual(await retry, true); + assert.strictEqual(host.rows()[1][0], 'onefailure draft'); + } finally { view.close(); } + }); + } + + it('does not authorize a hidden view with a failed edit', async () => { + const host = await createEditorHarness(); + host.failEdits('false'); + await host.receive({ type: 'editCell', row: 1, col: 0, value: 'failed' }); + host.panel.visible = false; + assert.strictEqual(await host.commitPendingDraft(), false); + }); + + it('keeps the active DOM during a structural virtual-row commit', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell(2, 0)); view.type('appended row'); + const html = host.panel.webview.html; + const pending = host.commitPendingDraft(); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: host.messages.at(-1) })); + const edit = view.messages.find(message => message.type === 'editCell'); + await host.receive(edit); + assert.strictEqual(host.panel.webview.html, html, 'structural edits cannot replace the DOM before acknowledgment'); + await host.receive(view.messages.find(message => message.type === 'viewRefreshReady')); + assert.strictEqual(await pending, true); + assert.strictEqual(host.rows()[2][0], 'appended row'); + } finally { view.close(); } + }); + + it('resends initialization-time requests, ignores stale acks, and aborts on disposal', async () => { + const host = await createEditorHarness(); + let settled = false; const pending = host.commitPendingDraft().then(result => { settled = true; return result; }); + const request = host.messages.at(-1); + await host.receive({ type: 'webviewReady' }); assert.deepStrictEqual(host.messages.at(-1), request); + await host.receive({ type: 'viewRefreshReady', requestId: request.requestId - 1 }); + assert.strictEqual(settled, false); + host.panel.dispose(); assert.strictEqual(await pending, false); + }); +}); diff --git a/src/test/editor-switch.test.ts b/src/test/editor-switch.test.ts index d5a56b7..5df522a 100644 --- a/src/test/editor-switch.test.ts +++ b/src/test/editor-switch.test.ts @@ -8,50 +8,44 @@ class Uri { constructor(public scheme: string, public authority: string, public path: string) {} toString() { return `${this.scheme}://${this.authority}${this.path}`; } } -class TabInputText { - constructor(public uri: Uri) {} -} -class TabInputCustom { - constructor(public uri: Uri, public viewType: string) {} -} -class TabInputTextDiff { - constructor(public original: Uri, public modified: Uri) {} -} +class TabInputText { constructor(public uri: Uri) {} } +class TabInputCustom { constructor(public uri: Uri, public viewType: string) {} } +class TabInputTextDiff { constructor(public original: Uri, public modified: Uri) {} } const state = { group: { viewColumn: 2, tabs: [] as any[], activeTab: undefined as any }, - enabled: true, - events: [] as any[][], - errors: [] as string[], - information: [] as string[], + otherGroups: [] as any[], + enabled: true, version: '1.142.0', available: true, + events: [] as any[][], errors: [] as string[], information: [] as string[], registered: new Map any>(), - open: async (_uri: Uri, _viewType: string): Promise => {} + commit: async (): Promise => true, + replace: async (_viewType: string): Promise => {}, + reveal: async (_uri: Uri, _viewType: string): Promise => {} }; const vscode = { Uri, TabInputText, TabInputCustom, + get version() { return state.version; }, window: { + get activeTextEditor() { return state.group.activeTab?.input instanceof TabInputText ? { document: { uri: state.group.activeTab.input.uri } } : undefined; }, tabGroups: { get activeTabGroup() { return state.group; }, - close: () => { assert.fail('switching must never close an editor or discard a pending draft'); } + get all() { return [state.group, ...state.otherGroups]; }, + close: () => { assert.fail('native replacement owns source removal; no separate close is safe'); } }, showErrorMessage: (message: string) => { state.errors.push(message); }, showInformationMessage: (message: string) => { state.information.push(message); } }, - workspace: { - getConfiguration: () => ({ get: () => state.enabled }) - }, + workspace: { textDocuments: [] as any[], getConfiguration: () => ({ get: () => state.enabled }) }, commands: { - executeCommand: async (command: string, uri?: Uri, viewType?: string, options?: any) => { - if (command === 'workbench.action.keepEditor') { - state.events.push([command]); - state.group.activeTab.isPreview = false; - return; - } - state.events.push([command, uri, viewType, options]); - await state.open(uri!, viewType!); + getCommands: async () => state.available ? ['reopenActiveEditorWith'] : [], + executeCommand: async (command: string, ...args: any[]) => { + state.events.push([command, ...args]); + if (command === 'vscode.openWith') { await state.reveal(args[0], args[1]); } + else if (command === 'workbench.action.keepEditor') { state.group.activeTab.isPreview = false; } + else if (command === 'reopenActiveEditorWith') { await state.replace(args[0]); } + else { assert.fail(`unexpected command: ${command}`); } }, registerCommand: (name: string, callback: (...args: any[]) => any) => { - state.registered.set(name, callback); - return { dispose() {} }; + state.registered.set(name, callback); return { dispose() {} }; } } }; @@ -62,140 +56,198 @@ Module.prototype.require = function (id: string) { return originalRequire.apply(this, arguments as any); }; import { registerCsvCommands } from '../commands'; +import { CsvEditorProvider } from '../CsvEditorProvider'; +import { supportsEditorReplacement } from '../editor-switch'; Module.prototype.require = originalRequire; -function makeTab(uri: Uri, table: boolean, preview = false) { - return { - input: table ? new TabInputCustom(uri, 'csv.editor') : new TabInputText(uri), - isPreview: preview, - isDirty: true - }; +function makeTab(uri: Uri, table: boolean, dirty = true) { + return { input: table ? new TabInputCustom(uri, 'csv.editor') : new TabInputText(uri), isDirty: dirty }; } function activateTab(tab: any) { - state.group.tabs = [tab]; - state.group.activeTab = tab; -} -async function toggle() { - await state.registered.get('csv.toggleEditor')!(); + state.group.tabs = [tab]; state.group.activeTab = tab; + if (tab.input instanceof TabInputCustom && tab.input.viewType === 'csv.editor') { + CsvEditorProvider.editors.push({ + getDocumentUri: () => tab.input.uri, + getViewColumn: () => 2, + commitPendingDraft: async () => { state.events.push(['commit']); return state.commit(); } + } as any); + } } +async function toggle() { await state.registered.get('csv.toggleEditor')!(); } beforeEach(() => { state.group = { viewColumn: 2, tabs: [], activeTab: undefined }; - state.enabled = true; - state.events = []; - state.errors = []; - state.information = []; - state.registered.clear(); + state.otherGroups = []; + state.enabled = true; state.version = '1.142.0'; state.available = true; + state.events = []; state.errors = []; state.information = []; + state.registered.clear(); CsvEditorProvider.editors = []; vscode.workspace.textDocuments = []; registerCsvCommands({ subscriptions: [] } as any); - state.open = async (uri, viewType) => { + state.commit = async () => true; + state.reveal = async (uri, viewType) => { + const source = state.group.activeTab; const table = viewType === 'csv.editor'; - let target = state.group.tabs.find(tab => - tab.input.uri.toString() === uri.toString() && + const target = state.group.tabs.find(tab => tab.input.uri?.toString() === uri.toString() && + (table ? tab.input instanceof TabInputCustom : tab.input instanceof TabInputText)) ?? makeTab(uri, table, source.isDirty); + if (!state.group.tabs.includes(target)) { state.group.tabs.push(target); } + state.group.activeTab = target; + if (table && !CsvEditorProvider.editors.some(controller => controller.getDocumentUri().toString() === uri.toString())) { + CsvEditorProvider.editors.push({ getDocumentUri: () => uri, getViewColumn: () => 2, + commitPendingDraft: async () => { state.events.push(['commit']); return state.commit(); } } as any); + } + }; + state.replace = async viewType => { + const original = state.group.activeTab; + const table = viewType === 'csv.editor'; + const target = state.group.tabs.find(tab => tab !== original && + tab.input.uri?.toString() === original.input.uri.toString() && (table ? tab.input instanceof TabInputCustom : tab.input instanceof TabInputText) - ); - if (!target) { - target = makeTab(uri, table); - state.group.tabs.push(target); + ) ?? makeTab(original.input.uri, table, original.isDirty); + state.group.tabs = state.group.tabs.filter(tab => tab !== original && tab !== target); + state.group.tabs.push(target); state.group.activeTab = target; + if (table) { + CsvEditorProvider.editors.push({ getDocumentUri: () => target.input.uri, getViewColumn: () => 2, + commitPendingDraft: async () => { state.events.push(['commit']); return state.commit(); } } as any); } - state.group.activeTab = target; }; }); -describe('CSV table/text switch command', () => { +describe('single-view CSV table/text switching', () => { for (const scheme of ['file', 'vscode-remote']) { for (const extension of ['csv', 'tsv', 'tab', 'psv', 'CSV']) { - it(`reuses ${scheme} .${extension} views in both directions without closing either`, async () => { + it(`replaces only the ${scheme} .${extension} source in its group`, async () => { const uri = new Uri(scheme, scheme === 'file' ? '' : 'wsl+Ubuntu', `/data/example.${extension}`); - const original = makeTab(uri, false); - activateTab(original); + activateTab(makeTab(uri, false)); const unrelated = makeTab(new Uri('file', '', '/other.csv'), false); state.group.tabs.push(unrelated); - await toggle(); - assert.deepStrictEqual(state.events, [['vscode.openWith', uri, 'csv.editor', { viewColumn: 2, preserveFocus: false, preview: false }]]); - assert.strictEqual(state.group.activeTab.input.uri, uri); - assert.strictEqual(state.group.activeTab.isDirty, true); - assert.strictEqual(state.group.viewColumn, 2); - assert.ok(state.group.tabs.includes(unrelated)); - await toggle(); - assert.strictEqual(state.events[1][2], 'default'); - assert.strictEqual(state.group.activeTab.input.uri, uri); - await toggle(); - await toggle(); - assert.strictEqual(state.group.tabs.filter(tab => tab.input.uri.toString() === uri.toString()).length, 2); + for (let round = 0; round < 4; round++) { + await toggle(); + assert.strictEqual(state.group.tabs.filter(tab => tab.input.uri.toString() === uri.toString()).length, 1); + assert.strictEqual(state.group.activeTab.input.uri, uri); + assert.strictEqual(state.group.activeTab.isDirty, true); + assert.ok(state.group.tabs.includes(unrelated)); + } + assert.deepStrictEqual(state.events.slice(0, 4), [ + ['vscode.openWith', uri, 'csv.editor', { viewColumn: 2, preserveFocus: false, preview: false }], + ['commit'], + ['vscode.openWith', uri, 'default', { viewColumn: 2, preserveFocus: false, preview: false }], + ['reopenActiveEditorWith', 'csv.editor'] + ]); + assert.strictEqual(state.events.filter(event => event[0] === 'commit').length, 4); assert.strictEqual(state.errors.length, 0); }); } } - it('pins a preview before opening the counterpart so an active grid draft is retained', async () => { + it('reuses a counterpart that is already open while removing only its source', async () => { const uri = new Uri('file', '', '/data.csv'); - const original = makeTab(uri, true, true); - activateTab(original); + const source = makeTab(uri, false); const target = makeTab(uri, true); + activateTab(source); state.group.tabs.push(target); await toggle(); - assert.deepStrictEqual(state.events[0], ['workbench.action.keepEditor']); - assert.strictEqual(state.events[1][0], 'vscode.openWith'); - assert.strictEqual(state.events[1][3].preview, false); - assert.ok(state.group.tabs.includes(original)); - assert.strictEqual(original.isPreview, false); + assert.deepStrictEqual(state.group.tabs, [target]); + assert.strictEqual(state.group.activeTab, target); }); - it('leaves the dirty source intact if opening the counterpart fails', async () => { - const original = makeTab(new Uri('file', '', '/data.csv'), false); - activateTab(original); - state.open = async () => { throw new Error('open failed'); }; + it('keeps the dirty source when native opening fails or is cancelled', async () => { + const source = makeTab(new Uri('file', '', '/data.csv'), false); + activateTab(source); state.reveal = async () => { throw new Error('open failed'); }; await toggle(); - assert.deepStrictEqual(state.group.tabs, [original]); - assert.strictEqual(original.isDirty, true); + assert.deepStrictEqual(state.group.tabs, [source]); assert.strictEqual(source.isDirty, true); assert.strictEqual(state.errors.length, 1); + state.reveal = async () => {}; + await toggle(); + assert.deepStrictEqual(state.group.tabs, [source]); assert.strictEqual(source.isDirty, true); }); - it('coalesces duplicate clicks while opening the same counterpart', async () => { - activateTab(makeTab(new Uri('file', '', '/data.csv'), false)); - let finishOpen!: () => void; - state.open = () => new Promise(resolve => { finishOpen = resolve; }); - const pending = toggle(); + it('waits for a table draft, aborting if it cannot commit or the user changes tabs', async () => { + const source = makeTab(new Uri('file', '', '/data.csv'), true); + activateTab(source); state.commit = async () => false; + await toggle(); assert.deepStrictEqual(state.events, [['commit']]); + assert.deepStrictEqual(state.group.tabs, [source]); + state.events = []; state.commit = async () => { + state.group.activeTab = makeTab(new Uri('file', '', '/unrelated.csv'), false); return true; + }; + await toggle(); assert.deepStrictEqual(state.events, [['commit']]); + }); + + it('does not steal focus back after the user changes tabs during target readiness', async () => { + const uri = new Uri('file', '', '/data.csv'); + activateTab(makeTab(uri, false)); + state.commit = async () => { + state.group.activeTab = makeTab(new Uri('file', '', '/another.csv'), false); + return true; + }; await toggle(); - finishOpen(); - await pending; - assert.strictEqual(state.events.length, 1); + assert.deepStrictEqual(state.events.map(event => event[0]), ['vscode.openWith', 'commit']); + assert.strictEqual(state.group.activeTab.input.uri.path, '/another.csv'); }); - it('respects disabled state when opening the table without changing configuration', async () => { - state.enabled = false; - activateTab(makeTab(new Uri('file', '', '/data.tsv'), false)); + it('does not move a preexisting table from a different editor group', async () => { + const uri = new Uri('file', '', '/data.csv'); + activateTab(makeTab(uri, false)); + const other = { viewColumn: 3, tabs: [makeTab(uri, true)] }; + state.otherGroups = [other]; await toggle(); assert.strictEqual(state.events.length, 0); - assert.strictEqual(state.enabled, false); - assert.strictEqual(state.information.length, 1); + assert.strictEqual(other.tabs.length, 1); + assert.match(state.information[0], /another editor group/); + }); + + it('coalesces duplicate clicks during replacement', async () => { + activateTab(makeTab(new Uri('file', '', '/data.csv'), false)); + let finish!: () => void; state.replace = () => new Promise(resolve => { finish = resolve; }); + const pending = toggle(); await new Promise(resolve => setImmediate(resolve)); + await toggle(); finish(); await pending; + assert.strictEqual(state.events.filter(event => event[0] === 'reopenActiveEditorWith').length, 1); }); - it('can leave the table for source text even if the extension has been disabled', async () => { - state.enabled = false; + it('respects disabled state while allowing a table to return to source', async () => { + state.enabled = false; activateTab(makeTab(new Uri('file', '', '/data.tsv'), false)); + await toggle(); assert.strictEqual(state.events.length, 0); assert.strictEqual(state.information.length, 1); activateTab(makeTab(new Uri('file', '', '/data.tsv'), true)); - await toggle(); - assert.strictEqual(state.events[0][2], 'default'); + await toggle(); assert.strictEqual(state.events.at(-1)?.[1], 'default'); }); - it('ignores unrelated custom editors, diff tabs, unsupported files, and empty groups', async () => { + it('ignores other custom editors, native diffs, unsupported files, untitled URIs and empty groups', async () => { const uri = new Uri('file', '', '/data.csv'); - for (const input of [new TabInputCustom(uri, 'another.editor'), new TabInputTextDiff(uri, uri), new TabInputText(new Uri('file', '', '/notes.md'))]) { - activateTab({ input }); - await toggle(); + for (const input of [new TabInputCustom(uri, 'another.editor'), new TabInputTextDiff(uri, uri), + new TabInputText(new Uri('file', '', '/notes.md')), new TabInputText(new Uri('untitled', '', '/data.csv'))]) { + activateTab({ input }); await toggle(); } - state.group.activeTab = undefined; - await toggle(); - assert.strictEqual(state.events.length, 0); + state.group.activeTab = undefined; await toggle(); assert.strictEqual(state.events.length, 0); + }); + + it('guards old/forked hosts and rechecks dirty state after the draft commits', async () => { + activateTab(makeTab(new Uri('file', '', '/data.csv'), false, false)); + state.version = '1.99.0'; await toggle(); assert.strictEqual(state.events.length, 0); + state.version = '1.119.0'; await toggle(); assert.strictEqual(state.events.at(-1)?.[0], 'reopenActiveEditorWith'); + state.events = []; activateTab(makeTab(new Uri('file', '', '/data.csv'), true, false)); + state.commit = async () => { state.group.activeTab.isDirty = true; return true; }; + await toggle(); assert.deepStrictEqual(state.events, [['commit']]); assert.match(state.information.at(-1)!, /1\.120/); + state.version = '1.120.0'; state.events = []; await toggle(); + assert.strictEqual(state.events.at(-1)?.[0], 'reopenActiveEditorWith'); + state.events = []; state.available = false; await toggle(); assert.strictEqual(state.events.length, 0); }); - it('contributes its command, activation, toolbar action, and documentation together', () => { + it('tests the native command and dirty-working-copy compatibility boundaries', () => { + for (const [version, dirty, expected] of [ + ['1.99.0', false, false], ['1.100.0', false, true], ['1.119.0', false, true], + ['1.119.0', true, false], ['1.120.0', true, true], ['1.142.0', true, true], + ['1.120.0-insider', true, true], ['2.0.0', true, true], ['unknown', false, false] + ] as const) { assert.strictEqual(supportsEditorReplacement(version, dirty), expected); } + }); + + it('contributes the real native text-pane toolbar context and updated documentation', () => { const root = path.join(__dirname, '..', '..'); const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); - const command = manifest.contributes.commands.find((entry: any) => entry.command === 'csv.toggleEditor'); - assert.strictEqual(command.title, 'CSV: Switch Between Table and Text'); - assert.ok(manifest.activationEvents.includes('onCommand:csv.toggleEditor')); const action = manifest.contributes.menus['editor/title'].find((entry: any) => entry.command === 'csv.toggleEditor'); - assert.strictEqual(action.group, 'navigation'); + assert.ok(manifest.activationEvents.includes('onCommand:csv.toggleEditor')); + assert.match(action.when, /activeEditor == workbench\.editors\.files\.textFileEditor/); + assert.match(action.when, /activeCustomEditorId == csv\.editor/); assert.ok(action.when.includes('!isInDiffEditor')); - assert.ok(fs.readFileSync(path.join(root, 'README.md'), 'utf8').includes('`csv.toggleEditor`')); + assert.ok(action.when.includes('resourceScheme != untitled')); + assert.ok(!action.when.includes('workbench.editors.textEditor')); + const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); + assert.ok(!readme.includes('Both representations may remain open')); + assert.ok(readme.includes('one representation remains')); }); }); From 3da0673d40db761c6bad41941c0042b523239545 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:08:39 -0400 Subject: [PATCH 3/5] Preserve newer cell drafts during failed edit recovery --- media/main.js | 1 + src/CsvEditorProvider.ts | 53 +++++++- src/test/edit-integrity-harness.ts | 15 +++ src/test/editor-switch-draft.test.ts | 3 +- src/test/failed-draft-recovery.test.ts | 174 +++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 src/test/failed-draft-recovery.test.ts diff --git a/media/main.js b/media/main.js index 234cdc8..62a4c6f 100644 --- a/media/main.js +++ b/media/main.js @@ -1977,6 +1977,7 @@ const editCell = (cell, event, mode = 'detail') => { cell.classList.remove('selected'); originalCellValue = cell.textContent; editingCell = cell; + vscode.postMessage({ type: 'cellEditStarted' }); editMode = mode; cell.classList.add('editing'); cell.setAttribute('contenteditable', 'true'); diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 87696d3..3bda787 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -65,11 +65,13 @@ class CsvEditorController { private isUpdatingDocument = false; private isSaving = false; private draftCommitSequence = 0; + private draftActivityRevision = 0; private renderedSeparator: string | undefined; private refreshAfterDraftCommit = false; private failedCellEdits = new Map(); private pendingDraftCommit: { requestId: number; + needsFreshAck: boolean; finish: (committed: boolean) => void; timer: ReturnType; } | undefined; @@ -136,9 +138,16 @@ class CsvEditorController { } }); - webviewPanel.webview.onDidReceiveMessage(e => this.enqueueOperation(async () => { + webviewPanel.webview.onDidReceiveMessage(e => { const mutatesTable = ['replaceCells', 'pasteCells', 'insertColumn', 'insertColumns', 'deleteColumn', 'deleteColumns', 'insertRow', 'insertRows', 'deleteRow', 'deleteRows', 'reorderColumns', 'reorderRows', 'sortColumn'].includes(e.type); + // Observe new drafts immediately, even while an earlier queued edit awaits + // applyEdit. An ACK received before that activity cannot authorize reload. + if (mutatesTable || ['cellEditStarted', 'editCell', 'discardCellDraft'].includes(e.type)) { + this.draftActivityRevision += 1; + } + const receivedRevision = this.draftActivityRevision; + return this.enqueueOperation(async () => { if (mutatesTable && this.failedCellEdits.size) { const failed = this.failedCellEdits.values().next().value!; void webviewPanel.webview.postMessage({ type: 'editCellFailed', row: failed.row, col: failed.col, retry: true }); @@ -151,7 +160,13 @@ class CsvEditorController { break; case 'viewRefreshReady': if (e.requestId === this.pendingDraftCommit?.requestId) { - this.finishDraftCommit(true); + if (this.pendingDraftCommit && (this.pendingDraftCommit.needsFreshAck || receivedRevision !== this.draftActivityRevision)) { + this.pendingDraftCommit.needsFreshAck = false; + this.pendingDraftCommit.requestId = ++this.draftCommitSequence; + this.postDraftCommitRequest(); + } else { + this.finishDraftCommit(true); + } } break; case 'editCell': @@ -216,7 +231,8 @@ class CsvEditorController { await this.openLinkExternally(e.url); break; } - })); + }); + }); const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { if ( @@ -269,6 +285,7 @@ class CsvEditorController { return new Promise(resolve => { this.pendingDraftCommit = { requestId: ++this.draftCommitSequence, + needsFreshAck: false, finish: resolve, // An unresponsive webview must leave its document and tab intact. timer: setTimeout(() => this.finishDraftCommit(false), 5000) @@ -298,6 +315,27 @@ class CsvEditorController { } } + private requestRecoveryRefresh(): void { + this.refreshAfterDraftCommit = true; + if (this.pendingDraftCommit) { + // Recovery was processed after this round's prepare. Take another snapshot + // rather than reusing an ACK that may precede a newly started cell draft. + this.pendingDraftCommit.needsFreshAck = true; + return; + } + // Never await the webview ACK from inside the ordered operation queue. + void this.commitPendingDraft().then(committed => { + if (committed && this.refreshAfterDraftCommit && this.currentWebviewPanel) { + if (this.currentWebviewPanel.visible) { + this.requestRecoveryRefresh(); + } else { + this.refreshAfterDraftCommit = false; + this.updateWebviewContent(); + } + } + }); + } + private getMaxFileSizeLimitMb(config: vscode.WorkspaceConfiguration): number { const raw = Number(config.get('maxFileSizeMB', CsvEditorController.DEFAULT_MAX_FILE_SIZE_MB)); if (!Number.isFinite(raw) || raw <= 0) { @@ -513,7 +551,7 @@ class CsvEditorController { private clearFailedCellEdit(row: number, col: number, value: string): void { if (this.failedCellEdits.delete(`${row}:${col}`)) { this.postCommittedCell(row, col, value); - if (!this.failedCellEdits.size) { this.updateWebviewContent(); } + if (!this.failedCellEdits.size) { this.requestRecoveryRefresh(); } } } @@ -597,7 +635,10 @@ class CsvEditorController { this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value, rendered }); // Trigger a full re-render if structure may have changed (new row/col created) - if (structuralChange || (recoveredDraft && !this.failedCellEdits.size)) { + if (recoveredDraft && !this.failedCellEdits.size) { + this.requestRecoveryRefresh(); + } + if (structuralChange) { try { this.updateWebviewContent(); } catch (e) { console.error('CSV: refresh failed after structural edit', e); } } } @@ -2105,7 +2146,7 @@ class CsvEditorController { } private getSeparator(): string { - if (this.failedCellEdits.size && this.renderedSeparator !== undefined) { + if ((this.failedCellEdits.size || this.refreshAfterDraftCommit) && this.renderedSeparator !== undefined) { return this.renderedSeparator; } const stored = CsvEditorProvider.getSeparatorForUri(this.context, this.document.uri); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 368cdae..5293acd 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -113,7 +113,9 @@ export async function createEditorHarness(options: { commitPendingDraft() { return CsvEditorProvider.editors.find(controller => controller.getDocumentUri() === uri as any)!.commitPendingDraft(); }, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, + get pendingDraftRequestId(): number | undefined { return (CsvEditorProvider.editors.find(controller => controller.getDocumentUri() === uri as any) as any)?.pendingDraftCommit?.requestId; }, deferEdits() { deferEdits = true; }, + resumeEdits() { deferEdits = false; }, failEdits(mode: 'false' | 'throw' | undefined) { editFailure = mode; }, releaseEdit() { delayedEdits.shift()?.(); }, async applyEdit(nextText: string): Promise { @@ -192,3 +194,16 @@ export function createWebviewDom(html: string) { } }; } + +export async function flushViewCommits(host: Awaited>, view: ReturnType): Promise { + for (let round = 0; round < 10; round++) { + const requestId = host.pendingDraftRequestId; + if (requestId === undefined) { return; } + const request = host.messages.find(message => message.type === 'prepareViewRefresh' && message.requestId === requestId); + if (!request) { throw new Error('Missing prepare message'); } + const cursor = view.messages.length; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + await Promise.all(view.messages.slice(cursor).map(message => host.receive(message))); + } + throw new Error('View commit did not settle'); +} diff --git a/src/test/editor-switch-draft.test.ts b/src/test/editor-switch-draft.test.ts index 710583f..f55219a 100644 --- a/src/test/editor-switch-draft.test.ts +++ b/src/test/editor-switch-draft.test.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import { describe, it } from 'node:test'; -import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +import { createEditorHarness, createWebviewDom, flushViewCommits } from './edit-integrity-harness'; describe('table switching commits the actual webview draft before replacement', () => { for (const separator of [',', '\t']) { @@ -51,6 +51,7 @@ describe('table switching commits the actual webview draft before replacement', const retry = host.commitPendingDraft(); view.window.dispatchEvent(new view.window.MessageEvent('message', { data: host.messages.at(-1) })); await Promise.all(view.messages.filter(message => ['editCell', 'viewRefreshReady'].includes(message.type)).map(message => host.receive(message))); + await flushViewCommits(host, view); assert.strictEqual(await retry, true); assert.strictEqual(host.rows()[1][0], 'onefailure draft'); } finally { view.close(); } diff --git a/src/test/failed-draft-recovery.test.ts b/src/test/failed-draft-recovery.test.ts new file mode 100644 index 0000000..c55804f --- /dev/null +++ b/src/test/failed-draft-recovery.test.ts @@ -0,0 +1,174 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom, flushViewCommits } from './edit-integrity-harness'; + +function deliver(host: Awaited>, view: ReturnType) { + const messages = host.messages.splice(0); + messages.forEach(message => { + if (message.type === 'prepareViewRefresh') { host.messages.push(message); } + else { view.window.dispatchEvent(new view.window.MessageEvent('message', { data: message })); } + }); +} + +describe('unapplied ordinary cell drafts keep source row identities intact', () => { + for (const failure of ['false', 'throw'] as const) { + it(`blocks row deletion after an ordinary ${failure} failure until Escape discards the draft`, async () => { + const host = await createEditorHarness({ rows: [['name', 'value'], ['one', 'two'], ['three', 'four']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type(' failed draft'); view.cell().blur(); + host.failEdits(failure); + await host.receive(view.messages.find(message => message.type === 'editCell')); + deliver(host, view); + assert.ok(view.cell().textContent.includes('failed draft')); + assert.strictEqual(view.cell().getAttribute('contenteditable'), null, 'ordinary failure must not repeatedly steal focus'); + host.failEdits(undefined); + await host.receive({ type: 'deleteRow', index: 1 }); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one', 'two'], ['three', 'four']]); + assert.strictEqual(host.errors.length, 1); + deliver(host, view); + assert.strictEqual(view.cell().getAttribute('contenteditable'), 'true', 'blocked mutation returns to the failed draft'); + view.messages.length = 0; + view.key('Escape'); + assert.deepStrictEqual(view.messages.map(message => message.type), ['discardCellDraft']); + await host.receive(view.messages[0]); + deliver(host, view); + assert.strictEqual(view.cell().textContent, 'one'); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one', 'two'], ['three', 'four']], 'discard must not write stale DOM text'); + await host.receive({ type: 'deleteRow', index: 1 }); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['three', 'four']]); + await flushViewCommits(host, view); + const fresh = createWebviewDom(host.panel.webview.html); + try { assert.strictEqual(fresh.cell().textContent, 'three'); } finally { fresh.close(); } + } finally { view.close(); } + }); + } + + it('clears the failure when the user explicitly reverts to the original source value', async () => { + const host = await createEditorHarness({ rows: [['name'], ['one'], ['three']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type('failed'); view.cell().blur(); + host.failEdits('false'); await host.receive(view.messages.find(message => message.type === 'editCell')); + deliver(host, view); host.failEdits(undefined); view.messages.length = 0; + view.start(view.cell()); view.cell().textContent = 'one'; view.cell().blur(); + await host.receive(view.messages.find(message => message.type === 'editCell')); + deliver(host, view); + await host.receive({ type: 'deleteRow', index: 1 }); + assert.deepStrictEqual(host.rows(), [['name'], ['three']]); + assert.strictEqual(host.errors.length, 0); + await flushViewCommits(host, view); + } finally { view.close(); } + }); + + it('keeps the rendered delimiter while a failed draft is retried after a separator change', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type(' draft'); view.cell().blur(); + host.failEdits('false'); await host.receive(view.messages.find(message => message.type === 'editCell')); + deliver(host, view); host.failEdits(undefined); + await host.setSeparator('|'); + view.messages.length = 0; view.start(view.cell()); + const pending = host.commitPendingDraft(); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: host.messages.at(-1) })); + await Promise.all(view.messages.filter(message => ['editCell', 'viewRefreshReady'].includes(message.type)).map(message => host.receive(message))); + await flushViewCommits(host, view); + assert.strictEqual(await pending, true); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one draft', 'two']]); + const fresh = createWebviewDom(host.panel.webview.html); + try { assert.strictEqual(fresh.window.document.getElementById('csv-root').dataset.sepcode, '124'); } + finally { fresh.close(); } + } finally { view.close(); } + }); + + for (const existingCommit of [false, true]) { + it(`preserves a new live B draft while recovered A applies (${existingCommit ? 'existing ACK' : 'ordinary blur'})`, async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type(' recovered'); view.cell().blur(); + host.failEdits('false'); await host.receive(view.messages.find(message => message.type === 'editCell')); + deliver(host, view); host.failEdits(undefined); view.messages.length = 0; + view.start(view.cell()); + host.deferEdits(); + let commit: Promise | undefined; + if (existingCommit) { + commit = host.commitPendingDraft(); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: host.messages.at(-1) })); + } else { view.cell().blur(); } + const queuedA = view.messages.filter(message => ['cellEditStarted', 'editCell', 'viewRefreshReady'].includes(message.type)).map(message => host.receive(message)); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + const html = host.panel.webview.html; + const cursor = view.messages.length; + view.start(view.cell(1, 1)); view.type(' SECOND-LIVE-DRAFT'); + const startedB = view.messages.slice(cursor).map(message => host.receive(message)); + host.resumeEdits(); host.releaseEdit(); + await Promise.all([...queuedA, ...startedB]); + assert.strictEqual(host.panel.webview.html, html, 'recovery cannot replace the DOM while B remains unposted'); + assert.ok(view.cell(1, 1).textContent.includes('SECOND-LIVE-DRAFT')); + assert.strictEqual(host.rows()[1][1], 'two'); + await flushViewCommits(host, view); + if (commit) { assert.strictEqual(await commit, true); } + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one recovered', 'two SECOND-LIVE-DRAFT']]); + const fresh = createWebviewDom(host.panel.webview.html); + try { assert.strictEqual(fresh.cell(1, 1).textContent, 'two SECOND-LIVE-DRAFT'); } + finally { fresh.close(); } + } finally { view.close(); } + }); + } + + it('renews a fresh recovery ACK if another draft starts while its edit is applying', async () => { + const host = await createEditorHarness({ rows: [['a', 'b', 'c'], ['one', 'two', 'three']] }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type(' recovered'); view.cell().blur(); + host.failEdits('false'); await host.receive(view.messages.find(message => message.type === 'editCell')); + deliver(host, view); host.failEdits(undefined); view.messages.length = 0; + view.start(view.cell()); view.cell().blur(); host.deferEdits(); + const queuedA = view.messages.map(message => host.receive(message)); + await new Promise(resolve => setImmediate(resolve)); + const html = host.panel.webview.html; + let cursor = view.messages.length; + view.start(view.cell(1, 1)); view.type(' SECOND-DRAFT'); + const startB = view.messages.slice(cursor).map(message => host.receive(message)); + host.releaseEdit(); await Promise.all([...queuedA, ...startB]); + cursor = view.messages.length; + const firstRequest = host.messages.find(message => message.type === 'prepareViewRefresh' && message.requestId === host.pendingDraftRequestId); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: firstRequest })); + const queuedB = view.messages.slice(cursor).map(message => host.receive(message)); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + cursor = view.messages.length; + view.start(view.cell(1, 2)); view.type(' THIRD-LIVE-DRAFT'); + const startC = view.messages.slice(cursor).map(message => host.receive(message)); + host.resumeEdits(); host.releaseEdit(); + await Promise.all([...queuedB, ...startC]); + assert.notStrictEqual(host.pendingDraftRequestId, firstRequest.requestId, 'the received new draft invalidates the earlier ACK'); + assert.strictEqual(host.panel.webview.html, html); + assert.ok(view.cell(1, 2).textContent.includes('THIRD-LIVE-DRAFT')); + assert.strictEqual(host.rows()[1][2], 'three'); + await flushViewCommits(host, view); + assert.deepStrictEqual(host.rows(), [['a', 'b', 'c'], ['one recovered', 'two SECOND-DRAFT', 'three THIRD-LIVE-DRAFT']]); + } finally { view.close(); } + }); + + it('discard restores the current owner after an external edit without applying the failed DOM value', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type('failed'); view.cell().blur(); + host.failEdits('false'); await host.receive(view.messages.find(message => message.type === 'editCell')); + deliver(host, view); host.failEdits(undefined); + await host.applyEdit('name,value\nexternal,change'); + await host.receive({ type: 'deleteRow', index: 1 }); deliver(host, view); + view.messages.length = 0; view.key('Escape'); + await host.receive(view.messages.find(message => message.type === 'discardCellDraft')); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['external', 'change']]); + await flushViewCommits(host, view); + const fresh = createWebviewDom(host.panel.webview.html); + try { assert.strictEqual(fresh.cell().textContent, 'external'); } finally { fresh.close(); } + } finally { view.close(); } + }); +}); From dd726383ce369f93ae4f19b675cfbedab9547222 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:24:44 -0400 Subject: [PATCH 4/5] Keep failed cell drafts recoverable when disabling table view --- README.md | 2 +- src/CsvEditorProvider.ts | 13 +++++-- src/test/edit-integrity-harness.ts | 13 ++++++- src/test/failed-draft-recovery.test.ts | 47 ++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 214a86c..514d160 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: ## Features -- **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs. If a cell edit fails, retry it or press Escape to discard its draft before changing the table. +- **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. If a cell edit fails, retry it or press Escape to discard its draft before changing or disabling the table view. - **Table/Text Switching:** Use the switch action in the editor toolbar or `CSV: Switch Between Table and Text` in the Command Palette to move between views while retaining unsaved edits. The target opens before the original view is replaced, so one representation remains in that editor group. An active cell commits before switching, and unsaved document edits remain unsaved. Other tabs and groups are unaffected. A table already open for this file in another group must be switched from that group. The table view requires `csv.enabled`. This action requires VS Code 1.100 or newer for clean documents and 1.120 or newer for unsaved documents; older hosts show update guidance. Existing extension features continue to support the declared VS Code minimum. - **Smart Resizing:** Automatic calculation of column widths for improved readability. - **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). diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 3bda787..fa08a09 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -428,11 +428,18 @@ class CsvEditorController { } public refresh() { - if (this.pendingDraftCommit || this.failedCellEdits.size) { + const config = vscode.workspace.getConfiguration('csv', this.document.uri); + const enabled = config.get('enabled', true); + if (this.failedCellEdits.size) { + if (!enabled) { + void vscode.window.showErrorMessage('CSV: Retry the failed cell edit or press Escape to discard it before disabling the table view.'); + } return; } - const config = vscode.workspace.getConfiguration('csv', this.document.uri); - if (!config.get('enabled', true)) { + if (this.pendingDraftCommit) { + return; + } + if (!enabled) { this.currentWebviewPanel?.dispose(); vscode.commands.executeCommand('vscode.openWith', this.document.uri, 'default'); } else { diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 5293acd..8feb2da 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -23,8 +23,14 @@ const vscodeStub = { activeColorTheme: { kind: 1 }, showErrorMessage(message: string) { activeHarness.errors.push(message); } }, + commands: { + executeCommand(command: string, ...args: unknown[]) { + activeHarness.commands.push([command, ...args]); + return Promise.resolve(); + } + }, workspace: { - getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), + getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : key === 'enabled' ? (activeHarness?.enabled ?? true) : fallback }), applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), onDidChangeTextDocument: (listener: (event: unknown) => void) => { activeHarness.changeListeners.push(listener); @@ -109,6 +115,11 @@ export async function createEditorHarness(options: { const harness = { document, panel, messages, changeListeners, saveListeners, errors: [] as string[], + commands: [] as unknown[][], + enabled: true, + setEnabled(enabled: boolean) { this.enabled = enabled; }, + refresh() { CsvEditorProvider.editors.find(controller => controller.getDocumentUri() === uri as any)!.refresh(); }, + get isOpen() { return CsvEditorProvider.editors.some(controller => controller.getDocumentUri() === uri as any); }, async setSeparator(separator: string) { await CsvEditorProvider.setSeparatorForUri(context as any, uri as any, separator); }, commitPendingDraft() { return CsvEditorProvider.editors.find(controller => controller.getDocumentUri() === uri as any)!.commitPendingDraft(); }, get diskText() { return diskText; }, diff --git a/src/test/failed-draft-recovery.test.ts b/src/test/failed-draft-recovery.test.ts index c55804f..0dca175 100644 --- a/src/test/failed-draft-recovery.test.ts +++ b/src/test/failed-draft-recovery.test.ts @@ -11,6 +11,53 @@ function deliver(host: Awaited>, view: Re } describe('unapplied ordinary cell drafts keep source row identities intact', () => { + for (const separator of [',', '\t']) { + for (const failure of ['false', 'throw'] as const) { + for (const recovery of ['retry', 'discard'] as const) { + it(`keeps a failed ${failure} draft when disabled until ${recovery} (${JSON.stringify(separator)})`, async () => { + const host = await createEditorHarness({ separator }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.messages.length = 0; + view.start(view.cell()); view.type(' failed draft'); view.cell().blur(); + host.failEdits(failure); + await Promise.all(view.messages.map(message => host.receive(message))); + deliver(host, view); + const html = host.panel.webview.html; + const draft = view.cell().textContent; + const diskText = host.diskText; + host.setEnabled(false); + host.refresh(); + assert.strictEqual(host.isOpen, true, 'the failed draft must retain its controller and panel'); + assert.strictEqual(host.panel.webview.html, html, 'disable must retain the current DOM'); + assert.strictEqual(view.cell().textContent, draft); + assert.ok(draft.includes('failed draft')); + assert.deepStrictEqual(host.rows(), [['name', 'value'], ['one', 'two']]); + assert.strictEqual(host.diskText, diskText); + assert.deepStrictEqual(host.commands, [], 'disable must not open a different editor before recovery'); + assert.match(host.errors.at(-1)!, /Retry.*Escape.*before disabling/); + + host.failEdits(undefined); + view.messages.length = 0; + view.start(view.cell()); + if (recovery === 'discard') { view.key('Escape'); } + else { view.cell().blur(); } + await Promise.all(view.messages.map(message => host.receive(message))); + await flushViewCommits(host, view); + assert.strictEqual(host.rows()[1][0], recovery === 'retry' ? 'one failed draft' : 'one'); + assert.strictEqual(host.diskText, diskText, 'recovery must not force a save'); + host.refresh(); + assert.strictEqual(host.isOpen, false, 'normal disable proceeds after the failed draft is resolved'); + assert.deepStrictEqual(host.commands, [['vscode.openWith', host.document.uri, 'default']]); + } finally { + view.close(); + if (host.isOpen) { host.panel.dispose(); } + } + }); + } + } + } + for (const failure of ['false', 'throw'] as const) { it(`blocks row deletion after an ordinary ${failure} failure until Escape discards the draft`, async () => { const host = await createEditorHarness({ rows: [['name', 'value'], ['one', 'two'], ['three', 'four']] }); From 506b9371e5a55ce474aaddc1195bb7a3d68b2141 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:29:13 -0400 Subject: [PATCH 5/5] Validate visible text target in its editor group --- src/editor-switch.ts | 5 +++- src/test/editor-switch.test.ts | 51 +++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/editor-switch.ts b/src/editor-switch.ts index 8b5a9e6..080f4f5 100644 --- a/src/editor-switch.ts +++ b/src/editor-switch.ts @@ -109,7 +109,10 @@ export async function toggleCsvEditor(): Promise { ); targetReady = !!controller && await controller.commitPendingDraft(); } else if (targetReady) { - targetReady = vscode.window.activeTextEditor?.document.uri.toString() === uri.toString(); + // Text focus can remain in a different split group after opening the target. + targetReady = vscode.window.visibleTextEditors.some(editor => + editor.viewColumn === group.viewColumn && editor.document.uri.toString() === uri.toString() + ); } const afterReady = vscode.window.tabGroups.activeTabGroup; if (afterReady.viewColumn !== group.viewColumn || diff --git a/src/test/editor-switch.test.ts b/src/test/editor-switch.test.ts index 5df522a..1236a41 100644 --- a/src/test/editor-switch.test.ts +++ b/src/test/editor-switch.test.ts @@ -14,6 +14,8 @@ class TabInputTextDiff { constructor(public original: Uri, public modified: Uri) const state = { group: { viewColumn: 2, tabs: [] as any[], activeTab: undefined as any }, otherGroups: [] as any[], + activeTextEditor: undefined as any, + visibleTextEditors: undefined as any[] | undefined, enabled: true, version: '1.142.0', available: true, events: [] as any[][], errors: [] as string[], information: [] as string[], registered: new Map any>(), @@ -25,7 +27,16 @@ const vscode = { Uri, TabInputText, TabInputCustom, get version() { return state.version; }, window: { - get activeTextEditor() { return state.group.activeTab?.input instanceof TabInputText ? { document: { uri: state.group.activeTab.input.uri } } : undefined; }, + get activeTextEditor() { + return state.activeTextEditor ?? (state.group.activeTab?.input instanceof TabInputText + ? { document: { uri: state.group.activeTab.input.uri }, viewColumn: state.group.viewColumn } : undefined); + }, + get visibleTextEditors() { + return state.visibleTextEditors ?? [state.group, ...state.otherGroups].flatMap(group => + group.activeTab?.input instanceof TabInputText + ? [{ document: { uri: group.activeTab.input.uri }, viewColumn: group.viewColumn }] : [] + ); + }, tabGroups: { get activeTabGroup() { return state.group; }, get all() { return [state.group, ...state.otherGroups]; }, @@ -78,6 +89,7 @@ async function toggle() { await state.registered.get('csv.toggleEditor')!(); } beforeEach(() => { state.group = { viewColumn: 2, tabs: [], activeTab: undefined }; state.otherGroups = []; + state.activeTextEditor = undefined; state.visibleTextEditors = undefined; state.enabled = true; state.version = '1.142.0'; state.available = true; state.events = []; state.errors = []; state.information = []; state.registered.clear(); CsvEditorProvider.editors = []; vscode.workspace.textDocuments = []; @@ -147,6 +159,43 @@ describe('single-view CSV table/text switching', () => { assert.strictEqual(state.group.activeTab, target); }); + for (const extension of ['csv', 'tsv']) { + it(`replaces a ${extension} table when its ready text target is visible but focus remains in another group`, async () => { + const uri = new Uri('file', '', `/data.${extension}`); + activateTab(makeTab(uri, true)); + const unrelated = makeTab(new Uri('file', '', '/other-group.csv'), false); + const other = { viewColumn: 3, tabs: [unrelated], activeTab: unrelated }; + state.otherGroups = [other]; + state.activeTextEditor = { document: { uri: unrelated.input.uri }, viewColumn: 3 }; + await toggle(); + assert.deepStrictEqual(state.group.tabs.map(tab => tab.input.constructor), [TabInputText]); + assert.strictEqual(state.group.activeTab.input.uri, uri); + assert.strictEqual(state.group.activeTab.isDirty, true); + assert.deepStrictEqual(state.events.at(-1), ['reopenActiveEditorWith', 'default']); + assert.deepStrictEqual(other.tabs, [unrelated]); + assert.strictEqual(other.activeTab, unrelated); + assert.strictEqual(vscode.window.activeTextEditor.document.uri, unrelated.input.uri); + assert.deepStrictEqual(state.errors, []); + }); + + it(`retains a ${extension} table if matching text is visible only in a different group`, async () => { + const uri = new Uri('file', '', `/data.${extension}`); + const source = makeTab(uri, true); + activateTab(source); + const otherText = makeTab(uri, false); + const other = { viewColumn: 3, tabs: [otherText], activeTab: otherText }; + state.otherGroups = [other]; + state.activeTextEditor = { document: { uri }, viewColumn: 3 }; + state.visibleTextEditors = [state.activeTextEditor]; + await toggle(); + assert.ok(state.group.tabs.includes(source)); + assert.strictEqual(state.group.activeTab, source); + assert.ok(!state.events.some(event => event[0] === 'reopenActiveEditorWith')); + assert.deepStrictEqual(other.tabs, [otherText]); + assert.strictEqual(other.activeTab, otherText); + }); + } + it('keeps the dirty source when native opening fails or is cancelled', async () => { const source = makeTab(new Uri('file', '', '/data.csv'), false); activateTab(source); state.reveal = async () => { throw new Error('open failed'); };