diff --git a/README.md b/README.md index a36f0cb..1dac54b 100644 --- a/README.md +++ b/README.md @@ -27,7 +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. +- **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. For a saved local file, choosing Text also remembers a reversible file-path editor association, so Find in Files continues to open that file in text while CSV stays enabled. The choice survives a window restart and an extension Off/On cycle; a successful switch back to Table restores only that owned association. Other files continue to open as tables. File patterns follow VS Code’s case-insensitive matching. Remote/virtual URIs whose authority cannot be isolated still switch views, with guidance to turn CSV off before using Find in Files; longer user associations are preserved and reported if they prevent native routing. Searching a file that is still in table mode is outside this text-mode handoff. 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. @@ -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`) @@ -97,7 +99,7 @@ Open the Command Palette and search for: Global (Settings UI or `settings.json`): -- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor. The existing Toggle Extension command honors an explicit workspace value, otherwise it changes the global value. While off, CSV temporarily routes its four file selectors and existing explicit CSV-editor associations to the native text editor, so Find in Files preserves the destination and selection. Re-enabling restores the previous associations. Other editors, unrelated associations, and intervening user changes are preserved. Routing recovery metadata is stored in VS Code extension state; it contains editor identifiers and patterns, never CSV data. If settings or policy prevent routing, CSV reports the failure instead of claiming the toggle succeeded. +- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor. The existing Toggle Extension command honors an explicit workspace value, otherwise it changes the global value. While off, CSV temporarily routes its four file selectors and existing explicit CSV-editor associations to the native text editor, so Find in Files preserves the destination and selection. Re-enabling restores the previous disabled-state associations while retaining files explicitly switched to Text. Other editors, unrelated associations, and intervening user changes are preserved. Routing recovery metadata is stored in VS Code extension state; it contains editor identifiers and patterns, never CSV data. If settings or policy prevent routing, CSV reports the failure instead of claiming the toggle succeeded. - `csv.fontFamily` (string, default empty): Override font family; falls back to `editor.fontFamily`. - `csv.fontSize` (number, default `0`): Override font size in px; set to `0` to inherit `editor.fontSize`. - `csv.mouseWheelZoom` (boolean, default `true`): Enable `Ctrl/Cmd + Mouse Wheel` zooming in the CSV editor. diff --git a/media/main.js b/media/main.js index 04d192d..62a4c6f 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(); @@ -1965,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'); @@ -1973,7 +1986,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 +2053,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 +2101,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 +2220,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 0566c8c..0a8e436 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "onLanguage:csv", "onLanguage:tsv", "onCommand:csv.toggleExtension", + "onCommand:csv.toggleEditor", "onCommand:csv.toggleHeader", "onCommand:csv.toggleSerialIndex", "onCommand:csv.changeSeparator", @@ -65,6 +66,11 @@ } ], "commands": [ + { + "command": "csv.toggleEditor", + "title": "CSV: Switch Between Table and Text", + "icon": "$(arrow-swap)" + }, { "command": "csv.toggleExtension", "title": "CSV: Toggle Extension On/Off" @@ -102,6 +108,15 @@ "title": "CSV: Toggle Clickable Links" } ], + "menus": { + "editor/title": [ + { + "command": "csv.toggleEditor", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || (activeEditor == workbench.editors.files.textFileEditor && resourceScheme != untitled && 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..3cd8964 --- /dev/null +++ b/scripts/editor-switch-smoke.cjs @@ -0,0 +1,99 @@ +// 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'); +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-')); + await vscode.extensions.getExtension('ReprEng.csv').activate(); + try { + 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', { 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 = 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); 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"`; + } + } + } + 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); + } + } + + 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 c0639eb..cf2ad7c 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -64,9 +64,21 @@ 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; private pendingOperation: Promise = Promise.resolve(); private isDisposed = false; private defaultEditorHandoff: Promise | undefined; + private disabledViewHandoff: Promise | undefined; private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; private separatorCache: { version: number; configKey: string; separator: string } | undefined; @@ -90,6 +102,7 @@ class CsvEditorController { // Observe disposal before awaiting anything so a closed panel stays closed. webviewPanel.onDidDispose(() => { this.isDisposed = true; + this.finishDraftCommit(false); changeDocumentSubscription?.dispose(); saveDocumentSubscription?.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); @@ -110,7 +123,7 @@ class CsvEditorController { } const proceed = await this.confirmLargeFileOpen(config, webviewPanel, _token); - if (!proceed || this.isDisposed || _token.isCancellationRequested) { + if (!proceed || this.isDisposed || _token.isCancellationRequested || document.isClosed) { return; } // csv.enabled may have changed while a remote stat or the size prompt was pending. @@ -152,11 +165,43 @@ 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 }); + 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) { + 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': 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,7 +258,8 @@ class CsvEditorController { await this.openLinkExternally(e.url); break; } - })); + }); + }); changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { if ( @@ -233,6 +279,7 @@ class CsvEditorController { } }); + } private enqueueOperation(operation: () => void | Promise): Promise { @@ -244,6 +291,74 @@ 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, + needsFreshAck: false, + 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; + if (!vscode.workspace.getConfiguration('csv', this.document.uri).get('enabled', true)) { + void Promise.resolve().then(() => this.refresh()); + } else { this.updateWebviewContent(); } + } + pending.finish(committed && this.failedCellEdits.size === 0); + } + } + + 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) { @@ -350,19 +465,40 @@ class CsvEditorController { } public refresh() { - const config = vscode.workspace.getConfiguration('csv', this.document.uri); - if (!config.get('enabled', true)) { - const panel = this.currentWebviewPanel; - if (panel) { - void this.openWithDefaultEditorAndClose(panel, this.document.uri).catch(error => { - console.error('CSV: failed to reopen the text editor', error); - }); - } - } else { - if (this.currentWebviewPanel) { - this.forceReload(); + if (this.isDisposed) { return; } + const enabled = vscode.workspace.getConfiguration('csv', this.document.uri).get('enabled', true); + if (this.failedCellEdits.size) { + if (!enabled) { + this.refreshAfterDraftCommit = true; + void vscode.window.showErrorMessage('CSV: Retry the failed cell edit or press Escape to discard it before disabling the table view.'); } + return; } + if (!enabled) { + const panel = this.currentWebviewPanel; + if (panel) { this.requestDisabledViewHandoff(panel); } + } else if (!this.pendingDraftCommit && !this.disabledViewHandoff && this.currentWebviewPanel) { + this.forceReload(); + } + } + + private requestDisabledViewHandoff(panel: vscode.WebviewPanel): void { + if (this.disabledViewHandoff) { return; } + this.disabledViewHandoff = (async () => { + const pending = this.pendingDraftCommit; + const committed = pending ? await new Promise(resolve => { + const finish = pending.finish; + pending.finish = result => { finish(result); resolve(result); }; + }) : await this.commitPendingDraft(); + if (!committed || this.isDisposed || this.currentWebviewPanel !== panel) { return; } + if (vscode.workspace.getConfiguration('csv', this.document.uri).get('enabled', true)) { + // A rapid re-enable still commits the draft, then retains its table. + this.updateWebviewContent(); + return; + } + await this.openWithDefaultEditorAndClose(panel, this.document.uri); + })().catch(error => { console.error('CSV: failed to reopen the text editor', error); }) + .finally(() => { this.disabledViewHandoff = undefined; }); } private forceReload() { @@ -457,6 +593,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.requestRecoveryRefresh(); } + } + } + + 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; @@ -477,6 +645,7 @@ class CsvEditorController { structuralChange = !!(trimmed || createdRow || createdCol || row >= hadRows || col >= hadColsAtRow); if (!structuralChange && previousValue === value) { + this.clearFailedCellEdit(row, col, value); return; } @@ -493,6 +662,7 @@ class CsvEditorController { } if (newCsvText === oldText) { + this.clearFailedCellEdit(row, col, value); return; } @@ -504,13 +674,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); @@ -519,6 +694,9 @@ 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 (recoveredDraft && !this.failedCellEdits.size) { + this.requestRecoveryRefresh(); + } if (structuralChange) { try { this.updateWebviewContent(); } catch (e) { console.error('CSV: refresh failed after structural edit', e); } } @@ -1418,12 +1596,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; @@ -2027,6 +2210,9 @@ class CsvEditorController { } private getSeparator(): string { + if ((this.failedCellEdits.size || this.refreshAfterDraftCommit) && 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/commands.ts b/src/commands.ts index 912889a..c7fc664 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,8 @@ import { getFonts } from 'font-list'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; +import { toggleCsvEditor } from './editor-switch'; +import type { EditorSwitchRouting } from './editor-associations'; function displaySeparator(separator: string): string { return separator === '\t' ? '\\t' : separator; @@ -43,9 +45,10 @@ async function toggleCsvEnabled(synchronizeRouting: () => Promise): Promis CsvEditorProvider.editors.slice().forEach(ed => ed.refresh()); } -export function registerCsvCommands(context: vscode.ExtensionContext, synchronizeRouting: () => Promise = async () => {}) { +export function registerCsvCommands(context: vscode.ExtensionContext, synchronizeRouting: () => Promise = async () => {}, prepareEditorRouting?: (uri: vscode.Uri, target: 'default' | 'csv.editor') => Promise) { let pendingToggle: Promise = Promise.resolve(); context.subscriptions.push( + vscode.commands.registerCommand('csv.toggleEditor', () => toggleCsvEditor(prepareEditorRouting)), vscode.commands.registerCommand('csv.toggleExtension', () => { const next = pendingToggle.catch(() => {}).then(() => toggleCsvEnabled(synchronizeRouting)); pendingToggle = next; diff --git a/src/editor-switch.ts b/src/editor-switch.ts new file mode 100644 index 0000000..9597473 --- /dev/null +++ b/src/editor-switch.ts @@ -0,0 +1,207 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { CsvEditorProvider } from './CsvEditorProvider'; +import type { EditorSwitchRouting } from './editor-associations'; + +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 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.` + ); +} + +function observeEditorReplacement(uri: vscode.Uri, source: string, target: string, viewColumn: vscode.ViewColumn) { + let resolve!: (complete: boolean) => void; + let subscription: vscode.Disposable | undefined; + let timer: ReturnType | undefined; + let settled = false; + const promise = new Promise(finish => { resolve = finish; }); + const finish = (complete: boolean) => { + if (settled) { return; } + settled = true; + subscription?.dispose(); + if (timer) { clearTimeout(timer); } + resolve(complete); + }; + const check = () => { + const group = vscode.window.tabGroups.all.find(candidate => candidate.viewColumn === viewColumn); + if (group?.tabs.some(tab => getInputUri(tab.input, target)?.toString() === uri.toString()) && + !group.tabs.some(tab => getInputUri(tab.input, source)?.toString() === uri.toString())) { finish(true); } + }; + // Native command completion can precede extension-host TabGroups events. + subscription = vscode.window.tabGroups.onDidChangeTabs(check); + return { promise, check, startTimeout: () => { + if (!settled) { timer = setTimeout(() => finish(false), 5000); } + }, cancel: () => finish(false) }; +} + +export async function toggleCsvEditor(prepareRouting?: (uri: vscode.Uri, target: 'default' | 'csv.editor') => Promise): 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 || 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}:${uri.toString()}`; + if (switchingTabs.has(switchKey)) { + return; + } + if (target === CsvEditorProvider.viewType && !vscode.workspace.getConfiguration('csv', uri).get('enabled', true)) { + 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); + let routing: EditorSwitchRouting | undefined; + let replaced = false; + let replacement: ReturnType | undefined; + try { + 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 pinned = vscode.window.tabGroups.activeTabGroup; + if (pinned.viewColumn !== group.viewColumn || getInputUri(pinned.activeTab?.input, source)?.toString() !== uri.toString()) { + return; + } + } + routing = await prepareRouting?.(uri, target); + const afterRouting = vscode.window.tabGroups.activeTabGroup; + if (afterRouting.viewColumn !== group.viewColumn || getInputUri(afterRouting.activeTab?.input, source)?.toString() !== uri.toString()) { return; } + if (routing?.limitation) { void vscode.window.showInformationMessage(routing.limitation); } + // An unqualified open proves that the per-file association actually wins; + // explicit openWith would hide a longer user association taking precedence. + if (target === 'default' && routing?.supported) { + await vscode.commands.executeCommand('vscode.open', uri, { + viewColumn: group.viewColumn, preserveFocus: false, preview: false + }); + } else { + await vscode.commands.executeCommand('vscode.openWith', uri, target, { + 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) { + // 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 || + (targetReady && getInputUri(afterReady.activeTab?.input, target)?.toString() !== uri.toString())) { + return; + } + if (!targetReady) { + if (target === 'default' && routing?.supported) { void vscode.window.showInformationMessage('CSV: Your editor associations still choose another view. They have been preserved; turn CSV off before using Find in Files.'); } + 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. + replacement = observeEditorReplacement(uri, source, target, group.viewColumn); + await vscode.commands.executeCommand('reopenActiveEditorWith', target); + replacement.check(); + replacement.startTimeout(); + replaced = await replacement.promise; + if (replaced) { await routing?.commit(); } + } catch (error) { + console.error('CSV: could not switch editor', error); + void vscode.window.showErrorMessage('CSV: Could not switch between the table and text editor.'); + } finally { + replacement?.cancel(); + if (!replaced) { + try { await routing?.rollback(); } catch (error) { console.error('CSV: could not restore file routing after a cancelled switch', error); } + } + switchingTabs.delete(switchKey); + } +} diff --git a/src/extension.ts b/src/extension.ts index 3480ca2..d64ba07 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -113,7 +113,7 @@ export async function activate(context: vscode.ExtensionContext) { // Listen before the first await so settings changes during recovery are queued. // Persisted Off routing must be installed before commands/provider handoffs. try { await synchronizeRouting(); } catch (error) { console.error('CSV: editor routing initialization failed', error); } - registerCsvCommands(context, synchronizeRouting); + registerCsvCommands(context, synchronizeRouting, (uri, target) => routing.prepareEditorSwitch(uri, target)); // Register the custom editor provider for CSV/TSV context.subscriptions.push( diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index fe987e2..023b85f 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -20,9 +20,19 @@ const vscodeStub = { replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } }, ColorThemeKind: { Dark: 1 }, - window: { activeColorTheme: { kind: 1 } }, + window: { + activeColorTheme: { kind: 1 }, + visibleTextEditors: [], + 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); @@ -57,10 +67,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, @@ -87,9 +99,11 @@ export async function createEditorHarness(options: { }; await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); const panel = { + disposed: false, active: true, visible: true, title: 'data.csv', + viewColumn: 2, webview: { html: '', options: {}, @@ -99,15 +113,30 @@ 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() { if (!panel.disposed) { panel.disposed = true; disposeListeners.forEach(listener => listener()); } } }; const harness = { document, panel, messages, changeListeners, saveListeners, + errors: [] as string[], + commands: [] as unknown[][], + enabled: true, + setEnabled(enabled: boolean) { this.enabled = enabled; }, + dispose() { panel.dispose(); }, + 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; }, 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 { + if (editFailure === 'false') { return false; } + if (editFailure === 'throw') { throw new Error('applyEdit rejected'); } return new Promise(resolve => { const apply = () => { text = nextText; @@ -181,3 +210,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-lifecycle.test.ts b/src/test/editor-lifecycle.test.ts index d3731d6..fd5845c 100644 --- a/src/test/editor-lifecycle.test.ts +++ b/src/test/editor-lifecycle.test.ts @@ -69,8 +69,10 @@ function makePanel(viewColumn = 1) { const resources: Uri[] = []; let html = ''; let disposed = false; + let receiveMessage: (message: any) => Promise = async () => {}; const panel = { active: true, + visible: true, viewColumn, title: 'test.csv', get disposed() { return disposed; }, @@ -84,7 +86,7 @@ function makePanel(viewColumn = 1) { }, asWebviewUri: (uri: Uri) => { resources.push(uri); return uri; }, postMessage: (message: any) => { messages.push(message); return Promise.resolve(true); }, - onDidReceiveMessage: () => ({ dispose() {} }) + onDidReceiveMessage: (listener: typeof receiveMessage) => { receiveMessage = listener; return { dispose() {} }; } }, onDidDispose: (callback: () => void) => { disposeListeners.push(callback); @@ -100,6 +102,7 @@ function makePanel(viewColumn = 1) { disposeListeners.forEach(callback => callback()); } }, + receive: (message: any) => receiveMessage(message), setActive: (active: boolean) => { panel.active = active; viewStateListeners.forEach(callback => callback({ webviewPanel: panel })); @@ -215,7 +218,7 @@ describe('CSV editor lifecycle and native text handoff', () => { it('reopens the correct group once when disabling with no visible text editor', async () => { const document = makeDocument(new Uri('file', '', '/data.csv')); - const { panel } = makePanel(2); + const { panel, messages } = makePanel(2); await resolve(document, panel); const controller = CsvEditorProvider.getActiveProvider()!; panel.setActive(false); @@ -224,12 +227,16 @@ describe('CSV editor lifecycle and native text handoff', () => { state.enabled = false; controller.refresh(); controller.refresh(); + assert.strictEqual(state.commands.length, 0, 'disable waits for the live draft'); + const request = messages.filter(message => message.type === 'prepareViewRefresh').at(-1); + const completing = panel.receive({ type: 'viewRefreshReady', requestId: request.requestId }); + await new Promise(resolve => setImmediate(resolve)); assert.deepStrictEqual(state.commands, [[ 'vscode.openWith', document.uri, 'default', { viewColumn: 2, preserveFocus: true } ]]); finishOpen(); - await Promise.resolve(); - await Promise.resolve(); + await completing; + await new Promise(resolve => setImmediate(resolve)); controller.refresh(); assert.strictEqual(state.commands.length, 1, 'a disposed controller cannot reopen the file later'); assert.strictEqual(panel.disposed, true); diff --git a/src/test/editor-switch-draft.test.ts b/src/test/editor-switch-draft.test.ts new file mode 100644 index 0000000..f55219a --- /dev/null +++ b/src/test/editor-switch-draft.test.ts @@ -0,0 +1,95 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom, flushViewCommits } 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))); + await flushViewCommits(host, view); + 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 new file mode 100644 index 0000000..604113c --- /dev/null +++ b/src/test/editor-switch.test.ts @@ -0,0 +1,403 @@ +import assert from 'assert'; +import fs from 'fs'; +import Module from 'module'; +import path from 'path'; +import { beforeEach, describe, it } from 'node:test'; +import type { EditorSwitchRouting } from '../editor-associations'; + +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 }, + 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>(), + prepareRouting: undefined as ((uri: Uri, target: string) => Promise) | undefined, + unqualifiedTable: false, + tabListeners: new Set<() => void>(), + 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.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: { + onDidChangeTabs: (listener: () => void) => { state.tabListeners.add(listener); return { dispose: () => state.tabListeners.delete(listener) }; }, + get activeTabGroup() { return state.group; }, + 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: { textDocuments: [] as any[], getConfiguration: () => ({ get: () => state.enabled }) }, + commands: { + 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 === 'vscode.open') { await state.reveal(args[0], state.unqualifiedTable ? 'csv.editor' : 'default'); } + 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() {} }; + } + } +}; +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'; +import { CsvEditorProvider } from '../CsvEditorProvider'; +import { supportsEditorReplacement } from '../editor-switch'; +Module.prototype.require = originalRequire; + +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; + 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.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 = []; + state.prepareRouting = undefined; state.unqualifiedTable = false; state.tabListeners.clear(); + registerCsvCommands({ subscriptions: [] } as any, async () => {}, (uri, target) => state.prepareRouting?.(uri, target) ?? Promise.resolve({ supported: false, commit: async () => {}, rollback: async () => {} })); + state.commit = async () => true; + state.reveal = async (uri, viewType) => { + const source = state.group.activeTab; + const table = viewType === 'csv.editor'; + 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) + ) ?? 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); + } + }; +}); + +describe('single-view CSV table/text switching', () => { + for (const scheme of ['file', 'vscode-remote']) { + for (const extension of ['csv', 'tsv', 'tab', 'psv', 'CSV']) { + it(`replaces only the ${scheme} .${extension} source in its group`, async () => { + const uri = new Uri(scheme, scheme === 'file' ? '' : 'wsl+Ubuntu', `/data/example.${extension}`); + activateTab(makeTab(uri, false)); + const unrelated = makeTab(new Uri('file', '', '/other.csv'), false); + state.group.tabs.push(unrelated); + 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('reuses a counterpart that is already open while removing only its source', async () => { + const uri = new Uri('file', '', '/data.csv'); + const source = makeTab(uri, false); const target = makeTab(uri, true); + activateTab(source); state.group.tabs.push(target); + await toggle(); + assert.deepStrictEqual(state.group.tabs, [target]); + 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'); }; + await toggle(); + 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('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(); + assert.deepStrictEqual(state.events.map(event => event[0]), ['vscode.openWith', 'commit']); + assert.strictEqual(state.group.activeTab.input.uri.path, '/another.csv'); + }); + + 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(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('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.at(-1)?.[1], 'default'); + }); + + 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')), new TabInputText(new Uri('untitled', '', '/data.csv'))]) { + activateTab({ input }); await toggle(); + } + 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('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 action = manifest.contributes.menus['editor/title'].find((entry: any) => entry.command === 'csv.toggleEditor'); + 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(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')); + }); +}); + + +describe('Text routing transaction around native replacement', () => { + function transaction(): EditorSwitchRouting { + return { supported: true, commit: async () => { state.events.push(['routing-commit']); }, rollback: async () => { state.events.push(['routing-rollback']); } }; + } + it('proves native routing with an unqualified open and finalizes only after replacement', async () => { + const uri = new Uri('file', '', '/data/check.csv'); activateTab(makeTab(uri, true)); + state.prepareRouting = async () => { state.events.push(['routing-prepare']); return transaction(); }; + await toggle(); + assert.ok(state.group.activeTab.input instanceof TabInputText); + assert.ok(state.events.some(event => event[0] === 'vscode.open')); + const replacement = state.events.findIndex(event => event[0] === 'reopenActiveEditorWith'); + assert.ok(state.events.findIndex(event => event[0] === 'routing-commit') > replacement); + assert.ok(state.events.every(event => event[0] !== 'routing-rollback')); + }); + it('rolls provisional Text routing back after a failed native replacement', async () => { + activateTab(makeTab(new Uri('file', '', '/data/check.csv'), true)); + state.prepareRouting = async () => transaction(); state.replace = async () => { throw new Error('cancelled'); }; + await toggle(); + assert.ok(state.events.some(event => event[0] === 'routing-rollback')); + assert.ok(state.events.every(event => event[0] !== 'routing-commit')); + assert.ok(state.group.activeTab.input instanceof TabInputCustom); + }); + it('does not close the source or finalize Text when a longer user association still chooses table', async () => { + activateTab(makeTab(new Uri('file', '', '/data/check.csv'), true)); + state.prepareRouting = async () => transaction(); state.unqualifiedTable = true; + await toggle(); + assert.ok(state.group.activeTab.input instanceof TabInputCustom); + assert.ok(state.events.every(event => event[0] !== 'reopenActiveEditorWith')); + assert.ok(state.events.some(event => event[0] === 'routing-rollback')); + assert.ok(state.information.some(message => message.includes('editor associations still choose'))); + }); + it('retains the established remote switch with honest search guidance', async () => { + activateTab(makeTab(new Uri('vscode-remote', 'wsl+Ubuntu', '/data/check.csv'), true)); + state.prepareRouting = async () => ({ ...transaction(), supported: false, limitation: 'Turn CSV off before Find in Files for this URI.' }); + await toggle(); + assert.ok(state.group.activeTab.input instanceof TabInputText); + assert.ok(state.events.every(event => event[0] !== 'vscode.open')); + assert.ok(state.information.some(message => message.includes('Turn CSV off'))); + }); + it('rolls back prepared routing if the user changes group before target opening', async () => { + activateTab(makeTab(new Uri('file', '', '/data/check.csv'), true)); + const original = state.group; + state.prepareRouting = async () => { + state.otherGroups = [original]; state.group = { viewColumn: 3, tabs: [], activeTab: undefined }; + return transaction(); + }; + await toggle(); + assert.ok(state.events.some(event => event[0] === 'routing-rollback')); + assert.ok(state.events.every(event => !['vscode.open', 'vscode.openWith', 'reopenActiveEditorWith'].includes(event[0]))); + assert.ok(original.activeTab.input instanceof TabInputCustom); + }); + + it('waits for delayed extension-host tab events before committing Table routing', async () => { + activateTab(makeTab(new Uri('file', '', '/data/check.csv'), false)); + state.prepareRouting = async () => transaction(); + const replace = state.replace; + state.replace = async viewType => { + setImmediate(async () => { await replace(viewType); state.tabListeners.forEach(listener => listener()); }); + }; + await toggle(); + assert.ok(state.group.activeTab.input instanceof TabInputCustom); + assert.ok(state.events.some(event => event[0] === 'routing-commit')); + assert.ok(state.events.every(event => event[0] !== 'routing-rollback')); + assert.strictEqual(state.tabListeners.size, 0); + }); + + it('starts its tab-event timeout after a slow native command completes', async () => { + activateTab(makeTab(new Uri('file', '', '/data/check.csv'), false)); + state.prepareRouting = async () => transaction(); + const replace = state.replace; + let release!: () => void; + state.replace = async () => { await new Promise(resolve => { release = resolve; }); }; + const originalTimer = global.setTimeout; + const timers: Array<() => void> = []; + global.setTimeout = ((callback: () => void, milliseconds: number, ...args: any[]) => { + if (milliseconds === 5000) { timers.push(callback); return {} as any; } + return originalTimer(callback, milliseconds, ...args); + }) as typeof setTimeout; + try { + const changing = toggle(); + while (!release) { await new Promise(resolve => setImmediate(resolve)); } + assert.strictEqual(timers.length, 0, 'a slow native command cannot consume the event-settlement timeout'); + release(); + while (!timers.length) { await new Promise(resolve => setImmediate(resolve)); } + await replace('csv.editor'); state.tabListeners.forEach(listener => listener()); + await changing; + assert.ok(state.events.some(event => event[0] === 'routing-commit')); + assert.ok(state.events.every(event => event[0] !== 'routing-rollback')); + } finally { global.setTimeout = originalTimer; } + }); + +}); diff --git a/src/test/failed-draft-recovery.test.ts b/src/test/failed-draft-recovery.test.ts new file mode 100644 index 0000000..b619382 --- /dev/null +++ b/src/test/failed-draft-recovery.test.ts @@ -0,0 +1,223 @@ +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 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'); + await new Promise(resolve => setImmediate(resolve)); + await flushViewCommits(host, view); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.isOpen, false, 'pending disable proceeds after the failed draft is resolved'); + assert.deepStrictEqual(host.commands, [['vscode.openWith', host.document.uri, 'default', { viewColumn: 2, preserveFocus: false }]]); + } 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']] }); + 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(); } + }); +}); diff --git a/src/test/saved-disable-draft.test.ts b/src/test/saved-disable-draft.test.ts new file mode 100644 index 0000000..de626d6 --- /dev/null +++ b/src/test/saved-disable-draft.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom, flushViewCommits } from './edit-integrity-harness'; +const turn = () => new Promise(resolve => setImmediate(resolve)); +function prepare(host: Awaited>, view: ReturnType) { + const request = host.messages.filter(message => message.type === 'prepareViewRefresh').at(-1); + assert.ok(request); + const cursor = view.messages.length; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + return view.messages.slice(cursor); +} +describe('Saved CSV disable uses the existing draft commit before native handoff', () => { + for (const separator of [',', '\t']) { + it(`waits for the live multiline ${JSON.stringify(separator)} draft and hands off once without saving`, async () => { + const host = await createEditorHarness({ separator }), view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.key('Enter', { shiftKey: true }); view.type('disable draft'); + host.deferEdits(); host.setEnabled(false); host.refresh(); host.refresh(); + assert.strictEqual(host.panel.disposed, false); assert.deepStrictEqual(host.commands, []); + const pending = prepare(host, view).map(message => host.receive(message)); + await turn(); assert.strictEqual(host.pendingEdits, 1); assert.deepStrictEqual(host.commands, []); + host.releaseEdit(); await Promise.all(pending); await flushViewCommits(host, view); await turn(); + assert.strictEqual(host.rows()[1][0], 'one\ndisable draft'); + assert.ok(!host.diskText.includes('disable draft')); + assert.strictEqual(host.panel.disposed, true); + assert.deepStrictEqual(host.commands, [['vscode.openWith', host.document.uri, 'default', { viewColumn: 2, preserveFocus: false }]]); + } finally { view.close(); host.dispose(); } + }); + } + for (const failure of ['false', 'throw'] as const) { + it(`keeps the ${failure} draft on disable and completes handoff after a successful retry`, async () => { + const host = await createEditorHarness(), view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type('failed draft'); host.failEdits(failure); + host.setEnabled(false); host.refresh(); + await Promise.all(prepare(host, view).map(message => host.receive(message))); await turn(); + assert.strictEqual(host.panel.disposed, false); assert.strictEqual(host.rows()[1][0], 'one'); + assert.deepStrictEqual(host.commands, []); assert.ok(view.cell().textContent.includes('failed draft')); + host.failEdits(undefined); + const failed = host.messages.find(message => message.type === 'editCellFailed'); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: failed })); + const cursor = view.messages.length; view.cell().blur(); + await Promise.all(view.messages.slice(cursor).map(message => host.receive(message))); + await flushViewCommits(host, view); await turn(); + // Recovery can start a final disable commit after its own ACK resolves. + await flushViewCommits(host, view); await turn(); + assert.strictEqual(host.rows()[1][0], 'onefailed draft'); + assert.strictEqual(host.panel.disposed, true); assert.strictEqual(host.commands.length, 1); + assert.ok(!host.diskText.includes('failed draft')); + } finally { view.close(); host.dispose(); } + }); + } + it('joins an already pending draft round and cancels teardown after rapid re-enable', async () => { + const host = await createEditorHarness(), view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); view.type('retained'); + const commit = host.commitPendingDraft(), requestId = host.pendingDraftRequestId; + host.setEnabled(false); host.refresh(); host.refresh(); + assert.strictEqual(host.pendingDraftRequestId, requestId); + host.setEnabled(true); host.refresh(); + await Promise.all(prepare(host, view).map(message => host.receive(message))); + assert.strictEqual(await commit, true); await turn(); + assert.strictEqual(host.rows()[1][0], 'oneretained'); assert.strictEqual(host.panel.disposed, false); + assert.deepStrictEqual(host.commands, []); + } finally { view.close(); host.dispose(); } + }); +});