From 7422abcb42704b91f8e4532057b1aeb7a72c968a Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:33:48 -0400 Subject: [PATCH 1/8] Support unsaved CSV and TSV document ownership --- README.md | 1 + package.json | 2 +- scripts/untitled-editor-smoke.cjs | 81 +++++++++ src/CsvEditorProvider.ts | 42 ++++- src/editor-switch.ts | 11 ++ src/extension.ts | 2 + src/test/untitled-editors.test.ts | 268 ++++++++++++++++++++++++++++++ src/untitled-editors.ts | 123 ++++++++++++++ 8 files changed, 524 insertions(+), 6 deletions(-) create mode 100644 scripts/untitled-editor-smoke.cjs create mode 100644 src/test/untitled-editors.test.ts create mode 100644 src/untitled-editors.ts diff --git a/README.md b/README.md index 8cca5fd..cbe3f83 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 and other per-file commands work in this view. The native source remains open and shares unsaved edits. Saving reveals the source and uses VS Code’s normal Save As flow; the temporary table closes when the untitled document becomes a saved file. - **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/package.json b/package.json index 65dd935..638b951 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "editor/title": [ { "command": "csv.toggleEditor", - "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || (activeEditor == workbench.editors.textEditor && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i))", + "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor || (activeEditor == workbench.editors.textEditor && (resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i || (resourceScheme == untitled && editorLangId =~ /^(csv|tsv)$/))))", "group": "navigation" } ] diff --git a/scripts/untitled-editor-smoke.cjs b/scripts/untitled-editor-smoke.cjs new file mode 100644 index 0000000..bd1ad61 --- /dev/null +++ b/scripts/untitled-editor-smoke.cjs @@ -0,0 +1,81 @@ +// 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); + console.log('UNTITLED_PRE_LANGUAGE', document.uri.toString(), document.isDirty, document.isClosed, document.getText()); + const live = await vscode.languages.setTextDocumentLanguage(document, language); + console.log('UNTITLED_POST_LANGUAGE', live.uri.toString(), live.isDirty, live.isClosed, live.getText()); + await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'language mode should open the untitled table'); + assert.equal(live.getText(), content); + await waitUntil(() => live.isDirty, 'untitled contents must remain dirty'); + 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`; + const live = await prepare(document, language, contents); + let controller = CsvEditorProvider.getActiveProvider(); + assert.ok(controller); + assert.equal(controller.getCurrentSeparator(), separator); + await controller.updateDocument(1, 1, '43'); + const expected = `name${separator}value\nnew${separator}43`; + assert.equal(live.getText(), expected); + 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); + 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 return to existing untitled grid'); + controller = CsvEditorProvider.getActiveProvider(); + await controller.handleSave(); + await waitUntil(() => fs.existsSync(filename) && !getActiveUntitledCsvEditor(), 'native save should produce the file and close temporary grid'); + assert.equal(fs.readFileSync(filename, 'utf8'), expected); + 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; + await CsvEditorProvider.getActiveProvider().handleSave(); + // VS Code's isolated extension-test host cancels the native Save As picker. + assert.equal(live.isClosed, false); + assert.equal(live.isDirty, true); + 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, shared edits, source switching, exact native saves, cancelled Save As retained'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}; diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 9135ca8..940c3b8 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -89,7 +89,7 @@ class CsvEditorController { } const proceed = await this.confirmLargeFileOpen(config, webviewPanel, _token); - if (!proceed) { + if (!proceed || _token.isCancellationRequested) { return; } @@ -198,6 +198,9 @@ class CsvEditorController { changeDocumentSubscription.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); this.currentWebviewPanel = undefined; + if (CsvEditorProvider.currentActive === this) { + CsvEditorProvider.currentActive = undefined; + } }); } @@ -234,7 +237,11 @@ class CsvEditorController { preserveFocus: !webviewPanel.active, preview: webviewPanel.active ? webviewPanel.active : false }; - await vscode.commands.executeCommand('vscode.openWith', uri, 'default', opts); + if (uri.scheme === 'untitled') { + await vscode.window.showTextDocument(this.document, opts); + } else { + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', opts); + } } finally { try { webviewPanel.dispose(); } catch {} } @@ -245,6 +252,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; @@ -295,8 +305,14 @@ class CsvEditorController { public refresh() { const config = vscode.workspace.getConfiguration('csv', this.document.uri); if (!config.get('enabled', true)) { - this.currentWebviewPanel?.dispose(); - vscode.commands.executeCommand('vscode.openWith', this.document.uri, 'default'); + const panel = this.currentWebviewPanel; + if (this.document.uri.scheme === 'untitled') { + void vscode.window.showTextDocument(this.document, { viewColumn: panel?.viewColumn, preview: false }); + panel?.dispose(); + } else { + panel?.dispose(); + vscode.commands.executeCommand('vscode.openWith', this.document.uri, 'default'); + } } else { if (this.currentWebviewPanel) { this.forceReload(); @@ -386,6 +402,13 @@ class CsvEditorController { ); } + public updateDocumentReference(document: vscode.TextDocument): void { + if (document.uri.toString() === this.document.uri.toString()) { + this.document = document; + this.refresh(); + } + } + public getDocumentUri(): vscode.Uri { return this.document.uri; } @@ -949,6 +972,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) { @@ -1976,7 +2006,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' + : 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/editor-switch.ts b/src/editor-switch.ts index 84ac6b7..8300577 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 } from './untitled-editors'; const switchingTabs = new Set(); const supportedExtensions = new Set(['.csv', '.tsv', '.tab', '.psv']); @@ -13,6 +14,16 @@ function getInputUri(input: unknown, viewType: string): vscode.Uri | undefined { } export async function toggleCsvEditor(): Promise { + const untitled = getActiveUntitledCsvEditor(); + if (untitled) { + await vscode.window.showTextDocument(untitled.document, { viewColumn: untitled.panel.viewColumn, preview: false }); + return; + } + const activeDocument = vscode.window.activeTextEditor?.document; + if (activeDocument && isUntitledCsv(activeDocument)) { + await openUntitledCsvEditor(activeDocument, vscode.window.activeTextEditor?.viewColumn); + return; + } const group = vscode.window.tabGroups.activeTabGroup; const tab = group.activeTab; if (!tab) { diff --git a/src/extension.ts b/src/extension.ts index 50ec42b..ec26e03 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'; export function activate(context: vscode.ExtensionContext) { console.log('CSV: Extension activated'); @@ -113,6 +114,7 @@ export function activate(context: vscode.ExtensionContext) { { supportsMultipleEditorsPerDocument: false } ) ); + registerUntitledCsvEditors(context); } export function deactivate() { diff --git a/src/test/untitled-editors.test.ts b/src/test/untitled-editors.test.ts new file mode 100644 index 0000000..5c06828 --- /dev/null +++ b/src/test/untitled-editors.test.ts @@ -0,0 +1,268 @@ +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 +}; +class FakeProvider { + static viewType = 'csv.editor'; + static editors: any[] = []; + static currentActive: any; + 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, + updateDocumentReference: (replacement: any) => { controller.document = replacement; } + }; + 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 = { + 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: { + get activeTextEditor() { return 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() {} + }, + 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 === './CsvEditorProvider') { return { CsvEditorProvider: FakeProvider }; } + return originalRequire.apply(this, arguments as any); +}; +import { getActiveUntitledCsvEditor, isUntitledCsv, openUntitledCsvEditor, registerUntitledCsvEditors } from '../untitled-editors'; +import { toggleCsvEditor } from '../editor-switch'; +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; + 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('does not steal focus after an explicit switch to source and reuses its table on return', async () => { + const original = document(); + activate(original); + await settle(); + const panel = state.panels[0]; + await toggleCsvEditor(); + assert.strictEqual(state.activeTextEditor.document, original); + await settle(); + assert.strictEqual(panel.active, false); + await toggleCsvEditor(); + assert.strictEqual(panel.active, true); + assert.strictEqual(state.panels.length, 1); + assert.strictEqual(state.resolutions.length, 1); + }); + + 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('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('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/untitled-editors.ts b/src/untitled-editors.ts new file mode 100644 index 0000000..440aad1 --- /dev/null +++ b/src/untitled-editors.ts @@ -0,0 +1,123 @@ +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(); +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 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 && isUntitledCsv(replacement)) { + if (editor) { + editor.document = replacement; + CsvEditorProvider.editors.filter(controller => controller.getDocumentUri().toString() === key) + .forEach(controller => controller.updateDocumentReference(replacement)); + } + return; + } + editor?.panel.dispose(); + opened.delete(key); + }, 0); + }), + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('csv.enabled') && vscode.workspace.getConfiguration('csv').get('enabled', true)) { + opened.clear(); + maybeOpen(vscode.window.activeTextEditor?.document); + } + }), + { dispose: () => { + editors.forEach(editor => editor.panel.dispose()); + editors.clear(); + opened.clear(); + extensionContext = undefined; + } } + ); + maybeOpen(vscode.window.activeTextEditor?.document); +} From e0ed224de9d730c7e6468c4556734a9d0e24d569 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:40:23 -0400 Subject: [PATCH 2/8] Preserve untitled drafts through disable and native Save As --- README.md | 2 +- media/main.js | 6 ++- scripts/untitled-editor-smoke.cjs | 22 +++++++---- src/CsvEditorProvider.ts | 38 +++++++++++++++++- src/commands.ts | 5 +++ src/extension.ts | 1 + src/test/edit-integrity-harness.ts | 26 +++++++++++-- src/test/untitled-draft-close.test.ts | 37 ++++++++++++++++++ src/test/untitled-editors.test.ts | 47 +++++++++++++++++++++-- src/test/untitled-enable-routing.test.ts | 49 ++++++++++++++++++++++++ src/untitled-editors.ts | 24 ++++++++++-- 11 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 src/test/untitled-draft-close.test.ts create mode 100644 src/test/untitled-enable-routing.test.ts diff --git a/README.md b/README.md index 99f8c0b..231bb73 100644 --- a/README.md +++ b/README.md @@ -36,7 +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 and other per-file commands work in this view. The native source remains open and shares unsaved edits. Saving reveals the source and uses VS Code’s normal Save As flow; the temporary table closes when the untitled document becomes a saved file. +- **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. The native source remains open and shares unsaved edits. Saving reveals the source and uses VS Code’s normal Save As flow; the temporary table closes when the untitled document becomes a saved file. - **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 04d192d..def3591 100644 --- a/media/main.js +++ b/media/main.js @@ -2038,7 +2038,11 @@ const copySelectionToClipboard = () => { window.addEventListener('message', event => { const message = event.data; - if(message.type === 'focus'){ + if (message.type === 'prepareViewRefresh') { + if (editingCell) editingCell.blur(); + persistState(); + vscode.postMessage({ type: 'viewRefreshReady' }); + } else if(message.type === 'focus'){ if (anchorCell) { try { anchorCell.focus({ preventScroll: true }); } catch { try { anchorCell.focus(); } catch {} } } else { diff --git a/scripts/untitled-editor-smoke.cjs b/scripts/untitled-editor-smoke.cjs index bd1ad61..7e2848e 100644 --- a/scripts/untitled-editor-smoke.cjs +++ b/scripts/untitled-editor-smoke.cjs @@ -19,12 +19,10 @@ async function prepare(document, language, content) { const edit = new vscode.WorkspaceEdit(); edit.insert(document.uri, new vscode.Position(0, 0), content); assert.equal(await vscode.workspace.applyEdit(edit), true); - console.log('UNTITLED_PRE_LANGUAGE', document.uri.toString(), document.isDirty, document.isClosed, document.getText()); const live = await vscode.languages.setTextDocumentLanguage(document, language); - console.log('UNTITLED_POST_LANGUAGE', live.uri.toString(), live.isDirty, live.isClosed, live.getText()); await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'language mode should open the untitled table'); assert.equal(live.getText(), content); - await waitUntil(() => live.isDirty, 'untitled contents must remain dirty'); + 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; } @@ -40,6 +38,14 @@ exports.run = async function () { await vscode.languages.setTextDocumentLanguage(document, 'plaintext'); const contents = `name${separator}value\nnew${separator}42`; const 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); @@ -54,9 +60,11 @@ exports.run = async function () { await vscode.commands.executeCommand('csv.toggleEditor'); await waitUntil(() => getActiveUntitledCsvEditor()?.document === live, 'switch should return to existing untitled grid'); controller = CsvEditorProvider.getActiveProvider(); - await controller.handleSave(); + const savedExpected = `name${separator}value\nnew${separator}"43\nqueued"`; + void controller.enqueueOperation(() => controller.updateDocument(1, 1, '43\nqueued')); + await controller.enqueueOperation(() => controller.handleSave()); await waitUntil(() => fs.existsSync(filename) && !getActiveUntitledCsvEditor(), 'native save should produce the file and close temporary grid'); - assert.equal(fs.readFileSync(filename, 'utf8'), expected); + 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); } @@ -67,14 +75,14 @@ exports.run = async function () { await CsvEditorProvider.getActiveProvider().handleSave(); // VS Code's isolated extension-test host cancels the native Save As picker. assert.equal(live.isClosed, false); - assert.equal(live.isDirty, true); + 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, shared edits, source switching, exact native saves, cancelled Save As retained'); + console.log('CSV_UNTITLED_SMOKE_OK: pretyped CSV+TSV, shared edits, source switching, disable/re-enable, exact native saves, cancelled Save As retained'); } finally { fs.rmSync(directory, { recursive: true, force: true }); } diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index be3ef01..9003445 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -64,6 +64,7 @@ class CsvEditorController { private isUpdatingDocument = false; private isSaving = false; + private pendingViewAction: (() => Promise) | undefined; private pendingOperation: Promise = Promise.resolve(); private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; @@ -129,6 +130,16 @@ class CsvEditorController { webviewPanel.webview.onDidReceiveMessage(e => this.enqueueOperation(async () => { switch (e.type) { + case 'viewRefreshReady': { + const action = this.pendingViewAction; + this.pendingViewAction = undefined; + if (action) { + await action(); + } else { + this.updateWebviewContent(); + } + break; + } case 'editCell': await this.updateDocument(e.row, e.col, e.value); break; @@ -213,6 +224,7 @@ class CsvEditorController { saveDocumentSubscription.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); this.currentWebviewPanel = undefined; + this.pendingViewAction = undefined; if (CsvEditorProvider.currentActive === this) { CsvEditorProvider.currentActive = undefined; } @@ -331,8 +343,7 @@ class CsvEditorController { if (!config.get('enabled', true)) { const panel = this.currentWebviewPanel; if (this.document.uri.scheme === 'untitled') { - void vscode.window.showTextDocument(this.document, { viewColumn: panel?.viewColumn, preview: false }); - panel?.dispose(); + this.revealSourceAfterCommit(); } else { panel?.dispose(); vscode.commands.executeCommand('vscode.openWith', this.document.uri, 'default'); @@ -344,6 +355,28 @@ class CsvEditorController { } } + public revealSourceAfterCommit(): void { + const panel = this.currentWebviewPanel; + if (!panel || this.pendingViewAction) { + return; + } + this.pendingViewAction = async () => { + await vscode.window.showTextDocument(this.document, { viewColumn: panel.viewColumn, preview: false }); + panel.dispose(); + }; + if (panel.visible) { + // The webview posts its final edit before acknowledging this request. + // The ordered message queue applies that edit before closing the panel. + void panel.webview.postMessage({ type: 'prepareViewRefresh' }); + } else { + void this.enqueueOperation(async () => { + const action = this.pendingViewAction; + this.pendingViewAction = undefined; + await action?.(); + }); + } + } + private forceReload() { if (!this.currentWebviewPanel) return; const panel = this.currentWebviewPanel; @@ -429,6 +462,7 @@ class CsvEditorController { public updateDocumentReference(document: vscode.TextDocument): void { if (document.uri.toString() === this.document.uri.toString()) { this.document = document; + this.separatorCache = undefined; this.refresh(); } } diff --git a/src/commands.ts b/src/commands.ts index db2d96a..4789a23 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -133,6 +133,11 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { 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/extension.ts b/src/extension.ts index ec26e03..2164f8c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,6 +32,7 @@ export function activate(context: vscode.ExtensionContext) { if (!input) return; if (viewType === CsvEditorProvider.viewType) return; // already our editor if (!uri) return; + if (uri.scheme === 'untitled') { return; } // Untitled buffers retain their native owner. const fsPath = uri.fsPath?.toLowerCase?.() || ''; const isCsvLike = fsPath.endsWith('.csv') || fsPath.endsWith('.tsv') || fsPath.endsWith('.tab') || fsPath.endsWith('.psv'); console.log(`[CSV(enable)]: -> eligible=${isCsvLike}`); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..c061385 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -19,7 +19,13 @@ const vscodeStub = { replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } }, ColorThemeKind: { Dark: 1 }, - window: { activeColorTheme: { kind: 1 } }, + window: { + activeColorTheme: { kind: 1 }, + showTextDocument: async (document: any) => { + activeHarness.sourceReveals.push(document.getText()); + return { document }; + } + }, workspace: { getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), @@ -49,6 +55,7 @@ export async function createEditorHarness(options: { separator?: string; header?: boolean; remote?: boolean; + untitled?: boolean; } = {}) { const separator = options.separator ?? ','; let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); @@ -57,12 +64,15 @@ export async function createEditorHarness(options: { const changeListeners: Array<(event: unknown) => void> = []; const viewListeners: Array<(event: unknown) => void> = []; const saveListeners: Array<(event: unknown) => void> = []; + const disposeListeners: Array<() => void> = []; + let controller: any; const messages: any[] = []; const delayedEdits: Array<() => void> = []; let deferEdits = false; - const uri = new TestUri(`/test/data.${separator === '\t' ? 'tsv' : 'csv'}`, options.remote ? 'vscode-remote' : 'file'); + const uri = new TestUri(`/test/data.${separator === '\t' ? 'tsv' : 'csv'}`, options.untitled ? 'untitled' : options.remote ? 'vscode-remote' : 'file'); const document = { uri, + languageId: separator === '\t' ? 'tsv' : 'csv', version: 1, getText: () => text, get lineCount() { return text.split(/\r\n|\n|\r/).length; }, @@ -97,10 +107,19 @@ export async function createEditorHarness(options: { onDidReceiveMessage: (listener: typeof messageListener) => { messageListener = listener; } }, onDidChangeViewState: (listener: (event: unknown) => void) => { viewListeners.push(listener); }, - onDidDispose() {} + disposed: false, + onDidDispose(listener: () => void) { disposeListeners.push(listener); }, + dispose() { + panel.disposed = true; + panel.active = false; + panel.visible = false; + disposeListeners.forEach(listener => listener()); + } }; const harness = { document, panel, messages, changeListeners, saveListeners, + sourceReveals: [] as string[], + revealSourceAfterCommit() { controller.revealSourceAfterCommit(); }, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, @@ -131,6 +150,7 @@ export async function createEditorHarness(options: { }; activeHarness = harness; await new CsvEditorProvider(context as any).resolveCustomTextEditor(document as any, panel as any, { isCancellationRequested: false } as any); + controller = CsvEditorProvider.currentActive; return harness; } 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-editors.test.ts b/src/test/untitled-editors.test.ts index 5c06828..3142a88 100644 --- a/src/test/untitled-editors.test.ts +++ b/src/test/untitled-editors.test.ts @@ -26,12 +26,15 @@ const state = { resolutions: [] as any[], subscriptions: [] as any[], tokens: [] as any[], - failResolve: false + failResolve: false, + commands: new Map any>(), + information: [] as string[] }; 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'); } @@ -39,7 +42,11 @@ class FakeProvider { document, getDocumentUri: () => controller.document.uri, isActive: () => panel.active, - updateDocumentReference: (replacement: any) => { controller.document = replacement; } + 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; @@ -92,7 +99,14 @@ const vscode = { activeEvent.fire(state.activeTextEditor); return state.activeTextEditor; }, - showErrorMessage() {} + 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; }, @@ -105,11 +119,13 @@ const vscode = { 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 { @@ -138,6 +154,8 @@ beforeEach(() => { state.subscriptions = []; state.tokens = []; state.failResolve = false; + state.commands.clear(); + state.information = []; FakeProvider.editors = []; FakeProvider.currentActive = undefined; registerUntitledCsvEditors({ subscriptions: state.subscriptions } as any); @@ -204,6 +222,19 @@ describe('untitled CSV document ownership and routing', () => { 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); @@ -246,6 +277,16 @@ describe('untitled CSV document ownership and routing', () => { assert.strictEqual(getActiveUntitledCsvEditor(), undefined); }); + 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(); diff --git a/src/test/untitled-enable-routing.test.ts b/src/test/untitled-enable-routing.test.ts new file mode 100644 index 0000000..7e61bd1 --- /dev/null +++ b/src/test/untitled-enable-routing.test.ts @@ -0,0 +1,49 @@ +import assert from 'assert'; +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[][] }; +const vscode = { + Uri, + workspace: { + getConfiguration: () => ({ get: () => true }), + onDidChangeConfiguration: (callback: any) => { state.listeners.push(callback); return { dispose() {} }; } + }, + window: { + 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 = []; + activate({ subscriptions: [] } as any); + state.listeners[0]({ affectsConfiguration: (key: string) => key === 'csv.enabled' }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(state.calls, []); + assert.deepStrictEqual(state.tabs, [csv, tsv]); + }); +}); diff --git a/src/untitled-editors.ts b/src/untitled-editors.ts index 440aad1..5ecda40 100644 --- a/src/untitled-editors.ts +++ b/src/untitled-editors.ts @@ -107,10 +107,28 @@ export function registerUntitledCsvEditors(context: vscode.ExtensionContext): vo }, 0); }), vscode.workspace.onDidChangeConfiguration(event => { - if (event.affectsConfiguration('csv.enabled') && vscode.workspace.getConfiguration('csv').get('enabled', true)) { - opened.clear(); - maybeOpen(vscode.window.activeTextEditor?.document); + 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(); + maybeOpen(vscode.window.activeTextEditor?.document); }), { dispose: () => { editors.forEach(editor => editor.panel.dispose()); From c5bb6fed4119e2eaf288df15af4008b223f4d94b Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:51:36 -0400 Subject: [PATCH 3/8] Preserve untitled drafts across language changes and expose Save As --- README.md | 2 +- media/main.js | 16 +- package.json | 20 ++- scripts/untitled-editor-smoke.cjs | 20 ++- src/CsvEditorProvider.ts | 140 ++++++++++++---- src/commands.ts | 2 + src/test/edit-integrity-harness.ts | 47 +++++- src/test/untitled-draft-lifecycle.test.ts | 190 ++++++++++++++++++++++ src/test/untitled-editors.test.ts | 14 ++ src/untitled-editors.ts | 19 ++- 10 files changed, 420 insertions(+), 50 deletions(-) create mode 100644 src/test/untitled-draft-lifecycle.test.ts diff --git a/README.md b/README.md index 231bb73..4bd3213 100644 --- a/README.md +++ b/README.md @@ -36,7 +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. The native source remains open and shares unsaved edits. Saving reveals the source and uses VS Code’s normal Save As flow; the temporary table closes when the untitled document becomes a saved file. +- **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. The native source remains open and shares unsaved edits. 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 def3591..e7513da 100644 --- a/media/main.js +++ b/media/main.js @@ -1497,6 +1497,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); @@ -1758,11 +1764,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) { @@ -2041,7 +2042,7 @@ window.addEventListener('message', event => { if (message.type === 'prepareViewRefresh') { if (editingCell) editingCell.blur(); persistState(); - vscode.postMessage({ type: 'viewRefreshReady' }); + vscode.postMessage({ type: 'viewRefreshReady', requestId: message.requestId }); } else if(message.type === 'focus'){ if (anchorCell) { try { anchorCell.focus({ preventScroll: true }); } catch { try { anchorCell.focus(); } catch {} } @@ -2200,3 +2201,6 @@ document.addEventListener('keydown', e => { clearSelection(); } }); + +// The host may be waiting to commit a draft while this webview initializes. +vscode.postMessage({ type: 'webviewReady' }); diff --git a/package.json b/package.json index a58c05c..66b3957 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "onLanguage:tsv", "onCommand:csv.toggleExtension", "onCommand:csv.toggleEditor", + "onCommand:csv.saveUntitled", "onCommand:csv.toggleHeader", "onCommand:csv.toggleSerialIndex", "onCommand:csv.changeSeparator", @@ -70,6 +71,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" @@ -113,6 +120,17 @@ "command": "csv.toggleEditor", "when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor || (activeEditor == workbench.editors.textEditor && (resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i || (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" } ] }, @@ -128,7 +146,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 index 7e2848e..061323a 100644 --- a/scripts/untitled-editor-smoke.cjs +++ b/scripts/untitled-editor-smoke.cjs @@ -37,7 +37,7 @@ exports.run = async function () { const document = await vscode.workspace.openTextDocument(uri); await vscode.languages.setTextDocumentLanguage(document, 'plaintext'); const contents = `name${separator}value\nnew${separator}42`; - const live = await prepare(document, language, contents); + 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'); @@ -49,6 +49,13 @@ exports.run = async function () { 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); @@ -62,8 +69,8 @@ exports.run = async function () { controller = CsvEditorProvider.getActiveProvider(); const savedExpected = `name${separator}value\nnew${separator}"43\nqueued"`; void controller.enqueueOperation(() => controller.updateDocument(1, 1, '43\nqueued')); - await controller.enqueueOperation(() => controller.handleSave()); - await waitUntil(() => fs.existsSync(filename) && !getActiveUntitledCsvEditor(), 'native save should produce the file and close temporary grid'); + 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); @@ -72,7 +79,10 @@ exports.run = async function () { const document = await vscode.workspace.openTextDocument({ language: 'plaintext' }); const live = await prepare(document, 'csv', 'name,value\ncancel,42'); const panel = getActiveUntitledCsvEditor().panel; - await CsvEditorProvider.getActiveProvider().handleSave(); + const controller = CsvEditorProvider.getActiveProvider(); + 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(() => {}); // VS Code's isolated extension-test host cancels the native Save As picker. 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'); @@ -82,7 +92,7 @@ exports.run = async function () { panel.dispose(); assert.equal(live.getText(), 'name,value\ncancel,42'); assert.equal(CsvEditorProvider.currentActive, undefined); - console.log('CSV_UNTITLED_SMOKE_OK: pretyped CSV+TSV, shared edits, source switching, disable/re-enable, exact native saves, cancelled Save As retained'); + console.log('CSV_UNTITLED_SMOKE_OK: pretyped CSV+TSV, language roundtrips, shared edits, source switching, disable/re-enable, registered Save As exact native saves, cancelled Save As retained'); } finally { fs.rmSync(directory, { recursive: true, force: true }); } diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 9003445..cdd5dbb 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -65,6 +65,11 @@ class CsvEditorController { private isUpdatingDocument = false; private isSaving = false; private pendingViewAction: (() => Promise) | undefined; + private pendingDocumentReference: { document: vscode.TextDocument; separator: string } | undefined; + private renderedSeparator: string | undefined; + private viewCommitRequested = false; + private viewCommitSequence = 0; + private pendingClose = false; private pendingOperation: Promise = Promise.resolve(); private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; @@ -91,7 +96,7 @@ class CsvEditorController { } const proceed = await this.confirmLargeFileOpen(config, webviewPanel, _token); - if (!proceed || _token.isCancellationRequested) { + if (!proceed || _token.isCancellationRequested || document.isClosed) { return; } @@ -130,14 +135,20 @@ class CsvEditorController { webviewPanel.webview.onDidReceiveMessage(e => this.enqueueOperation(async () => { switch (e.type) { + case 'webviewReady': { + if (this.viewCommitRequested) { + this.postViewCommitRequest(); + } + break; + } case 'viewRefreshReady': { - const action = this.pendingViewAction; - this.pendingViewAction = undefined; - if (action) { - await action(); - } else { - this.updateWebviewContent(); + if (this.viewCommitRequested && e.requestId !== this.viewCommitSequence) { + break; + } + if (!this.viewCommitRequested && e.requestId !== undefined) { + break; } + await this.finishViewCommit(); break; } case 'editCell': @@ -156,7 +167,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); @@ -225,6 +240,8 @@ class CsvEditorController { CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); this.currentWebviewPanel = undefined; this.pendingViewAction = undefined; + this.pendingDocumentReference = undefined; + this.viewCommitRequested = false; if (CsvEditorProvider.currentActive === this) { CsvEditorProvider.currentActive = undefined; } @@ -349,31 +366,83 @@ class CsvEditorController { vscode.commands.executeCommand('vscode.openWith', this.document.uri, 'default'); } } else { - if (this.currentWebviewPanel) { + if (this.currentWebviewPanel && !this.viewCommitRequested) { this.forceReload(); } } } - public revealSourceAfterCommit(): void { + public revealSourceAfterCommit(reason: 'disabled' | 'language' = 'disabled'): void { const panel = this.currentWebviewPanel; - if (!panel || this.pendingViewAction) { + 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 vscode.window.showTextDocument(this.document, { viewColumn: panel.viewColumn, preview: false }); panel.dispose(); }; + 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 { + void this.currentWebviewPanel?.webview.postMessage({ + type: 'prepareViewRefresh', requestId: this.viewCommitSequence + }); + } + + private requestViewCommit(): void { + const panel = this.currentWebviewPanel; + if (!panel || this.viewCommitRequested) { + return; + } + this.viewCommitRequested = true; + this.viewCommitSequence += 1; if (panel.visible) { - // The webview posts its final edit before acknowledging this request. - // The ordered message queue applies that edit before closing the panel. - void panel.webview.postMessage({ type: 'prepareViewRefresh' }); + // 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(async () => { - const action = this.pendingViewAction; - this.pendingViewAction = undefined; - await action?.(); - }); + void this.enqueueOperation(() => this.finishViewCommit()); + } + } + + private async finishViewCommit(): Promise { + this.viewCommitRequested = false; + if (this.pendingDocumentReference) { + this.document = this.pendingDocumentReference.document; + this.pendingDocumentReference = undefined; + this.separatorCache = undefined; + } + const action = this.pendingViewAction; + this.pendingViewAction = undefined; + this.pendingClose = false; + if (action) { + await action(); + } else { + this.updateWebviewContent(); } } @@ -461,9 +530,11 @@ class CsvEditorController { public updateDocumentReference(document: vscode.TextDocument): void { if (document.uri.toString() === this.document.uri.toString()) { - this.document = document; - this.separatorCache = undefined; - this.refresh(); + this.pendingDocumentReference = { + document, + separator: this.pendingDocumentReference?.separator ?? this.renderedSeparator ?? this.getSeparator() + }; + this.requestViewCommit(); } } @@ -478,12 +549,15 @@ class CsvEditorController { // ───────────── Document Editing Methods ───────────── 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; @@ -518,11 +592,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); } finally { this.isUpdatingDocument = false; @@ -533,13 +607,13 @@ class CsvEditorController { } 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 }); // Trigger a full re-render if structure may have changed (new row/col created) - if (structuralChange) { + if (structuralChange && !this.pendingDocumentReference) { try { this.updateWebviewContent(); } catch (e) { console.error('CSV: refresh failed after structural edit', e); } } } @@ -1445,12 +1519,13 @@ class CsvEditorController { // ───────────── Webview Rendering ───────────── private updateWebviewContent() { - if (!this.currentWebviewPanel) return; + if (!this.currentWebviewPanel || this.viewCommitRequested || this.pendingDocumentReference) return; const webview = this.currentWebviewPanel.webview; const config = vscode.workspace.getConfiguration('csv', this.document.uri); const addSerialIndex = CsvEditorProvider.getSerialIndexForUri(this.context, this.document.uri); const separator = this.getSeparator(); + this.renderedSeparator = separator; const hiddenRows = this.getHiddenRows(); let parsed; @@ -2049,6 +2124,9 @@ class CsvEditorController { } private getSeparator(): string { + if (this.pendingDocumentReference) { + return this.pendingDocumentReference.separator; + } const stored = CsvEditorProvider.getSeparatorForUri(this.context, this.document.uri); if (stored && stored.length) return stored; @@ -2063,8 +2141,8 @@ class CsvEditorController { return this.separatorCache.separator; } - const filePath = this.document.uri.scheme === 'untitled' && this.document.languageId === 'tsv' - ? 'untitled.tsv' + 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); diff --git a/src/commands.ts b/src/commands.ts index 4789a23..ff0a80a 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'; function displaySeparator(separator: string): string { return separator === '\t' ? '\\t' : separator; @@ -23,6 +24,7 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref export function registerCsvCommands(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.commands.registerCommand('csv.toggleEditor', toggleCsvEditor), + vscode.commands.registerCommand('csv.saveUntitled', saveActiveUntitledCsvEditor), vscode.commands.registerCommand('csv.toggleExtension', () => toggleBooleanConfig('enabled', true, 'CSV extension') ), diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index c061385..96fd709 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -26,8 +26,14 @@ const vscodeStub = { return { document }; } }, + commands: { + executeCommand: async (command: string) => { + activeHarness.nativeSaveCommands.push(command); + return activeHarness.document.save(); + } + }, workspace: { - getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), + getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : key === 'enabled' ? activeHarness?.enabled ?? true : fallback }), applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), onDidChangeTextDocument: (listener: (event: unknown) => void) => { activeHarness.changeListeners.push(listener); @@ -56,6 +62,10 @@ export async function createEditorHarness(options: { 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 }); @@ -69,9 +79,10 @@ export async function createEditorHarness(options: { const messages: any[] = []; const delayedEdits: Array<() => void> = []; let deferEdits = false; - const uri = new TestUri(`/test/data.${separator === '\t' ? 'tsv' : 'csv'}`, options.untitled ? 'untitled' : 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, @@ -94,6 +105,9 @@ 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 = { active: true, visible: true, @@ -118,8 +132,26 @@ export async function createEditorHarness(options: { }; const harness = { document, panel, messages, changeListeners, saveListeners, + enabled: true, sourceReveals: [] as string[], - revealSourceAfterCommit() { controller.revealSourceAfterCommit(); }, + nativeSaveCommands: [] as string[], + get controllerCount() { return CsvEditorProvider.editors.length; }, + get activeController() { return CsvEditorProvider.currentActive; }, + saveUntitledAfterCommit() { controller.saveUntitledAfterCommit(); }, + requestViewCommit() { controller.requestViewCommit(); }, + 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; }, deferEdits() { deferEdits = true; }, @@ -149,7 +181,14 @@ 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/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 index 3142a88..c407523 100644 --- a/src/test/untitled-editors.test.ts +++ b/src/test/untitled-editors.test.ts @@ -27,6 +27,7 @@ const state = { subscriptions: [] as any[], tokens: [] as any[], failResolve: false, + saveRequests: 0, commands: new Map any>(), information: [] as string[] }; @@ -42,6 +43,7 @@ class FakeProvider { document, getDocumentUri: () => controller.document.uri, isActive: () => panel.active, + saveUntitledAfterCommit: () => { state.saveRequests += 1; }, updateDocumentReference: (replacement: any) => { controller.document = replacement; }, revealSourceAfterCommit: async () => { await vscode.window.showTextDocument(controller.document, { viewColumn: panel.viewColumn }); @@ -154,6 +156,7 @@ beforeEach(() => { state.subscriptions = []; state.tokens = []; state.failResolve = false; + state.saveRequests = 0; state.commands.clear(); state.information = []; FakeProvider.editors = []; @@ -277,6 +280,17 @@ describe('untitled CSV document ownership and routing', () => { 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(); diff --git a/src/untitled-editors.ts b/src/untitled-editors.ts index 5ecda40..3571e1a 100644 --- a/src/untitled-editors.ts +++ b/src/untitled-editors.ts @@ -19,6 +19,15 @@ 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 openUntitledCsvEditor(document: vscode.TextDocument, viewColumn?: vscode.ViewColumn): Promise { if (!extensionContext || document.isClosed || !isUntitledCsv(document) || !vscode.workspace.getConfiguration('csv', document.uri).get('enabled', true)) { @@ -94,11 +103,16 @@ export function registerUntitledCsvEditors(context: vscode.ExtensionContext): vo setTimeout(() => { const replacement = vscode.workspace.textDocuments.find(candidate => candidate.uri.toString() === key && !candidate.isClosed); const editor = editors.get(key); - if (replacement && isUntitledCsv(replacement)) { + if (replacement) { if (editor) { editor.document = replacement; CsvEditorProvider.editors.filter(controller => controller.getDocumentUri().toString() === key) - .forEach(controller => controller.updateDocumentReference(replacement)); + .forEach(controller => { + controller.updateDocumentReference(replacement); + if (!isUntitledCsv(replacement)) { + controller.revealSourceAfterCommit('language'); + } + }); } return; } @@ -128,6 +142,7 @@ export function registerUntitledCsvEditors(context: vscode.ExtensionContext): vo return; } opened.clear(); + editors.forEach((_editor, key) => opened.add(key)); maybeOpen(vscode.window.activeTextEditor?.document); }), { dispose: () => { From 3a4b2636554a64be928ab3ce0b46ce4697c0b0b9 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:04:34 -0400 Subject: [PATCH 4/8] Commit untitled drafts before switching to text --- README.md | 2 +- scripts/untitled-editor-smoke.cjs | 7 +- src/CsvEditorProvider.ts | 81 +++++++++++-- src/editor-switch.ts | 4 +- src/test/edit-integrity-harness.ts | 2 + src/test/untitled-editors.test.ts | 61 +++++++++- src/test/untitled-switch-commit.test.ts | 145 ++++++++++++++++++++++++ src/untitled-editors.ts | 35 ++++++ 8 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 src/test/untitled-switch-commit.test.ts diff --git a/README.md b/README.md index 4bd3213..2378c16 100644 --- a/README.md +++ b/README.md @@ -36,7 +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. The native source remains open and shares unsaved edits. 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. +- **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/scripts/untitled-editor-smoke.cjs b/scripts/untitled-editor-smoke.cjs index 061323a..8fc0e75 100644 --- a/scripts/untitled-editor-smoke.cjs +++ b/scripts/untitled-editor-smoke.cjs @@ -59,13 +59,18 @@ exports.run = async function () { 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 return to existing untitled grid'); + 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')); diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index cdd5dbb..7cf1386 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -61,6 +61,7 @@ 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; @@ -71,6 +72,8 @@ class CsvEditorController { private viewCommitSequence = 0; private pendingClose = false; private pendingOperation: Promise = Promise.resolve(); + private readonly failedCellEdits = new Set(); + private readonly draftCommitWaiters = new Set<{ resolve: (success: boolean) => void; timer: ReturnType }>(); private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; private separatorCache: { version: number; configKey: string; separator: string } | undefined; @@ -242,6 +245,7 @@ class CsvEditorController { this.pendingViewAction = undefined; this.pendingDocumentReference = undefined; this.viewCommitRequested = false; + this.resolveDraftCommitWaiters(false); if (CsvEditorProvider.currentActive === this) { CsvEditorProvider.currentActive = undefined; } @@ -366,12 +370,46 @@ class CsvEditorController { vscode.commands.executeCommand('vscode.openWith', this.document.uri, 'default'); } } else { - if (this.currentWebviewPanel && !this.viewCommitRequested) { + 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) { @@ -408,9 +446,20 @@ class CsvEditorController { } private postViewCommitRequest(): void { - void this.currentWebviewPanel?.webview.postMessage({ - type: 'prepareViewRefresh', requestId: this.viewCommitSequence - }); + 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 { @@ -431,6 +480,12 @@ class CsvEditorController { 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; @@ -441,13 +496,14 @@ class CsvEditorController { this.pendingClose = false; if (action) { await action(); - } else { + } else if (!hadWaiters) { 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 = ''; @@ -555,6 +611,7 @@ class CsvEditorController { this.isUpdatingDocument = true; let structuralChange = false; let applied = false; + const cellKey = `${row}:${col}`; try { const separator = this.getSeparator(); const oldText = document.getText(); @@ -571,6 +628,7 @@ class CsvEditorController { structuralChange = !!(trimmed || createdRow || createdCol || row >= hadRows || col >= hadColsAtRow); if (!structuralChange && previousValue === value) { + this.failedCellEdits.delete(cellKey); return; } @@ -587,6 +645,7 @@ class CsvEditorController { } if (newCsvText === oldText) { + this.failedCellEdits.delete(cellKey); return; } @@ -598,6 +657,14 @@ class CsvEditorController { const edit = new vscode.WorkspaceEdit(); edit.replace(document.uri, fullRange, newCsvText); applied = await vscode.workspace.applyEdit(edit); + if (applied) { + this.failedCellEdits.delete(cellKey); + } else { + this.failedCellEdits.add(cellKey); + } + } catch (error) { + this.failedCellEdits.add(cellKey); + throw error; } finally { this.isUpdatingDocument = false; } @@ -1519,7 +1586,7 @@ class CsvEditorController { // ───────────── Webview Rendering ───────────── private updateWebviewContent() { - if (!this.currentWebviewPanel || this.viewCommitRequested || this.pendingDocumentReference) return; + if (!this.currentWebviewPanel || this.viewCommitRequested || this.pendingDocumentReference || this.failedCellEdits.size) return; const webview = this.currentWebviewPanel.webview; const config = vscode.workspace.getConfiguration('csv', this.document.uri); diff --git a/src/editor-switch.ts b/src/editor-switch.ts index 8300577..3bf3d92 100644 --- a/src/editor-switch.ts +++ b/src/editor-switch.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; -import { getActiveUntitledCsvEditor, isUntitledCsv, openUntitledCsvEditor } from './untitled-editors'; +import { getActiveUntitledCsvEditor, isUntitledCsv, openUntitledCsvEditor, showActiveUntitledCsvSource } from './untitled-editors'; const switchingTabs = new Set(); const supportedExtensions = new Set(['.csv', '.tsv', '.tab', '.psv']); @@ -16,7 +16,7 @@ function getInputUri(input: unknown, viewType: string): vscode.Uri | undefined { export async function toggleCsvEditor(): Promise { const untitled = getActiveUntitledCsvEditor(); if (untitled) { - await vscode.window.showTextDocument(untitled.document, { viewColumn: untitled.panel.viewColumn, preview: false }); + await showActiveUntitledCsvSource(); return; } const activeDocument = vscode.window.activeTextEditor?.document; diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 96fd709..aa7831e 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -110,6 +110,7 @@ export async function createEditorHarness(options: { } const panel = { active: true, + viewColumn: 2, visible: true, title: 'data.csv', webview: { @@ -139,6 +140,7 @@ export async function createEditorHarness(options: { 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; }, diff --git a/src/test/untitled-editors.test.ts b/src/test/untitled-editors.test.ts index c407523..14b25b1 100644 --- a/src/test/untitled-editors.test.ts +++ b/src/test/untitled-editors.test.ts @@ -27,6 +27,9 @@ const state = { 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[] @@ -43,6 +46,8 @@ class FakeProvider { 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 () => { @@ -156,6 +161,9 @@ beforeEach(() => { state.subscriptions = []; state.tokens = []; state.failResolve = false; + state.commitResult = true; + state.commitWait = undefined; + state.commitRequests = 0; state.saveRequests = 0; state.commands.clear(); state.information = []; @@ -199,19 +207,60 @@ describe('untitled CSV document ownership and routing', () => { assert.strictEqual(state.resolutions[0].document, original); }); - it('does not steal focus after an explicit switch to source and reuses its table on return', async () => { + 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.activeTextEditor.document, original); + 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(); - assert.strictEqual(panel.active, false); + state.commitResult = false; await toggleCsvEditor(); - assert.strictEqual(panel.active, true); - assert.strictEqual(state.panels.length, 1); - assert.strictEqual(state.resolutions.length, 1); + 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 () => { diff --git a/src/test/untitled-switch-commit.test.ts b/src/test/untitled-switch-commit.test.ts new file mode 100644 index 0000000..9b634ab --- /dev/null +++ b/src/test/untitled-switch-commit.test.ts @@ -0,0 +1,145 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } 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))); +} + +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(); } + }); +}); diff --git a/src/untitled-editors.ts b/src/untitled-editors.ts index 3571e1a..ff94510 100644 --- a/src/untitled-editors.ts +++ b/src/untitled-editors.ts @@ -9,6 +9,7 @@ type UntitledEditor = { }; const editors = new Map(); const opened = new Set(); +const switchingToSource = new Set(); let extensionContext: vscode.ExtensionContext | undefined; export function isUntitledCsv(document: vscode.TextDocument): boolean { @@ -28,6 +29,39 @@ export function saveActiveUntitledCsvEditor(): void { ?.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)) { @@ -149,6 +183,7 @@ export function registerUntitledCsvEditors(context: vscode.ExtensionContext): vo editors.forEach(editor => editor.panel.dispose()); editors.clear(); opened.clear(); + switchingToSource.clear(); extensionContext = undefined; } } ); From da26604d3d5dfad88c15b4a19d89a988d878e999 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:08:28 -0400 Subject: [PATCH 5/8] Preserve rebind recovery through draft commit waiters --- src/CsvEditorProvider.ts | 1 + src/test/untitled-switch-commit.test.ts | 38 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 61eb93c..c6555bd 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -607,6 +607,7 @@ class CsvEditorController { document, separator: this.pendingDocumentReference?.separator ?? this.renderedSeparator ?? this.getSeparator() }; + this.refreshAfterDraftCommit = true; this.requestViewCommit(); } } diff --git a/src/test/untitled-switch-commit.test.ts b/src/test/untitled-switch-commit.test.ts index 9b634ab..eabca5d 100644 --- a/src/test/untitled-switch-commit.test.ts +++ b/src/test/untitled-switch-commit.test.ts @@ -142,4 +142,42 @@ describe('awaitable untitled draft commit', () => { 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(); } + }); }); From e612bda16bf70b0bcf4dadfa1b2c85029a3ed47c Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:24:12 -0400 Subject: [PATCH 6/8] Make native Save As cancellation smoke deterministic --- scripts/untitled-editor-smoke.cjs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/scripts/untitled-editor-smoke.cjs b/scripts/untitled-editor-smoke.cjs index 8fc0e75..e86888b 100644 --- a/scripts/untitled-editor-smoke.cjs +++ b/scripts/untitled-editor-smoke.cjs @@ -85,10 +85,25 @@ exports.run = async function () { const live = await prepare(document, 'csv', 'name,value\ncancel,42'); const panel = getActiveUntitledCsvEditor().panel; const controller = CsvEditorProvider.getActiveProvider(); - 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(() => {}); - // VS Code's isolated extension-test host cancels the native Save As picker. + // 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'); @@ -97,7 +112,7 @@ exports.run = async function () { 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, cancelled Save As retained'); + 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 }); } From f51d17bb3161d9c6981417ff4c1d8f9978577618 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:29:32 -0400 Subject: [PATCH 7/8] Route untitled switches from the active editor group --- src/editor-switch.ts | 6 ++- src/test/untitled-editors.test.ts | 4 ++ src/test/untitled-toggle-routing.test.ts | 54 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/test/untitled-toggle-routing.test.ts diff --git a/src/editor-switch.ts b/src/editor-switch.ts index 296705a..8cafc55 100644 --- a/src/editor-switch.ts +++ b/src/editor-switch.ts @@ -34,8 +34,10 @@ export async function toggleCsvEditor(): Promise { return; } const activeDocument = vscode.window.activeTextEditor?.document; - if (activeDocument && isUntitledCsv(activeDocument)) { - await openUntitledCsvEditor(activeDocument, vscode.window.activeTextEditor?.viewColumn); + const activeInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + if (activeDocument && isUntitledCsv(activeDocument) && activeInput instanceof vscode.TabInputText && + activeInput.uri.toString() === activeDocument.uri.toString()) { + await openUntitledCsvEditor(activeDocument, vscode.window.tabGroups.activeTabGroup.viewColumn); return; } const group = vscode.window.tabGroups.activeTabGroup; diff --git a/src/test/untitled-editors.test.ts b/src/test/untitled-editors.test.ts index 14b25b1..1d1a9ec 100644 --- a/src/test/untitled-editors.test.ts +++ b/src/test/untitled-editors.test.ts @@ -34,6 +34,7 @@ const state = { commands: new Map any>(), information: [] as string[] }; +class TabInputText { constructor(public uri: any) {} } class FakeProvider { static viewType = 'csv.editor'; static editors: any[] = []; @@ -88,6 +89,7 @@ function createPanel(viewType: string, title: string, viewColumn: number) { return panel; } const vscode = { + TabInputText, ViewColumn: { Active: -1 }, CancellationTokenSource: class { token = { isCancellationRequested: false }; @@ -97,6 +99,8 @@ const vscode = { 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; }, onDidChangeActiveTextEditor: activeEvent.listen, createWebviewPanel: createPanel, diff --git a/src/test/untitled-toggle-routing.test.ts b/src/test/untitled-toggle-routing.test.ts new file mode 100644 index 0000000..66fbbd1 --- /dev/null +++ b/src/test/untitled-toggle-routing.test.ts @@ -0,0 +1,54 @@ +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 }, + 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 }]); + }); +}); From a19ec24771de0f6227795b522f27d852b920be7e Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:31:26 -0400 Subject: [PATCH 8/8] Find the untitled owner in the active editor group --- src/editor-switch.ts | 14 ++++++++------ src/test/untitled-editors.test.ts | 1 + src/test/untitled-toggle-routing.test.ts | 8 ++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/editor-switch.ts b/src/editor-switch.ts index 49c1e59..fc9cc21 100644 --- a/src/editor-switch.ts +++ b/src/editor-switch.ts @@ -33,14 +33,16 @@ export async function toggleCsvEditor(): Promise { await showActiveUntitledCsvSource(); return; } - const activeDocument = vscode.window.activeTextEditor?.document; - const activeInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; - if (activeDocument && isUntitledCsv(activeDocument) && activeInput instanceof vscode.TabInputText && - activeInput.uri.toString() === activeDocument.uri.toString()) { - await openUntitledCsvEditor(activeDocument, vscode.window.tabGroups.activeTabGroup.viewColumn); + 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 group = vscode.window.tabGroups.activeTabGroup; const tab = group.activeTab; if (!tab) { return; diff --git a/src/test/untitled-editors.test.ts b/src/test/untitled-editors.test.ts index 1d1a9ec..5a4ae3c 100644 --- a/src/test/untitled-editors.test.ts +++ b/src/test/untitled-editors.test.ts @@ -102,6 +102,7 @@ const vscode = { 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) => { diff --git a/src/test/untitled-toggle-routing.test.ts b/src/test/untitled-toggle-routing.test.ts index 66fbbd1..591cba0 100644 --- a/src/test/untitled-toggle-routing.test.ts +++ b/src/test/untitled-toggle-routing.test.ts @@ -14,6 +14,7 @@ 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 () => [] } @@ -51,4 +52,11 @@ describe('untitled switch routing across editor groups', () => { 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 }]); + }); });