diff --git a/README.md b/README.md index 1dac54b..84d6312 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Advanced Multi-Cell Selection:** Easily select and copy blocks of data, then paste them elsewhere as properly formatted CSV. - **Add/Delete Columns:** Right-click any cell to add a column left or right, or remove the selected column. - **Add/Delete Rows:** Insert above/below or remove the selected row via context menu. +- **Untitled CSV/TSV:** Choose CSV or TSV language mode in a new untitled text document to open its table without saving first. Separator, header, serial-index, and hidden-row commands work in this view; save the document before changing file encoding. Switching from the untitled table to text commits its live cell, reveals the same native document, and closes the temporary table. Switching back creates a table against that document. Unlike saved-file view replacement, opening an untitled table retains its native source tab because it owns the unsaved document. Use the table’s **Save Untitled Document As…** action or **Ctrl/Cmd+S** in either selection or cell-edit mode. This commits the active cell, reveals the source, and uses VS Code’s normal Save As flow; the temporary table closes when the untitled document becomes a saved file. VS Code’s native File > Save and Save command cannot target a standalone table panel; switch to the source before using those native actions. Changing CSV/TSV language mode preserves pending edits before rebuilding the table. - **Edit Empty CSVs:** Create or open an empty CSV file and start typing immediately. - **Column Sorting:** Right-click a header and choose A–Z or Z–A. - **Custom Font Controls:** Choose a font family and optional font-size override, or inherit VS Code defaults. diff --git a/media/main.js b/media/main.js index 62a4c6f..ffb911c 100644 --- a/media/main.js +++ b/media/main.js @@ -1499,6 +1499,12 @@ document.addEventListener('keydown', e => { return; } const key = typeof e.key === 'string' ? e.key.toLowerCase() : ''; + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && key === 's') { + e.preventDefault(); + if (editingCell) { editingCell.blur(); } + vscode.postMessage({ type: 'save' }); + return; + } if ((e.ctrlKey || e.metaKey) && key === 'f') { e.preventDefault(); openFindReplace(false); @@ -1760,11 +1766,6 @@ document.addEventListener('keydown', e => { return; } - if (editingCell && ((e.ctrlKey || e.metaKey) && e.key === 's')) { - e.preventDefault(); - editingCell.blur(); - vscode.postMessage({ type: 'save' }); - } if (editingCell && e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) { diff --git a/package.json b/package.json index 0a8e436..7157db9 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "onLanguage:tsv", "onCommand:csv.toggleExtension", "onCommand:csv.toggleEditor", + "onCommand:csv.saveUntitled", "onCommand:csv.toggleHeader", "onCommand:csv.toggleSerialIndex", "onCommand:csv.changeSeparator", @@ -71,6 +72,12 @@ "title": "CSV: Switch Between Table and Text", "icon": "$(arrow-swap)" }, + { + "command": "csv.saveUntitled", + "title": "CSV: Save Untitled Document As\u2026", + "icon": "$(save)", + "enablement": "activeWebviewPanelId == csv.untitledEditor" + }, { "command": "csv.toggleExtension", "title": "CSV: Toggle Extension On/Off" @@ -112,8 +119,19 @@ "editor/title": [ { "command": "csv.toggleEditor", - "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || (activeEditor == workbench.editors.files.textFileEditor && resourceScheme != untitled && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i))", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor || (activeEditor == workbench.editors.files.textFileEditor && resourceScheme != untitled && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i) || (activeEditor == workbench.editors.textResourceEditor && resourceScheme == untitled && editorLangId =~ /^(csv|tsv)$/))", "group": "navigation" + }, + { + "command": "csv.saveUntitled", + "when": "activeWebviewPanelId == csv.untitledEditor", + "group": "navigation@1" + } + ], + "commandPalette": [ + { + "command": "csv.saveUntitled", + "when": "activeWebviewPanelId == csv.untitledEditor" } ] }, @@ -129,7 +147,7 @@ "csv.fontFamily": { "type": "string", "default": "", - "description": "Font family used by the CSV custom editor. Leave empty to inherit ‘editor.fontFamily’.", + "description": "Font family used by the CSV custom editor. Leave empty to inherit \u2018editor.fontFamily\u2019.", "scope": "application" }, "csv.fontSize": { diff --git a/scripts/untitled-editor-smoke.cjs b/scripts/untitled-editor-smoke.cjs new file mode 100644 index 0000000..e86888b --- /dev/null +++ b/scripts/untitled-editor-smoke.cjs @@ -0,0 +1,119 @@ +// 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 { getActiveUntitledCsvEditor } = require('../out/untitled-editors'); + +async function waitUntil(predicate, message) { + const deadline = Date.now() + 5000; + while (!predicate()) { + if (Date.now() > deadline) { throw new Error(message); } + await new Promise(resolve => setTimeout(resolve, 20)); + } +} +async function prepare(document, language, content) { + await vscode.window.showTextDocument(document, { preview: false }); + const edit = new vscode.WorkspaceEdit(); + edit.insert(document.uri, new vscode.Position(0, 0), content); + assert.equal(await vscode.workspace.applyEdit(edit), true); + const live = await vscode.languages.setTextDocumentLanguage(document, language); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'language mode should open the untitled table'); + assert.equal(live.getText(), content); + assert.ok(vscode.window.tabGroups.all.flatMap(group => group.tabs).some(tab => tab.input.uri?.toString() === live.uri.toString() && tab.isDirty), 'native source must retain its unsaved state'); + return live; +} + +exports.run = async function () { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'csv-untitled-')); + await vscode.extensions.getExtension('ReprEng.csv').activate(); + try { + for (const language of ['csv', 'tsv']) { + const separator = language === 'tsv' ? '\t' : ','; + const filename = path.join(directory, `saved.${language}`); + const uri = vscode.Uri.file(filename).with({ scheme: 'untitled' }); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.languages.setTextDocumentLanguage(document, 'plaintext'); + const contents = `name${separator}value\nnew${separator}42`; + let live = await prepare(document, language, contents); + await new Promise(resolve => setTimeout(resolve, 200)); + await vscode.workspace.getConfiguration('csv').update('enabled', false, vscode.ConfigurationTarget.Global); + await waitUntil(() => vscode.window.activeTextEditor?.document.uri.toString() === live.uri.toString() && !getActiveUntitledCsvEditor(), 'disabling should reveal the native untitled owner'); + assert.equal(live.getText(), contents); + assert.equal(fs.existsSync(filename), false, 'disabling must not save'); + await vscode.workspace.getConfiguration('csv').update('enabled', true, vscode.ConfigurationTarget.Global); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'enabling should safely reopen associated-path untitled buffers'); + assert.equal(live.getText(), contents); + let controller = CsvEditorProvider.getActiveProvider(); + assert.ok(controller); + assert.equal(controller.getCurrentSeparator(), separator); + const oppositeLanguage = language === 'csv' ? 'tsv' : 'csv'; + live = await vscode.languages.setTextDocumentLanguage(live, oppositeLanguage); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live && controller.getCurrentSeparator() === (oppositeLanguage === 'tsv' ? '\t' : ','), 'native language rebind should use its logical extension'); + assert.equal(live.getText(), contents, 'language change must not convert source data'); + live = await vscode.languages.setTextDocumentLanguage(live, language); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live && controller.getCurrentSeparator() === separator, 'native language roundtrip should restore its logical extension'); + assert.equal(live.getText(), contents); + await controller.updateDocument(1, 1, '43'); + const expected = `name${separator}value\nnew${separator}43`; + assert.equal(live.getText(), expected); + const previousPanel = getActiveUntitledCsvEditor().panel; + await vscode.commands.executeCommand('csv.toggleEditor'); + assert.equal(vscode.window.activeTextEditor.document.uri.toString(), live.uri.toString()); + assert.equal(vscode.window.activeTextEditor.document.getText(), expected); + assert.equal(getActiveUntitledCsvEditor(), undefined, 'source switch closes only the standalone table'); + assert.equal(live.isClosed, false, 'native unsaved owner remains live'); + assert.equal(fs.existsSync(filename), false, 'source switching must not save'); + await new Promise(resolve => setTimeout(resolve, 50)); + assert.ok(vscode.window.activeTextEditor, 'explicit source switch must stay in source'); + await vscode.commands.executeCommand('csv.toggleEditor'); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'switch should recreate the untitled grid against the same owner'); + assert.notEqual(getActiveUntitledCsvEditor().panel, previousPanel); + controller = CsvEditorProvider.getActiveProvider(); + const savedExpected = `name${separator}value\nnew${separator}"43\nqueued"`; + void controller.enqueueOperation(() => controller.updateDocument(1, 1, '43\nqueued')); + await vscode.commands.executeCommand('csv.saveUntitled'); + await waitUntil(() => live.isClosed && fs.existsSync(filename) && fs.readFileSync(filename, 'utf8') === savedExpected && !CsvEditorProvider.editors.some(candidate => candidate.getDocumentUri().toString() === live.uri.toString()), 'registered native save should finish writing exact bytes and close the temporary grid'); + assert.equal(fs.readFileSync(filename, 'utf8'), savedExpected); + await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.file(filename), 'csv.editor', { preview: false }); + assert.equal(CsvEditorProvider.getActiveProvider().getCurrentSeparator(), separator); + } + + const document = await vscode.workspace.openTextDocument({ language: 'plaintext' }); + const live = await prepare(document, 'csv', 'name,value\ncancel,42'); + const panel = getActiveUntitledCsvEditor().panel; + const controller = CsvEditorProvider.getActiveProvider(); + // Exercise the cancellation result without opening an interactive native picker. + // Associated-path documents above exercise actual native saving and disk bytes. + const executeCommand = vscode.commands.executeCommand; + let cancelledSaveCalls = 0; + vscode.commands.executeCommand = (command, ...args) => { + if (command === 'workbench.action.files.save') { + cancelledSaveCalls += 1; + return Promise.resolve(); + } + return executeCommand(command, ...args); + }; + try { + await vscode.commands.executeCommand('csv.saveUntitled'); + await waitUntil(() => vscode.window.activeTextEditor?.document.uri.toString() === live.uri.toString() && !panel.active, 'registered Save As command should reveal the native owner and finish its view-state transition'); + await controller.enqueueOperation(() => {}); + } finally { + vscode.commands.executeCommand = executeCommand; + } + assert.equal(cancelledSaveCalls, 1); + assert.equal(live.isClosed, false); + assert.ok(vscode.window.tabGroups.all.flatMap(group => group.tabs).some(tab => tab.input.uri?.toString() === live.uri.toString() && tab.isDirty), 'cancel must retain the dirty native source'); + assert.equal(live.getText(), 'name,value\ncancel,42'); + await vscode.commands.executeCommand('csv.toggleEditor'); + await waitUntil(() => getActiveUntitledCsvEditor()?.panel === panel, 'cancelled Save As must retain and reuse the grid'); + panel.dispose(); + assert.equal(live.getText(), 'name,value\ncancel,42'); + assert.equal(CsvEditorProvider.currentActive, undefined); + console.log('CSV_UNTITLED_SMOKE_OK: pretyped CSV+TSV, language roundtrips, shared edits, source switching, disable/re-enable, registered Save As exact native saves, controlled Save As cancellation retained'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}; diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index cf2ad7c..065f3b9 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -61,24 +61,24 @@ class CsvEditorController { private static readonly DEFAULT_MAX_FILE_SIZE_MB = 10; private static readonly LARGE_FILE_CONTINUE_THIS_TIME = 'Continue This Time'; private static readonly LARGE_FILE_IGNORE_FOREVER = 'Ignore Forever'; + private static readonly DRAFT_COMMIT_TIMEOUT_MS = 5000; private isUpdatingDocument = false; private isSaving = false; - private draftCommitSequence = 0; - private draftActivityRevision = 0; + private pendingViewAction: (() => Promise) | undefined; + private pendingDocumentReference: { document: vscode.TextDocument; separator: string } | undefined; 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 viewCommitRequested = false; + private viewCommitSequence = 0; + private viewCommitNeedsFreshAck = false; + private draftActivityRevision = 0; + private pendingClose = false; private pendingOperation: Promise = Promise.resolve(); + private readonly failedCellEdits = new Map(); + private refreshAfterDraftCommit = false; + private readonly draftCommitWaiters = new Set<{ resolve: (success: boolean) => void; timer: ReturnType }>(); 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; @@ -102,7 +102,10 @@ class CsvEditorController { // Observe disposal before awaiting anything so a closed panel stays closed. webviewPanel.onDidDispose(() => { this.isDisposed = true; - this.finishDraftCommit(false); + this.pendingViewAction = undefined; + this.pendingDocumentReference = undefined; + this.viewCommitRequested = false; + this.resolveDraftCommitWaiters(false); changeDocumentSubscription?.dispose(); saveDocumentSubscription?.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); @@ -182,20 +185,28 @@ class CsvEditorController { return; } switch (e.type) { - case 'webviewReady': - this.postDraftCommitRequest(); + case 'webviewReady': { + if (this.viewCommitRequested) { + this.postViewCommitRequest(); + } 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); - } + } + case 'viewRefreshReady': { + if (this.viewCommitRequested && e.requestId !== this.viewCommitSequence) { + break; + } + if (!this.viewCommitRequested && e.requestId !== undefined) { + break; + } + if (this.viewCommitRequested && (this.viewCommitNeedsFreshAck || receivedRevision !== this.draftActivityRevision)) { + this.viewCommitNeedsFreshAck = false; + this.viewCommitSequence += 1; + this.postViewCommitRequest(); + break; } + await this.finishViewCommit(); break; + } case 'editCell': await this.updateDocument(e.row, e.col, e.value); break; @@ -215,7 +226,11 @@ class CsvEditorController { await this.findMatches(e.requestId, e.query, e.options); break; case 'save': - await this.handleSave(); + if (this.viewCommitRequested) { + this.saveAfterCommit(); + } else { + await this.handleSave(); + } break; case 'copyToClipboard': await vscode.env.clipboard.writeText(e.text); @@ -291,74 +306,6 @@ 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) { @@ -392,6 +339,10 @@ class CsvEditorController { if (!this.defaultEditorHandoff) { this.defaultEditorHandoff = (async () => { try { + if (uri.scheme === 'untitled') { + await vscode.window.showTextDocument(this.document, { viewColumn: webviewPanel.viewColumn, preserveFocus: !webviewPanel.active, preview: false }); + return; + } // Search can already have revealed the text editor. Opening it again // loses its destination, so leave an existing editor in this group alone. const existing = vscode.window.visibleTextEditors.find(editor => @@ -417,6 +368,9 @@ class CsvEditorController { webviewPanel: vscode.WebviewPanel, token: vscode.CancellationToken ): Promise { + if (this.document.uri.scheme === 'untitled') { + return true; + } const maxFileSizeMB = this.getMaxFileSizeLimitMb(config); if (maxFileSizeMB <= 0) { return true; @@ -468,41 +422,166 @@ class CsvEditorController { if (this.isDisposed) { return; } const enabled = vscode.workspace.getConfiguration('csv', this.document.uri).get('enabled', true); if (this.failedCellEdits.size) { + this.refreshAfterDraftCommit = true; 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.revealSourceAfterCommit(); + } else { + if (this.viewCommitRequested || this.failedCellEdits.size) { + this.refreshAfterDraftCommit = true; + return; + } + if (this.currentWebviewPanel && !this.viewCommitRequested && !this.failedCellEdits.size) { + this.forceReload(); + } + } + } + + public getViewColumn(): vscode.ViewColumn | undefined { + return this.currentWebviewPanel?.viewColumn; + } + + public commitPendingDraft(): Promise { + const panel = this.currentWebviewPanel; + if (!panel) { + return Promise.resolve(false); + } + return new Promise(resolve => { + const waiter = { + resolve, + timer: setTimeout(() => { + this.draftCommitWaiters.delete(waiter); + resolve(false); + }, CsvEditorController.DRAFT_COMMIT_TIMEOUT_MS) + }; + this.draftCommitWaiters.add(waiter); + const alreadyRequested = this.viewCommitRequested; + this.requestViewCommit(); + if (alreadyRequested && panel.visible) { + this.postViewCommitRequest(); + } + }); + } + + private resolveDraftCommitWaiters(success: boolean): void { + for (const waiter of this.draftCommitWaiters) { + clearTimeout(waiter.timer); + waiter.resolve(success); + } + this.draftCommitWaiters.clear(); + } + + public revealSourceAfterCommit(reason: 'disabled' | 'language' = 'disabled'): void { + const panel = this.currentWebviewPanel; + if (!panel) { + return; + } + // A deliberate close takes priority over a pending Save As or re-render. + this.pendingClose = true; + this.pendingViewAction = async () => { + const enabled = vscode.workspace.getConfiguration('csv', this.document.uri).get('enabled', true); + const supportedLanguage = this.document.languageId === 'csv' || this.document.languageId === 'tsv'; + if (enabled && (reason === 'disabled' || supportedLanguage)) { + // A quick re-enable/language roundtrip still commits the active draft. 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; }); + }; + this.requestViewCommit(); + } + + public saveUntitledAfterCommit(): void { + if (this.document.uri.scheme === 'untitled') { + this.saveAfterCommit(); + } + } + + private saveAfterCommit(): void { + if (!this.currentWebviewPanel || this.pendingClose) { + return; + } + this.pendingViewAction = () => this.handleSave(); + this.requestViewCommit(); + } + + private postViewCommitRequest(): void { + const panel = this.currentWebviewPanel; + if (!panel) { + this.resolveDraftCommitWaiters(false); + return; + } + try { + void Promise.resolve(panel.webview.postMessage({ + type: 'prepareViewRefresh', requestId: this.viewCommitSequence + })).then(delivered => { + if (!delivered) { this.resolveDraftCommitWaiters(false); } + }, () => this.resolveDraftCommitWaiters(false)); + } catch { + this.resolveDraftCommitWaiters(false); + } + } + + private requestViewCommit(): void { + const panel = this.currentWebviewPanel; + if (!panel || this.viewCommitRequested) { + return; + } + this.viewCommitRequested = true; + this.viewCommitNeedsFreshAck = false; + this.viewCommitSequence += 1; + if (panel.visible) { + // The final edit arrives before the acknowledgment in the ordered queue. + // webviewReady resends this request if the first script is still loading. + this.postViewCommitRequest(); + } else { + void this.enqueueOperation(() => this.finishViewCommit()); + } + } + + private requestRecoveryRefresh(): void { + this.refreshAfterDraftCommit = true; + if (this.viewCommitRequested) { + // A recovery cannot reuse the snapshot taken before its queued edit. + this.viewCommitNeedsFreshAck = true; + return; + } + // The acknowledgment itself runs on the ordered queue, so never await it here. + this.requestViewCommit(); + } + + private async finishViewCommit(): Promise { + this.viewCommitRequested = false; + if (this.failedCellEdits.size) { + // Keep the live DOM and any pending action until its failed cell can be retried. + this.resolveDraftCommitWaiters(false); + return; + } + const hadWaiters = this.draftCommitWaiters.size > 0; + if (this.pendingDocumentReference) { + this.document = this.pendingDocumentReference.document; + this.pendingDocumentReference = undefined; + this.separatorCache = undefined; + } + const action = this.pendingViewAction; + this.pendingViewAction = undefined; + this.pendingClose = false; + const refreshNeeded = this.refreshAfterDraftCommit; + this.refreshAfterDraftCommit = false; + if (action) { + await action(); + } else if (!hadWaiters || refreshNeeded) { + this.updateWebviewContent(); + } + this.resolveDraftCommitWaiters(!!this.currentWebviewPanel && !this.failedCellEdits.size); } private forceReload() { - if (!this.currentWebviewPanel) return; + if (!this.currentWebviewPanel || this.failedCellEdits.size) return; const panel = this.currentWebviewPanel; // First, blank the DOM to ensure a full script/style reinit on next set panel.webview.html = ''; @@ -583,6 +662,17 @@ class CsvEditorController { ); } + public updateDocumentReference(document: vscode.TextDocument): void { + if (document.uri.toString() === this.document.uri.toString()) { + this.pendingDocumentReference = { + document, + separator: this.pendingDocumentReference?.separator ?? this.renderedSeparator ?? this.getSeparator() + }; + this.refreshAfterDraftCommit = true; + this.requestViewCommit(); + } + } + public getDocumentUri(): vscode.Uri { return this.document.uri; } @@ -596,7 +686,7 @@ class CsvEditorController { 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 + type: 'editCellFailed', row, col, retry: !!this.viewCommitRequested }); } @@ -620,18 +710,22 @@ class CsvEditorController { return; } // Escape discards the failed DOM draft, never overwrites the current owner. - const data = Papa.parse(this.document.getText(), { delimiter: this.getSeparator() }).data; + const document = this.pendingDocumentReference?.document ?? this.document; + const data = Papa.parse(document.getText(), { delimiter: this.getSeparator() }).data; this.clearFailedCellEdit(row, col, String(data[row]?.[col] ?? '')); } private async updateDocument(row: number, col: number, value: string) { + // The old document may be closed during a language change. Apply its final + // draft to the live shared URI using the delimiter of the still-visible grid. + const document = this.pendingDocumentReference?.document ?? this.document; this.isUpdatingDocument = true; let structuralChange = false; let applied = false; try { const separator = this.getSeparator(); - const oldText = this.document.getText(); + const oldText = document.getText(); const result = Papa.parse(oldText, { dynamicTyping: false, delimiter: separator }); const data = result.data as string[][]; const hadRows = data.length; @@ -668,11 +762,11 @@ class CsvEditorController { const fullRange = new vscode.Range( 0, 0, - this.document.lineCount, - this.document.lineCount ? this.document.lineAt(this.document.lineCount - 1).text.length : 0 + document.lineCount, + document.lineCount ? document.lineAt(document.lineCount - 1).text.length : 0 ); const edit = new vscode.WorkspaceEdit(); - edit.replace(this.document.uri, fullRange, newCsvText); + edit.replace(document.uri, fullRange, newCsvText); applied = await vscode.workspace.applyEdit(edit); } catch (error) { this.reportFailedCellEdit(row, col); @@ -688,7 +782,7 @@ class CsvEditorController { 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); + const config = vscode.workspace.getConfiguration('csv', document.uri); const clickableLinks = config.get('clickableLinks', true); const rendered = this.formatCellContent(value ?? '', clickableLinks); this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value, rendered }); @@ -1187,6 +1281,13 @@ class CsvEditorController { private async handleSave() { this.isSaving = true; try { + if (this.document.uri.scheme === 'untitled') { + // Native Save owns the actual Save As transition. The temporary grid + // closes when VS Code closes the original untitled document. + await vscode.window.showTextDocument(this.document, { viewColumn: this.currentWebviewPanel?.viewColumn, preview: false }); + await vscode.commands.executeCommand('workbench.action.files.save'); + return; + } const success = await this.document.save(); console.log(success ? 'CSV: Document saved' : 'CSV: Failed to save document'); } catch (error) { @@ -1596,8 +1697,8 @@ class CsvEditorController { // ───────────── Webview Rendering ───────────── private updateWebviewContent() { - if (!this.currentWebviewPanel || this.failedCellEdits.size) return; - if (this.pendingDraftCommit) { + if (!this.currentWebviewPanel || this.failedCellEdits.size || this.pendingDocumentReference) return; + if (this.viewCommitRequested) { this.refreshAfterDraftCommit = true; return; } @@ -2210,6 +2311,9 @@ class CsvEditorController { } private getSeparator(): string { + if (this.pendingDocumentReference) { + return this.pendingDocumentReference.separator; + } if ((this.failedCellEdits.size || this.refreshAfterDraftCommit) && this.renderedSeparator !== undefined) { return this.renderedSeparator; } @@ -2227,7 +2331,9 @@ class CsvEditorController { return this.separatorCache.separator; } - const filePath = this.document?.uri.fsPath || this.document?.uri.path || ''; + const filePath = this.document.uri.scheme === 'untitled' + ? this.document.languageId === 'tsv' ? 'untitled.tsv' : 'untitled.csv' + : this.document?.uri.fsPath || this.document?.uri.path || ''; const text = this.document.getText(); const separator = CsvEditorProvider.resolveInheritedSeparator(filePath, text, settings); this.separatorCache = { version, configKey, separator }; diff --git a/src/commands.ts b/src/commands.ts index c7fc664..f000621 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,6 +2,7 @@ import { getFonts } from 'font-list'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; import { toggleCsvEditor } from './editor-switch'; +import { saveActiveUntitledCsvEditor } from './untitled-editors'; import type { EditorSwitchRouting } from './editor-associations'; function displaySeparator(separator: string): string { @@ -49,6 +50,7 @@ export function registerCsvCommands(context: vscode.ExtensionContext, synchroniz let pendingToggle: Promise = Promise.resolve(); context.subscriptions.push( vscode.commands.registerCommand('csv.toggleEditor', () => toggleCsvEditor(prepareEditorRouting)), + vscode.commands.registerCommand('csv.saveUntitled', saveActiveUntitledCsvEditor), vscode.commands.registerCommand('csv.toggleExtension', () => { const next = pendingToggle.catch(() => {}).then(() => toggleCsvEnabled(synchronizeRouting)); pendingToggle = next; @@ -161,6 +163,11 @@ export function registerCsvCommands(context: vscode.ExtensionContext, synchroniz const active = CsvEditorProvider.getActiveProvider(); if (!active) { vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); return; } const uri = active.getDocumentUri(); + if (uri.scheme === 'untitled') { + vscode.window.showInformationMessage('Save this document before changing its file encoding.'); + return; + } + // Close any existing tabs for this URI so we can reuse the slot cleanly try { diff --git a/src/editor-switch.ts b/src/editor-switch.ts index 9597473..a56a6e9 100644 --- a/src/editor-switch.ts +++ b/src/editor-switch.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; +import { getActiveUntitledCsvEditor, isUntitledCsv, openUntitledCsvEditor, showActiveUntitledCsvSource } from './untitled-editors'; import type { EditorSwitchRouting } from './editor-associations'; const switchingTabs = new Set(); @@ -53,7 +54,21 @@ function observeEditorReplacement(uri: vscode.Uri, source: string, target: strin } export async function toggleCsvEditor(prepareRouting?: (uri: vscode.Uri, target: 'default' | 'csv.editor') => Promise): Promise { + const untitled = getActiveUntitledCsvEditor(); + if (untitled) { + await showActiveUntitledCsvSource(); + return; + } const group = vscode.window.tabGroups.activeTabGroup; + const activeInput = group.activeTab?.input; + const activeDocument = activeInput instanceof vscode.TabInputText + ? vscode.window.visibleTextEditors.find(editor => editor.viewColumn === group.viewColumn && + editor.document.uri.toString() === activeInput.uri.toString())?.document + : undefined; + if (activeDocument && isUntitledCsv(activeDocument)) { + await openUntitledCsvEditor(activeDocument, group.viewColumn); + return; + } const tab = group.activeTab; if (!tab) { return; diff --git a/src/extension.ts b/src/extension.ts index d64ba07..524b881 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; import { registerCsvCommands } from './commands'; +import { registerUntitledCsvEditors } from './untitled-editors'; import { CsvEditorAssociationRouting } from './editor-associations'; export async function activate(context: vscode.ExtensionContext) { @@ -123,6 +124,7 @@ export async function activate(context: vscode.ExtensionContext) { { supportsMultipleEditorsPerDocument: false } ) ); + registerUntitledCsvEditors(context); } export function deactivate() { diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 023b85f..b1e7e2a 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -22,17 +22,24 @@ const vscodeStub = { ColorThemeKind: { Dark: 1 }, window: { activeColorTheme: { kind: 1 }, + showTextDocument: async (document: any) => { + activeHarness.sourceReveals.push(document.getText()); + return { document }; + }, visibleTextEditors: [], showErrorMessage(message: string) { activeHarness.errors.push(message); } }, commands: { - executeCommand(command: string, ...args: unknown[]) { - activeHarness.commands.push([command, ...args]); - return Promise.resolve(); + executeCommand: async (command: string, ...args: unknown[]) => { + activeHarness.recordedCommands.push([command, ...args]); + if (command === 'workbench.action.files.save') { + activeHarness.nativeSaveCommands.push(command); + return activeHarness.document.save(); + } } }, workspace: { - getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : key === 'enabled' ? (activeHarness?.enabled ?? true) : 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); @@ -60,6 +67,11 @@ export async function createEditorHarness(options: { separator?: string; header?: boolean; remote?: boolean; + untitled?: boolean; + fileName?: string; + separatorOverride?: string; + cancelInitialization?: boolean; + closeDuringInitialization?: boolean; } = {}) { const separator = options.separator ?? ','; let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); @@ -67,15 +79,18 @@ 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 disposeListeners: Array<() => void> = []; + let controller: any; 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 uri = new TestUri(options.fileName ?? `/test/data.${separator === '\t' ? 'tsv' : 'csv'}`, options.untitled ? 'untitled' : options.remote ? 'vscode-remote' : 'file'); const document = { uri, + isClosed: false, + languageId: separator === '\t' ? 'tsv' : 'csv', version: 1, getText: () => text, get lineCount() { return text.split(/\r\n|\n|\r/).length; }, @@ -98,12 +113,14 @@ export async function createEditorHarness(options: { } }; await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); + if (options.separatorOverride) { + await CsvEditorProvider.setSeparatorForUri(context as any, uri as any, options.separatorOverride); + } const panel = { - disposed: false, active: true, + viewColumn: 2, visible: true, title: 'data.csv', - viewColumn: 2, webview: { html: '', options: {}, @@ -113,23 +130,48 @@ export async function createEditorHarness(options: { onDidReceiveMessage: (listener: typeof messageListener) => { messageListener = listener; } }, onDidChangeViewState: (listener: (event: unknown) => void) => { viewListeners.push(listener); }, + disposed: false, onDidDispose(listener: () => void) { disposeListeners.push(listener); }, - dispose() { if (!panel.disposed) { panel.disposed = true; disposeListeners.forEach(listener => listener()); } } + dispose() { + if (panel.disposed) { return; } + panel.disposed = true; + panel.active = false; + panel.visible = false; + 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; }, + errors: [] as string[], + recordedCommands: [] as unknown[][], 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(); }, + sourceReveals: [] as string[], + nativeSaveCommands: [] as string[], + get controllerCount() { return CsvEditorProvider.editors.length; }, + get activeController() { return CsvEditorProvider.currentActive; }, + saveUntitledAfterCommit() { controller.saveUntitledAfterCommit(); }, + requestViewCommit() { controller.requestViewCommit(); }, + commitPendingDraft() { return controller.commitPendingDraft() as Promise; }, + revealSourceAfterCommit() { harness.enabled = false; controller.revealSourceAfterCommit(); }, + setEnabled(enabled: boolean) { harness.enabled = enabled; controller.refresh(); }, + getCurrentSeparator() { return controller.getCurrentSeparator() as string; }, + rebindDocument(languageId: string) { + const replacement = { + ...document, languageId, + get version() { return document.version; }, + getText: () => text, + get lineCount() { return text.split(/\r\n|\n|\r/).length; } + }; + controller.updateDocumentReference(replacement); + return replacement; + }, 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; }, + get pendingDraftRequestId(): number | undefined { return controller?.viewCommitRequested ? controller.viewCommitSequence : undefined; }, deferEdits() { deferEdits = true; }, resumeEdits() { deferEdits = false; }, failEdits(mode: 'false' | 'throw' | undefined) { editFailure = mode; }, @@ -161,7 +203,15 @@ export async function createEditorHarness(options: { rows() { return Papa.parse(text, { delimiter: separator }).data; } }; activeHarness = harness; - await new CsvEditorProvider(context as any).resolveCustomTextEditor(document as any, panel as any, { isCancellationRequested: false } as any); + const token = { isCancellationRequested: false }; + const initializing = new CsvEditorProvider(context as any).resolveCustomTextEditor(document as any, panel as any, token as any); + if (options.cancelInitialization || options.closeDuringInitialization) { + token.isCancellationRequested = !!options.cancelInitialization; + document.isClosed = !!options.closeDuringInitialization; + panel.dispose(); + } + await initializing; + controller = CsvEditorProvider.currentActive; return harness; } diff --git a/src/test/editor-routing-commands.test.ts b/src/test/editor-routing-commands.test.ts index 2a639f9..fb5986c 100644 --- a/src/test/editor-routing-commands.test.ts +++ b/src/test/editor-routing-commands.test.ts @@ -26,10 +26,12 @@ function fixture(initial: { enabled?: boolean; workspaceEnabled?: boolean; assoc const api: any = { Uri, ConfigurationTarget: { Global: 1, Workspace: 2 }, workspace: { - workspaceFolders: [{}], workspaceFile: undefined, + workspaceFolders: [{}], workspaceFile: undefined, textDocuments: [], + onDidOpenTextDocument: () => ({ dispose() {} }), + onDidCloseTextDocument: () => ({ dispose() {} }), getConfiguration: (section: string) => ({ - get: (_key: string, fallback: unknown) => section === 'csv' ? (data.workspaceEnabled ?? data.enabled ?? fallback) : { ...data.associations, ...data.workspaceAssociations }, - inspect: () => section === 'csv' ? { defaultValue: true, globalValue: data.enabled, workspaceValue: data.workspaceEnabled } : { globalValue: clone(data.associations), workspaceValue: clone(data.workspaceAssociations) }, + get: (key: string, fallback: unknown) => section === 'csv' && key === 'enabled' ? (data.workspaceEnabled ?? data.enabled ?? fallback) : section === 'workbench' && key === 'editorAssociations' ? { ...data.associations, ...data.workspaceAssociations } : fallback, + inspect: (key: string) => section === 'csv' && key === 'enabled' ? { defaultValue: true, globalValue: data.enabled, workspaceValue: data.workspaceEnabled } : section === 'workbench' && key === 'editorAssociations' ? { globalValue: clone(data.associations), workspaceValue: clone(data.workspaceAssociations) } : undefined, update: async (key: string, value: any, target: number) => { data.updates.push({ section, key, value: clone(value), target }); if (section === 'csv') { @@ -44,6 +46,7 @@ function fixture(initial: { enabled?: boolean; workspaceEnabled?: boolean; assoc onDidChangeConfiguration: (listener: (e: any) => Promise) => { listeners.push(listener); return { dispose() {} }; } }, window: { + onDidChangeActiveTextEditor: () => ({ dispose() {} }), tabGroups: { all: [group], close: async (tab: any) => { data.closed.push(tab); group.tabs = group.tabs.filter((candidate: any) => candidate !== tab); return true; } }, showErrorMessage: (message: string) => { data.errors.push(message); }, showInformationMessage: (message: string) => { data.infos.push(message); }, @@ -75,6 +78,7 @@ function fixture(initial: { enabled?: boolean; workspaceEnabled?: boolean; assoc try { delete require.cache[require.resolve('../extension')]; delete require.cache[require.resolve('../commands')]; + delete require.cache[require.resolve('../untitled-editors')]; activate = require('../extension').activate; } finally { Module.prototype.require = original; } const context = { subscriptions: [], globalState: store(), workspaceState: store() }; diff --git a/src/test/failed-draft-recovery.test.ts b/src/test/failed-draft-recovery.test.ts index b619382..5e2940c 100644 --- a/src/test/failed-draft-recovery.test.ts +++ b/src/test/failed-draft-recovery.test.ts @@ -34,7 +34,7 @@ describe('unapplied ordinary cell drafts keep source row identities intact', () 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.deepStrictEqual(host.recordedCommands, [], 'disable must not open a different editor before recovery'); assert.match(host.errors.at(-1)!, /Retry.*Escape.*before disabling/); host.failEdits(undefined); @@ -46,11 +46,10 @@ describe('unapplied ordinary cell drafts keep source row identities intact', () 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)); + host.refresh(); 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 }]]); + assert.strictEqual(host.isOpen, false, 'normal disable proceeds after the failed draft is resolved'); + assert.deepStrictEqual(host.recordedCommands, [['vscode.openWith', host.document.uri, 'default', { viewColumn: host.panel.viewColumn, preserveFocus: false }]]); } finally { view.close(); if (host.isOpen) { host.panel.dispose(); } diff --git a/src/test/saved-disable-draft.test.ts b/src/test/saved-disable-draft.test.ts index de626d6..81d7d3e 100644 --- a/src/test/saved-disable-draft.test.ts +++ b/src/test/saved-disable-draft.test.ts @@ -16,14 +16,14 @@ describe('Saved CSV disable uses the existing draft commit before native handoff 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, []); + assert.strictEqual(host.panel.disposed, false); assert.deepStrictEqual(host.recordedCommands, []); const pending = prepare(host, view).map(message => host.receive(message)); - await turn(); assert.strictEqual(host.pendingEdits, 1); assert.deepStrictEqual(host.commands, []); + await turn(); assert.strictEqual(host.pendingEdits, 1); assert.deepStrictEqual(host.recordedCommands, []); 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 }]]); + assert.deepStrictEqual(host.recordedCommands, [['vscode.openWith', host.document.uri, 'default', { viewColumn: 2, preserveFocus: false }]]); } finally { view.close(); host.dispose(); } }); } @@ -35,7 +35,7 @@ describe('Saved CSV disable uses the existing draft commit before native handoff 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')); + assert.deepStrictEqual(host.recordedCommands, []); 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 })); @@ -45,7 +45,7 @@ describe('Saved CSV disable uses the existing draft commit before native handoff // 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.strictEqual(host.panel.disposed, true); assert.strictEqual(host.recordedCommands.length, 1); assert.ok(!host.diskText.includes('failed draft')); } finally { view.close(); host.dispose(); } }); @@ -61,7 +61,7 @@ describe('Saved CSV disable uses the existing draft commit before native handoff 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, []); + assert.deepStrictEqual(host.recordedCommands, []); } finally { view.close(); host.dispose(); } }); }); diff --git a/src/test/test-memento.ts b/src/test/test-memento.ts new file mode 100644 index 0000000..530c271 --- /dev/null +++ b/src/test/test-memento.ts @@ -0,0 +1,15 @@ +// VS Code Mementos serialize writes and return the supplied default for absent keys. +export function createTestMemento() { + const values = new Map(); + const clone = (value: T): T => value === undefined ? value : JSON.parse(JSON.stringify(value)); + return { + keys: () => [...values.keys()], + get(key: string, fallback?: T): T | undefined { + return values.has(key) ? clone(values.get(key) as T) : fallback; + }, + async update(key: string, value: unknown): Promise { + if (value === undefined) { values.delete(key); } + else { values.set(key, clone(value)); } + } + }; +} diff --git a/src/test/untitled-draft-close.test.ts b/src/test/untitled-draft-close.test.ts new file mode 100644 index 0000000..a291c51 --- /dev/null +++ b/src/test/untitled-draft-close.test.ts @@ -0,0 +1,37 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +describe('untitled grid closes after its final active draft commits', () => { + for (const separator of [',', '\t']) { + it(`preserves the active multiline draft when disabled (${JSON.stringify(separator)})`, async () => { + const host = await createEditorHarness({ separator, untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('latest draft'); + host.deferEdits(); + host.revealSourceAfterCommit(); + const request = host.messages.find(message => message.type === 'prepareViewRefresh'); + assert.ok(request); + assert.strictEqual(host.panel.disposed, false); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + const operations = view.messages.filter(message => message.type === 'editCell' || message.type === 'viewRefreshReady'); + assert.deepStrictEqual(operations.map(message => message.type), ['editCell', 'viewRefreshReady']); + const pending = operations.map(message => host.receive(message)); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + assert.strictEqual(host.panel.disposed, false); + assert.strictEqual(host.sourceReveals.length, 0); + host.releaseEdit(); + await Promise.all(pending); + assert.strictEqual(host.rows()[1][0], 'one\nlatest draft'); + assert.strictEqual(host.sourceReveals[0], host.document.getText()); + assert.strictEqual(host.panel.disposed, true); + } finally { + view.close(); + } + }); + } +}); diff --git a/src/test/untitled-draft-lifecycle.test.ts b/src/test/untitled-draft-lifecycle.test.ts new file mode 100644 index 0000000..6ea0d47 --- /dev/null +++ b/src/test/untitled-draft-lifecycle.test.ts @@ -0,0 +1,190 @@ +import assert from 'assert'; +import Papa from 'papaparse'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +const nextTurn = () => new Promise(resolve => setImmediate(resolve)); +function sendPrepare(host: Awaited>, view: ReturnType) { + const request = host.messages.filter(message => message.type === 'prepareViewRefresh').at(-1); + assert.ok(request); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + return view.messages.filter(message => message.type === 'editCell' || message.type === 'viewRefreshReady') + .map(message => host.receive(message)); +} +function startMultiline(view: ReturnType) { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('latest draft'); +} + +describe('untitled controller and webview draft lifecycle', () => { + for (const separator of [',', '\t']) { + const nextLanguage = separator === ',' ? 'tsv' : 'csv'; + const nextSeparator = separator === ',' ? '\t' : ','; + it(`commits a live multiline draft using the original grammar before ${nextLanguage} rebind`, async () => { + const host = await createEditorHarness({ separator, untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + startMultiline(view); + const html = host.panel.webview.html; + host.deferEdits(); + const replacement = host.rebindDocument(nextLanguage); + host.document.isClosed = true; + host.document.getText = () => { throw new Error('old TextDocument is closed'); }; + assert.strictEqual(host.panel.webview.html, html, 'no reload may destroy the active DOM draft'); + assert.strictEqual(host.getCurrentSeparator(), separator); + const operations = sendPrepare(host, view); + await nextTurn(); + assert.strictEqual(host.pendingEdits, 1); + assert.strictEqual(host.panel.webview.html, html); + host.releaseEdit(); + await Promise.all(operations); + assert.strictEqual(Papa.parse(replacement.getText(), { delimiter: separator }).data[1][0], 'one\nlatest draft'); + assert.strictEqual(host.getCurrentSeparator(), nextSeparator, 'language owns logical extension in both directions'); + assert.strictEqual(host.panel.disposed, false); + } finally { view.close(); } + }); + + it(`keeps the grid after rapid disable/re-enable while ${nextLanguage} rebind is pending`, async () => { + const host = await createEditorHarness({ separator, untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + startMultiline(view); + host.rebindDocument(nextLanguage); + host.revealSourceAfterCommit(); + host.setEnabled(true); + await Promise.all(sendPrepare(host, view)); + assert.strictEqual(host.rows()[1][0], 'one\nlatest draft'); + assert.strictEqual(host.getCurrentSeparator(), nextSeparator); + assert.strictEqual(host.panel.disposed, false); + assert.strictEqual(host.sourceReveals.length, 0); + } finally { view.close(); } + }); + + it(`keeps deliberate disable priority over a pending language rebind (${JSON.stringify(separator)})`, async () => { + const host = await createEditorHarness({ separator, untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + startMultiline(view); + host.revealSourceAfterCommit(); + const replacement = host.rebindDocument(nextLanguage); + host.saveUntitledAfterCommit(); + await Promise.all(sendPrepare(host, view)); + assert.strictEqual(host.rows()[1][0], 'one\nlatest draft'); + assert.strictEqual(host.sourceReveals[0], replacement.getText()); + assert.strictEqual(host.nativeSaveCommands.length, 0); + assert.strictEqual(host.panel.disposed, true); + } finally { view.close(); } + }); + } + + it('retains explicit separator overrides across associated-path language changes', async () => { + const host = await createEditorHarness({ separator: '\t', untitled: true, fileName: '/data.tsv', separatorOverride: '|' }); + const view = createWebviewDom(host.panel.webview.html); + try { + host.rebindDocument('csv'); + await Promise.all(sendPrepare(host, view)); + assert.strictEqual(host.getCurrentSeparator(), '|'); + } finally { view.close(); } + }); + + it('cancels stale disable teardown when re-enabled before acknowledgment', async () => { + const host = await createEditorHarness({ untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + startMultiline(view); + host.revealSourceAfterCommit(); + host.setEnabled(true); + await Promise.all(sendPrepare(host, view)); + assert.strictEqual(host.rows()[1][0], 'one\nlatest draft'); + assert.strictEqual(host.panel.disposed, false); + assert.strictEqual(host.sourceReveals.length, 0); + } finally { view.close(); } + }); + + it('ignores stale acknowledgments and resends a pending commit when a webview finishes loading', async () => { + const host = await createEditorHarness({ untitled: true }); + const original = host.panel.webview.html; + host.rebindDocument('tsv'); + 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(host.panel.webview.html, original); + assert.strictEqual(host.getCurrentSeparator(), ','); + await host.receive({ type: 'viewRefreshReady', requestId: request.requestId }); + assert.strictEqual(host.getCurrentSeparator(), '\t'); + const current = host.panel.webview.html; + await host.receive({ type: 'viewRefreshReady', requestId: request.requestId }); + assert.strictEqual(host.panel.webview.html, current); + }); + + it('does not register a controller or touch the webview after immediate panel cancellation', async () => { + const host = await createEditorHarness({ untitled: true, cancelInitialization: true }); + assert.strictEqual(host.panel.disposed, true); + assert.strictEqual(host.panel.webview.html, ''); + assert.strictEqual(host.messages.length, 0); + assert.ok(!host.activeController || host.activeController.getDocumentUri() !== host.document.uri); + }); + + it('does not register a controller after the owner closes during initialization', async () => { + const host = await createEditorHarness({ untitled: true, closeDuringInitialization: true }); + assert.strictEqual(host.panel.webview.html, ''); + assert.strictEqual(host.messages.length, 0); + assert.ok(!host.activeController || host.activeController.getDocumentUri() !== host.document.uri); + }); + + it('waits for the active draft before revealing the native owner for Save As', async () => { + const host = await createEditorHarness({ untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + startMultiline(view); + host.deferEdits(); + host.saveUntitledAfterCommit(); + const pending = sendPrepare(host, view); + await nextTurn(); + assert.strictEqual(host.pendingEdits, 1); + assert.strictEqual(host.sourceReveals.length, 0); + assert.strictEqual(host.nativeSaveCommands.length, 0); + host.releaseEdit(); + await Promise.all(pending); + assert.strictEqual(host.rows()[1][0], 'one\nlatest draft'); + assert.strictEqual(host.diskText, host.document.getText()); + assert.deepStrictEqual(host.nativeSaveCommands, ['workbench.action.files.save']); + } finally { view.close(); } + }); + + it('preserves a saved-document save received while a view refresh is awaiting its draft', async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + startMultiline(view); + // Exercise the shared handshake without importing the separate filter PR. + host.requestViewCommit(); + await host.receive({ type: 'save' }); + await Promise.all(sendPrepare(host, view)); + assert.strictEqual(host.rows()[1][0], 'one\nlatest draft'); + assert.strictEqual(host.diskText, host.document.getText()); + assert.strictEqual(host.sourceReveals.length, 0); + } finally { view.close(); } + }); + + for (const modifier of ['ctrlKey', 'metaKey']) { + for (const editing of [false, true]) { + it(`routes ${modifier}+S from ${editing ? 'editing' : 'selection'} mode to the native owner`, async () => { + const host = await createEditorHarness({ untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + if (editing) { startMultiline(view); } + view.key('s', { [modifier]: true }); + const operations = view.messages.filter(message => message.type === 'editCell' || message.type === 'save'); + assert.deepStrictEqual(operations.map(message => message.type), editing ? ['editCell', 'save'] : ['save']); + await Promise.all(operations.map(message => host.receive(message))); + assert.strictEqual(host.nativeSaveCommands.length, 1); + assert.strictEqual(host.diskText, host.document.getText()); + if (editing) { assert.strictEqual(host.rows()[1][0], 'one\nlatest draft'); } + } finally { view.close(); } + }); + } + } +}); diff --git a/src/test/untitled-editors.test.ts b/src/test/untitled-editors.test.ts new file mode 100644 index 0000000..5a4ae3c --- /dev/null +++ b/src/test/untitled-editors.test.ts @@ -0,0 +1,377 @@ +import assert from 'assert'; +import Module from 'module'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +function event() { + const listeners = new Set<(value: T) => void>(); + return { + listen: (callback: (value: T) => void) => { + listeners.add(callback); + return { dispose: () => listeners.delete(callback) }; + }, + fire: (value: T) => { listeners.forEach(callback => callback(value)); }, + clear: () => listeners.clear(), + get size() { return listeners.size; } + }; +} +const activeEvent = event(); +const openEvent = event(); +const closeEvent = event(); +const configEvent = event(); +const state = { + enabled: true, + activeTextEditor: undefined as any, + documents: [] as any[], + panels: [] as any[], + resolutions: [] as any[], + subscriptions: [] as any[], + tokens: [] as any[], + failResolve: false, + commitResult: true, + commitWait: undefined as Promise | undefined, + commitRequests: 0, + saveRequests: 0, + commands: new Map any>(), + information: [] as string[] +}; +class TabInputText { constructor(public uri: any) {} } +class FakeProvider { + static viewType = 'csv.editor'; + static editors: any[] = []; + static currentActive: any; + static getActiveProvider() { return FakeProvider.currentActive; } + async resolveCustomTextEditor(document: any, panel: any, token: any) { + state.resolutions.push({ document, panel, token }); + if (state.failResolve) { throw new Error('resolve failed'); } + const controller = { + document, + getDocumentUri: () => controller.document.uri, + isActive: () => panel.active, + getViewColumn: () => panel.viewColumn, + commitPendingDraft: () => { state.commitRequests++; return state.commitWait ?? Promise.resolve(state.commitResult); }, + saveUntitledAfterCommit: () => { state.saveRequests += 1; }, + updateDocumentReference: (replacement: any) => { controller.document = replacement; }, + revealSourceAfterCommit: async () => { + await vscode.window.showTextDocument(controller.document, { viewColumn: panel.viewColumn }); + panel.dispose(); + } + }; + FakeProvider.editors.push(controller); + FakeProvider.currentActive = controller; + panel.onDidDispose(() => { + FakeProvider.editors = FakeProvider.editors.filter(candidate => candidate !== controller); + if (FakeProvider.currentActive === controller) { FakeProvider.currentActive = undefined; } + }); + } +} +function createPanel(viewType: string, title: string, viewColumn: number) { + const disposeEvent = event(); + state.panels.forEach(panel => { panel.active = false; }); + state.activeTextEditor = undefined; + const panel = { + viewType, title, viewColumn, active: true, disposed: false, + onDidDispose: disposeEvent.listen, + reveal: (column?: number) => { + state.panels.forEach(other => { other.active = false; }); + panel.active = true; + panel.viewColumn = column ?? panel.viewColumn; + state.activeTextEditor = undefined; + }, + dispose: () => { + if (!panel.disposed) { + panel.disposed = true; + panel.active = false; + disposeEvent.fire(); + } + } + }; + state.panels.push(panel); + return panel; +} +const vscode = { + TabInputText, + ViewColumn: { Active: -1 }, + CancellationTokenSource: class { + token = { isCancellationRequested: false }; + disposed = false; + constructor() { state.tokens.push(this); } + cancel() { this.token.isCancellationRequested = true; } + dispose() { this.disposed = true; } + }, + window: { + tabGroups: { get activeTabGroup() { return { viewColumn: state.activeTextEditor?.viewColumn ?? 2, + activeTab: state.activeTextEditor ? { input: new TabInputText(state.activeTextEditor.document.uri) } : undefined }; } }, + get activeTextEditor() { return state.activeTextEditor; }, + get visibleTextEditors() { return state.activeTextEditor ? [state.activeTextEditor] : []; }, + onDidChangeActiveTextEditor: activeEvent.listen, + createWebviewPanel: createPanel, + showTextDocument: async (document: any, options: any) => { + state.panels.forEach(panel => { panel.active = false; }); + state.activeTextEditor = { document, viewColumn: options.viewColumn }; + activeEvent.fire(state.activeTextEditor); + return state.activeTextEditor; + }, + showErrorMessage() {}, + showInformationMessage(message: string) { state.information.push(message); } + }, + commands: { + registerCommand: (name: string, callback: (...args: any[]) => any) => { + state.commands.set(name, callback); + return { dispose() {} }; + } + }, + workspace: { + get textDocuments() { return state.documents; }, + getConfiguration: () => ({ get: () => state.enabled }), + onDidOpenTextDocument: openEvent.listen, + onDidCloseTextDocument: closeEvent.listen, + onDidChangeConfiguration: configEvent.listen + } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { return vscode; } + if (id === 'font-list') { return { getFonts: async () => [] }; } + if (id === './CsvEditorProvider') { return { CsvEditorProvider: FakeProvider }; } + return originalRequire.apply(this, arguments as any); +}; +import { getActiveUntitledCsvEditor, isUntitledCsv, openUntitledCsvEditor, registerUntitledCsvEditors } from '../untitled-editors'; +import { toggleCsvEditor } from '../editor-switch'; +import { registerCsvCommands } from '../commands'; +Module.prototype.require = originalRequire; + +function document(languageId = 'csv', scheme = 'untitled', name = 'Untitled-1'): any { + return { + uri: { scheme, path: name, toString: () => `${scheme}:${name}` }, + languageId, + fileName: name, + isClosed: false, + isDirty: true, + getText: () => 'name,value\nalready typed,42' + }; +} +function activate(document: any) { + state.documents = [document]; + state.activeTextEditor = { document, viewColumn: 2 }; + activeEvent.fire(state.activeTextEditor); +} +const settle = () => new Promise(resolve => setTimeout(resolve, 5)); + +beforeEach(() => { + state.enabled = true; + state.activeTextEditor = undefined; + state.documents = []; + state.panels = []; + state.resolutions = []; + state.subscriptions = []; + state.tokens = []; + state.failResolve = false; + state.commitResult = true; + state.commitWait = undefined; + state.commitRequests = 0; + state.saveRequests = 0; + state.commands.clear(); + state.information = []; + FakeProvider.editors = []; + FakeProvider.currentActive = undefined; + registerUntitledCsvEditors({ subscriptions: state.subscriptions } as any); +}); +afterEach(() => { state.subscriptions.forEach(subscription => subscription.dispose()); }); + +describe('untitled CSV document ownership and routing', () => { + for (const language of ['csv', 'tsv']) { + it(`opens a standalone ${language.toUpperCase()} table against the already typed original document`, async () => { + const original = document(language); + activate(original); + await settle(); + assert.strictEqual(state.panels.length, 1); + assert.strictEqual(state.panels[0].viewType, 'csv.untitledEditor'); + assert.strictEqual(state.panels[0].viewColumn, 2); + assert.strictEqual(state.resolutions[0].document, original); + assert.strictEqual(original.getText(), 'name,value\nalready typed,42'); + assert.strictEqual(original.isDirty, true); + assert.strictEqual(getActiveUntitledCsvEditor()?.document, original); + }); + } + + it('does not open tables for unshown buffers, saved files, or unrelated languages', async () => { + openEvent.fire(document()); + activate(document('plaintext')); + activate(document('csv', 'file')); + await settle(); + assert.strictEqual(state.panels.length, 0); + assert.strictEqual(isUntitledCsv(document('json')), false); + }); + + it('recognizes changing language on an existing active untitled buffer', async () => { + const original = document('plaintext'); + activate(original); + original.languageId = 'csv'; + openEvent.fire(original); + await settle(); + assert.strictEqual(state.resolutions[0].document, original); + }); + + it('commits before replacing the untitled table with its native owner and recreates on return', async () => { + const original = document(); + activate(original); + await settle(); + const panel = state.panels[0]; + let finish!: (success: boolean) => void; + state.commitWait = new Promise(resolve => { finish = resolve; }); + const switching = toggleCsvEditor(); + await settle(); + assert.strictEqual(panel.disposed, false); + assert.strictEqual(panel.active, true); + assert.strictEqual(state.activeTextEditor, undefined); + assert.strictEqual(state.commitRequests, 1); + finish(true); + await switching; + assert.strictEqual((state.activeTextEditor as any).document, original); + await settle(); + assert.strictEqual(panel.disposed, true); + assert.strictEqual(original.isClosed, false); + assert.strictEqual(original.isDirty, true); + assert.strictEqual(state.saveRequests, 0); + await toggleCsvEditor(); + assert.strictEqual(state.panels[1].active, true); + assert.strictEqual(state.panels.length, 2); + assert.strictEqual(state.resolutions.length, 2); + assert.strictEqual(state.resolutions[1].document, original); + }); + + it('keeps the standalone panel and native owner intact when the draft cannot commit', async () => { + const original = document(); + activate(original); + await settle(); + state.commitResult = false; + await toggleCsvEditor(); + assert.strictEqual(state.panels[0].disposed, false); + assert.strictEqual(state.panels[0].active, true); + assert.strictEqual(state.activeTextEditor, undefined); + assert.strictEqual(original.isClosed, false); + assert.strictEqual(original.getText(), 'name,value\nalready typed,42'); + assert.strictEqual(state.saveRequests, 0); + }); + + it('ignores a completed commit after its standalone panel is disposed', async () => { + const original = document(); + activate(original); + await settle(); + let finish!: (success: boolean) => void; + state.commitWait = new Promise(resolve => { finish = resolve; }); + const switching = toggleCsvEditor(); + state.panels[0].dispose(); + finish(true); + await switching; + assert.strictEqual(state.activeTextEditor, undefined); + assert.strictEqual(original.isClosed, false); + }); + + it('respects disabled state and opens the current buffer when explicitly re-enabled', async () => { + state.enabled = false; + activate(document()); + await settle(); + assert.strictEqual(state.panels.length, 0); + state.enabled = true; + configEvent.fire({ affectsConfiguration: () => true }); + await settle(); + assert.strictEqual(state.panels.length, 1); + }); + + it('returns to the unchanged native source when disabled from settings', async () => { + const original = document(); + activate(original); + await settle(); + state.enabled = false; + configEvent.fire({ affectsConfiguration: () => true }); + await settle(); + assert.strictEqual(state.panels[0].disposed, true); + assert.strictEqual(state.activeTextEditor.document, original); + assert.strictEqual(original.getText(), 'name,value\nalready typed,42'); + assert.strictEqual(FakeProvider.currentActive, undefined); + }); + + it('keeps an intentionally closed table closed until explicitly requested', async () => { + const original = document(); + activate(original); + await settle(); + state.panels[0].dispose(); + activate(original); + await settle(); + assert.strictEqual(state.panels.length, 1); + await openUntitledCsvEditor(original, 2); + assert.strictEqual(state.panels.length, 2); + assert.strictEqual(state.tokens[0].token.isCancellationRequested, true); + assert.strictEqual(state.tokens[0].disposed, true); + }); + + it('rebinds a language-change replacement at the same URI without losing the table', async () => { + const original = document(); + activate(original); + await settle(); + const replacement = document('tsv'); + original.isClosed = true; + state.documents = [replacement]; + closeEvent.fire(original); + openEvent.fire(replacement); + await settle(); + assert.strictEqual(state.panels[0].disposed, false); + assert.strictEqual(getActiveUntitledCsvEditor()?.document, replacement); + assert.strictEqual(FakeProvider.editors[0].document, replacement); + }); + + it('closes the temporary grid when Save As closes its original owner without guessing a saved URI', async () => { + const original = document(); + activate(original); + await settle(); + original.isClosed = true; + state.documents = [document('csv', 'file', '/saved.csv')]; + closeEvent.fire(original); + await settle(); + assert.strictEqual(state.panels[0].disposed, true); + assert.strictEqual(FakeProvider.currentActive, undefined); + assert.strictEqual(getActiveUntitledCsvEditor(), undefined); + }); + + it('routes the registered untitled Save As action to the active owner controller', async () => { + activate(document()); + await settle(); + registerCsvCommands({ subscriptions: [] } as any); + await state.commands.get('csv.saveUntitled')!(); + assert.strictEqual(state.saveRequests, 1); + await toggleCsvEditor(); + await state.commands.get('csv.saveUntitled')!(); + assert.strictEqual(state.saveRequests, 1, 'source/native save owns text editor focus'); + }); + + it('requires saving before encoding commands can close or reopen an untitled owner', async () => { + activate(document()); + await settle(); + registerCsvCommands({ subscriptions: [] } as any); + await state.commands.get('csv.changeEncoding')!(); + assert.strictEqual(state.panels[0].disposed, false); + assert.strictEqual(state.information.length, 1); + assert.match(state.information[0], /Save this document/); + }); + + it('cleans up a rejected initialization and allows a subsequent explicit retry', async () => { + state.failResolve = true; + const original = document(); + await assert.rejects(openUntitledCsvEditor(original, 2), /resolve failed/); + assert.strictEqual(state.panels[0].disposed, true); + assert.strictEqual(state.tokens[0].disposed, true); + state.failResolve = false; + await openUntitledCsvEditor(original, 2); + assert.strictEqual(state.panels[1].active, true); + }); + + it('disposes event subscriptions and panels with the extension context', async () => { + activate(document()); + await settle(); + state.subscriptions.forEach(subscription => subscription.dispose()); + assert.strictEqual(state.panels[0].disposed, true); + assert.strictEqual(activeEvent.size + openEvent.size + closeEvent.size + configEvent.size, 0); + assert.strictEqual(FakeProvider.currentActive, undefined); + }); +}); diff --git a/src/test/untitled-enable-routing.test.ts b/src/test/untitled-enable-routing.test.ts new file mode 100644 index 0000000..6796bdc --- /dev/null +++ b/src/test/untitled-enable-routing.test.ts @@ -0,0 +1,56 @@ +import assert from 'assert'; +import { createTestMemento } from './test-memento'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +class Uri { + constructor(public fsPath: string, public scheme: string) {} + toString() { return `${this.scheme}:${this.fsPath}`; } +} +const state = { listeners: [] as any[], tabs: [] as any[], calls: [] as any[][], errors: [] as string[] }; +const vscode = { + Uri, + ConfigurationTarget: { Global: 1, Workspace: 2 }, + workspace: { + getConfiguration: (section: string) => ({ + get: (key: string, fallback: unknown) => section === 'csv' && key === 'enabled' ? true : fallback, + inspect: (key: string) => section === 'csv' && key === 'enabled' ? { defaultValue: true } : undefined + }), + onDidChangeConfiguration: (callback: any) => { state.listeners.push(callback); return { dispose() {} }; } + }, + window: { + showErrorMessage: (message: string) => { state.errors.push(message); }, + tabGroups: { + get all() { return [{ viewColumn: 1, tabs: state.tabs }]; }, + close: async (tabs: any) => { state.calls.push(['close', tabs]); return true; } + }, + registerCustomEditorProvider: () => ({ dispose() {} }) + }, + commands: { executeCommand: async (...args: any[]) => { state.calls.push(args); } } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { return vscode; } + if (id === './CsvEditorProvider') { return { CsvEditorProvider: class { static viewType = 'csv.editor'; static editors = []; } }; } + if (id === './commands') { return { registerCsvCommands() {} }; } + if (id === './untitled-editors') { return { registerUntitledCsvEditors() {} }; } + return originalRequire.apply(this, arguments as any); +}; +import { activate } from '../extension'; +Module.prototype.require = originalRequire; + +describe('enabling the saved-file editor preserves untitled owners', () => { + it('never closes or openWith-routes associated-path CSV/TSV untitled tabs', async () => { + const csv = { input: { uri: new Uri('/workspace/data.csv', 'untitled') }, isActive: true }; + const tsv = { input: { uri: new Uri('/workspace/data.tsv', 'untitled') }, isActive: false }; + state.tabs = [csv, tsv]; + state.calls = []; + state.listeners = []; + await activate({ subscriptions: [], globalState: createTestMemento(), workspaceState: createTestMemento() } as any); + await state.listeners[0]({ affectsConfiguration: (key: string) => key === 'csv.enabled' }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(state.errors, [], 'activation and enablement routing must complete'); + assert.deepStrictEqual(state.calls, []); + assert.deepStrictEqual(state.tabs, [csv, tsv]); + }); +}); diff --git a/src/test/untitled-switch-commit.test.ts b/src/test/untitled-switch-commit.test.ts new file mode 100644 index 0000000..d96f93f --- /dev/null +++ b/src/test/untitled-switch-commit.test.ts @@ -0,0 +1,184 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom, flushViewCommits } from './edit-integrity-harness'; + +const turn = () => new Promise(resolve => setImmediate(resolve)); +function deliver(host: Awaited>, view: ReturnType) { + const request = host.messages.filter(message => message.type === 'prepareViewRefresh').at(-1); + assert.ok(request); + const start = view.messages.length; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: request })); + return Promise.all(view.messages.slice(start).map(message => host.receive(message))) + .then(() => flushViewCommits(host, view)); +} + +describe('awaitable untitled draft commit', () => { + for (const separator of [',', '\t']) { + it(`waits for a real multiline DOM draft and delayed WorkspaceEdit without saving (${JSON.stringify(separator)})`, async () => { + const host = await createEditorHarness({ untitled: true, separator }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('pending draft'); + host.deferEdits(); + const html = host.panel.webview.html; + const disk = host.diskText; + let finished = false; + const commit = host.commitPendingDraft().then(success => { finished = true; return success; }); + const operation = deliver(host, view); + await turn(); + assert.strictEqual(host.pendingEdits, 1); + assert.strictEqual(finished, false); + assert.strictEqual(host.panel.webview.html, html); + host.releaseEdit(); + await operation; + assert.strictEqual(await commit, true); + assert.strictEqual(host.rows()[1][0], 'one\npending draft'); + assert.strictEqual(host.panel.webview.html, html, 'commit helper must not replace its live view'); + assert.strictEqual(host.diskText, disk); + assert.deepStrictEqual(host.sourceReveals, []); + assert.deepStrictEqual(host.nativeSaveCommands, []); + assert.strictEqual(host.activeController!.getViewColumn(), 2); + } finally { view.close(); host.panel.dispose(); } + }); + } + + for (const failure of ['false', 'throw']) { + it(`rejects an edit returning ${failure}, retains the DOM draft, and permits an explicit same-cell retry`, async () => { + const host = await createEditorHarness({ untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + const apply = host.applyEdit; + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('retained draft'); + const html = host.panel.webview.html; + host.applyEdit = async () => { if (failure === 'throw') { throw new Error('fixture edit rejected'); } return false; }; + const commit = host.commitPendingDraft(); + await deliver(host, view); + assert.strictEqual(await commit, false); + assert.strictEqual(view.cell().textContent, 'one\nretained draft'); + assert.strictEqual(host.panel.webview.html, html); + assert.strictEqual(host.panel.disposed, false); + assert.strictEqual(host.rows()[1][0], 'one'); + assert.deepStrictEqual(host.nativeSaveCommands, []); + host.applyEdit = apply; + view.start(view.cell()); + const retry = host.commitPendingDraft(); + await deliver(host, view); + assert.strictEqual(await retry, true); + assert.strictEqual(host.rows()[1][0], 'one\nretained draft'); + } finally { view.close(); host.panel.dispose(); } + }); + } + + it('resolves false on disposal and ignores its late acknowledgment', async () => { + const host = await createEditorHarness({ untitled: true }); + const commit = host.commitPendingDraft(); + const request = host.messages.at(-1); + host.panel.dispose(); + assert.strictEqual(await commit, false); + assert.strictEqual(await host.commitPendingDraft(), false); + await host.receive({ type: 'viewRefreshReady', requestId: request.requestId }); + assert.deepStrictEqual(host.sourceReveals, []); + }); + + it('returns false when the commit request cannot reach the webview', async () => { + const host = await createEditorHarness({ untitled: true }); + const post = host.panel.webview.postMessage; + try { + host.panel.webview.postMessage = async () => { throw new Error('panel unavailable'); }; + assert.strictEqual(await host.commitPendingDraft(), false); + assert.strictEqual(host.panel.disposed, false); + host.panel.webview.postMessage = post; + const view = createWebviewDom(host.panel.webview.html); + try { + const retry = host.commitPendingDraft(); + await deliver(host, view); + assert.strictEqual(await retry, true); + } finally { view.close(); } + } finally { host.panel.dispose(); } + }); + + it('times out without discarding a draft or opening another view', async () => { + const host = await createEditorHarness({ untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + const type = host.activeController!.constructor as any; + const timeout = type.DRAFT_COMMIT_TIMEOUT_MS; + type.DRAFT_COMMIT_TIMEOUT_MS = 10; + try { + view.start(view.cell()); + view.type('still editing'); + assert.strictEqual(await host.commitPendingDraft(), false); + assert.strictEqual(view.cell().textContent, 'onestill editing'); + assert.ok(view.cell().isContentEditable || view.cell().getAttribute('contenteditable') === 'true'); + assert.strictEqual(host.panel.disposed, false); + assert.deepStrictEqual(host.sourceReveals, []); + } finally { type.DRAFT_COMMIT_TIMEOUT_MS = timeout; view.close(); host.panel.dispose(); } + }); + + it('drains already pending edits for a hidden view and keeps an explicitly pending save action', async () => { + const host = await createEditorHarness({ untitled: true }); + host.deferEdits(); + const edit = host.receive({ type: 'editCell', row: 1, col: 0, value: 'queued' }); + await turn(); + host.panel.visible = false; + let done = false; + const commit = host.commitPendingDraft().then(success => { done = true; return success; }); + await turn(); + assert.strictEqual(done, false); + host.releaseEdit(); + await edit; + assert.strictEqual(await commit, true); + assert.strictEqual(host.rows()[1][0], 'queued'); + assert.deepStrictEqual(host.nativeSaveCommands, []); + host.panel.visible = true; + const view = createWebviewDom(host.panel.webview.html); + try { + host.saveUntitledAfterCommit(); + const pending = host.commitPendingDraft(); + await deliver(host, view); + assert.strictEqual(await pending, true); + assert.deepStrictEqual(host.nativeSaveCommands, ['workbench.action.files.save']); + } finally { view.close(); host.panel.dispose(); } + }); + + it('discards a failed draft against the live language-rebound owner before updating its grammar', async () => { + const host = await createEditorHarness({ untitled: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell()); + view.key('Enter', { shiftKey: true }); + view.type('unapplied draft'); + const replacement = host.rebindDocument('tsv'); + host.document.isClosed = true; + host.document.getText = () => { throw new Error('captured owner is closed'); }; + host.failEdits('false'); + const start = host.messages.length; + const commit = host.commitPendingDraft(); + await deliver(host, view); + assert.strictEqual(await commit, false); + assert.strictEqual(host.getCurrentSeparator(), ','); + for (const message of host.messages.slice(start)) { + if (message.type === 'editCellFailed') { + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: message })); + } + } + const beforeEscape = view.messages.length; + view.key('Escape'); + const discarded = view.messages.slice(beforeEscape); + assert.ok(discarded.some(message => message.type === 'discardCellDraft')); + assert.ok(!discarded.some(message => message.type === 'editCell')); + for (const message of discarded) { await host.receive(message); } + assert.strictEqual(replacement.getText(), 'name,value\r\none,two'); + assert.deepStrictEqual(host.nativeSaveCommands, []); + host.failEdits(undefined); + const retry = host.commitPendingDraft(); + await deliver(host, view); + assert.strictEqual(await retry, true); + assert.strictEqual(host.getCurrentSeparator(), '\t'); + assert.ok(!host.panel.webview.html.includes('unapplied draft')); + } finally { view.close(); host.panel.dispose(); } + }); +}); diff --git a/src/test/untitled-toggle-routing.test.ts b/src/test/untitled-toggle-routing.test.ts new file mode 100644 index 0000000..591cba0 --- /dev/null +++ b/src/test/untitled-toggle-routing.test.ts @@ -0,0 +1,62 @@ +import assert from 'assert'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +class Uri { + constructor(public scheme: string, public path: string) {} + toString() { return `${this.scheme}:${this.path}`; } +} +class TabInputText { constructor(public uri: Uri) {} } +class TabInputCustom { constructor(public uri: Uri, public viewType: string) {} } +const owner = { uri: new Uri('untitled', 'Untitled-1'), languageId: 'csv' }; +const group = { viewColumn: 1, activeTab: undefined as any }; +const opened: unknown[] = []; +const vscode = { + TabInputText, TabInputCustom, version: '1.142.0', + window: { activeTextEditor: { document: owner, viewColumn: 2 }, + visibleTextEditors: [{ document: owner, viewColumn: 1 }], + tabGroups: { activeTabGroup: group }, showInformationMessage() {}, showErrorMessage() {} }, + workspace: { getConfiguration: () => ({ get: () => true }) }, + commands: { getCommands: async () => [] } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { return vscode; } + if (id === './CsvEditorProvider') { return { CsvEditorProvider: { viewType: 'csv.editor' } }; } + if (id === './untitled-editors') { + return { getActiveUntitledCsvEditor: () => undefined, + isUntitledCsv: (document: typeof owner) => document.uri.scheme === 'untitled', + openUntitledCsvEditor: async (document: unknown, column: number) => { opened.push({ document, column }); } }; + } + return originalRequire.apply(this, arguments as any); +}; +const { toggleCsvEditor } = require('../editor-switch') as typeof import('../editor-switch'); +Module.prototype.require = originalRequire; + +describe('untitled switch routing across editor groups', () => { + it('ignores another group’s untitled text owner while switching a saved table', async () => { + opened.length = 0; + group.activeTab = { input: new TabInputCustom(new Uri('file', '/saved.csv'), 'csv.editor') }; + await toggleCsvEditor(); + assert.deepStrictEqual(opened, []); + }); + it('ignores another group’s untitled text owner while switching saved text', async () => { + opened.length = 0; + group.activeTab = { input: new TabInputText(new Uri('file', '/saved.csv')) }; + await toggleCsvEditor(); + assert.deepStrictEqual(opened, []); + }); + it('opens the untitled grid when its native owner is the active tab', async () => { + opened.length = 0; + group.activeTab = { input: new TabInputText(owner.uri) }; + await toggleCsvEditor(); + assert.deepStrictEqual(opened, [{ document: owner, column: 1 }]); + }); + it('uses the visible untitled owner when the global active text editor points elsewhere', async () => { + opened.length = 0; + vscode.window.activeTextEditor = { document: { ...owner, uri: new Uri('file', '/other.csv') }, viewColumn: 2 }; + group.activeTab = { input: new TabInputText(owner.uri) }; + await toggleCsvEditor(); + assert.deepStrictEqual(opened, [{ document: owner, column: 1 }]); + }); +}); diff --git a/src/untitled-editors.ts b/src/untitled-editors.ts new file mode 100644 index 0000000..ff94510 --- /dev/null +++ b/src/untitled-editors.ts @@ -0,0 +1,191 @@ +import * as vscode from 'vscode'; +import { CsvEditorProvider } from './CsvEditorProvider'; + +export const untitledCsvViewType = 'csv.untitledEditor'; + +type UntitledEditor = { + document: vscode.TextDocument; + panel: vscode.WebviewPanel; +}; +const editors = new Map(); +const opened = new Set(); +const switchingToSource = new Set(); +let extensionContext: vscode.ExtensionContext | undefined; + +export function isUntitledCsv(document: vscode.TextDocument): boolean { + return document.uri.scheme === 'untitled' && (document.languageId === 'csv' || document.languageId === 'tsv'); +} + +export function getActiveUntitledCsvEditor(): UntitledEditor | undefined { + return Array.from(editors.values()).find(editor => editor.panel.active && !editor.document.isClosed); +} + +export function saveActiveUntitledCsvEditor(): void { + const editor = getActiveUntitledCsvEditor(); + if (!editor) { + return; + } + CsvEditorProvider.editors.find(controller => controller.getDocumentUri().toString() === editor.document.uri.toString()) + ?.saveUntitledAfterCommit(); +} + +export async function showActiveUntitledCsvSource(): Promise { + const editor = getActiveUntitledCsvEditor(); + if (!editor) { + return; + } + const key = editor.document.uri.toString(); + if (switchingToSource.has(key)) { + return; + } + const controller = CsvEditorProvider.editors.find(candidate => candidate.getDocumentUri().toString() === key); + if (!controller) { + return; + } + switchingToSource.add(key); + try { + if (!await controller.commitPendingDraft() || editors.get(key) !== editor || editor.document.isClosed) { + return; + } + const source = await vscode.window.showTextDocument(editor.document, { + viewColumn: controller.getViewColumn() ?? editor.panel.viewColumn, preview: false + }); + if (source.document === editor.document && editors.get(key) === editor) { + // The native owner remains open. Only its temporary standalone table closes. + editor.panel.dispose(); + } + } catch (error) { + console.error('CSV: could not reveal the untitled source', error); + void vscode.window.showErrorMessage('CSV: Could not switch the untitled table to text.'); + } finally { + switchingToSource.delete(key); + } +} + +export async function openUntitledCsvEditor(document: vscode.TextDocument, viewColumn?: vscode.ViewColumn): Promise { + if (!extensionContext || document.isClosed || !isUntitledCsv(document) || + !vscode.workspace.getConfiguration('csv', document.uri).get('enabled', true)) { + return; + } + const key = document.uri.toString(); + const existing = editors.get(key); + if (existing) { + existing.panel.reveal(viewColumn); + return; + } + opened.add(key); + // A plain webview keeps the native untitled document as its owner. Asking + // vscode.openWith for an untitled custom text editor can reset that document. + const panel = vscode.window.createWebviewPanel( + untitledCsvViewType, + `${document.fileName.split(/[\\/]/).pop() || 'Untitled'} — CSV`, + viewColumn ?? vscode.ViewColumn.Active, + { enableScripts: true } + ); + const tokenSource = new vscode.CancellationTokenSource(); + editors.set(key, { document, panel }); + panel.onDidDispose(() => { + editors.delete(key); + tokenSource.cancel(); + tokenSource.dispose(); + const active = CsvEditorProvider.currentActive; + if (active?.getDocumentUri().toString() === key) { + CsvEditorProvider.currentActive = undefined; + } + }); + try { + await new CsvEditorProvider(extensionContext).resolveCustomTextEditor(document, panel, tokenSource.token); + } catch (error) { + opened.delete(key); + panel.dispose(); + throw error; + } +} + +export function registerUntitledCsvEditors(context: vscode.ExtensionContext): void { + extensionContext = context; + const maybeOpen = (document: vscode.TextDocument | undefined) => { + if (!document || !isUntitledCsv(document) || opened.has(document.uri.toString())) { + return; + } + const active = vscode.window.activeTextEditor; + if (!active || active.document.uri.toString() !== document.uri.toString()) { + return; + } + void openUntitledCsvEditor(document, active.viewColumn).catch(error => { + console.error('CSV: could not open the untitled table', error); + vscode.window.showErrorMessage('CSV: Could not open the untitled document in the table view.'); + }); + }; + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(editor => maybeOpen(editor?.document)), + vscode.workspace.onDidOpenTextDocument(document => { + if (document.uri.scheme !== 'untitled') { + return; + } + if (!isUntitledCsv(document)) { + opened.delete(document.uri.toString()); + } + maybeOpen(document); + }), + vscode.workspace.onDidCloseTextDocument(document => { + const key = document.uri.toString(); + if (!editors.has(key) && !opened.has(key)) { + return; + } + // Changing language also closes/reopens a TextDocument at the same URI. + setTimeout(() => { + const replacement = vscode.workspace.textDocuments.find(candidate => candidate.uri.toString() === key && !candidate.isClosed); + const editor = editors.get(key); + if (replacement) { + if (editor) { + editor.document = replacement; + CsvEditorProvider.editors.filter(controller => controller.getDocumentUri().toString() === key) + .forEach(controller => { + controller.updateDocumentReference(replacement); + if (!isUntitledCsv(replacement)) { + controller.revealSourceAfterCommit('language'); + } + }); + } + return; + } + editor?.panel.dispose(); + opened.delete(key); + }, 0); + }), + vscode.workspace.onDidChangeConfiguration(event => { + if (!event.affectsConfiguration('csv.enabled')) { + return; + } + if (!vscode.workspace.getConfiguration('csv').get('enabled', true)) { + editors.forEach(editor => { + const controller = CsvEditorProvider.editors.find(candidate => candidate.getDocumentUri().toString() === editor.document.uri.toString()); + if (controller) { + controller.revealSourceAfterCommit(); + return; + } + // A panel still initializing has no active draft or controller yet. + if (!editor.document.isClosed) { + void Promise.resolve(vscode.window.showTextDocument(editor.document, { + viewColumn: editor.panel.viewColumn, preview: false, preserveFocus: !editor.panel.active + })).catch(error => console.error('CSV: could not reveal the untitled source', error)); + } + editor.panel.dispose(); + }); + return; + } + opened.clear(); + editors.forEach((_editor, key) => opened.add(key)); + maybeOpen(vscode.window.activeTextEditor?.document); + }), + { dispose: () => { + editors.forEach(editor => editor.panel.dispose()); + editors.clear(); + opened.clear(); + switchingToSource.clear(); + extensionContext = undefined; + } } + ); + maybeOpen(vscode.window.activeTextEditor?.document); +}