diff --git a/README.md b/README.md index fd300ce..a36f0cb 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Open the Command Palette and search for: Global (Settings UI or `settings.json`): -- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor. +- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor. The existing Toggle Extension command honors an explicit workspace value, otherwise it changes the global value. While off, CSV temporarily routes its four file selectors and existing explicit CSV-editor associations to the native text editor, so Find in Files preserves the destination and selection. Re-enabling restores the previous associations. Other editors, unrelated associations, and intervening user changes are preserved. Routing recovery metadata is stored in VS Code extension state; it contains editor identifiers and patterns, never CSV data. If settings or policy prevent routing, CSV reports the failure instead of claiming the toggle succeeded. - `csv.fontFamily` (string, default empty): Override font family; falls back to `editor.fontFamily`. - `csv.fontSize` (number, default `0`): Override font size in px; set to `0` to inherit `editor.fontSize`. - `csv.mouseWheelZoom` (boolean, default `true`): Enable `Ctrl/Cmd + Mouse Wheel` zooming in the CSV editor. diff --git a/package.json b/package.json index 092489b..0566c8c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "Other" ], "activationEvents": [ + "onStartupFinished", "onLanguage:csv", "onLanguage:tsv", "onCommand:csv.toggleExtension", diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 2f1babf..c0639eb 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -65,6 +65,8 @@ class CsvEditorController { private isUpdatingDocument = false; private isSaving = false; private pendingOperation: Promise = Promise.resolve(); + private isDisposed = false; + private defaultEditorHandoff: Promise | undefined; private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; private separatorCache: { version: number; configKey: string; separator: string } | undefined; @@ -81,6 +83,24 @@ class CsvEditorController { _token: vscode.CancellationToken ): Promise { this.document = document; + this.currentWebviewPanel = webviewPanel; + let changeDocumentSubscription: vscode.Disposable | undefined; + let saveDocumentSubscription: vscode.Disposable | undefined; + // Remote file checks can outlive the preview tab that requested them. + // Observe disposal before awaiting anything so a closed panel stays closed. + webviewPanel.onDidDispose(() => { + this.isDisposed = true; + changeDocumentSubscription?.dispose(); + saveDocumentSubscription?.dispose(); + CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); + if (CsvEditorProvider.currentActive === this) { + CsvEditorProvider.currentActive = undefined; + } + this.currentWebviewPanel = undefined; + }); + if (_token.isCancellationRequested) { + return; + } const config = vscode.workspace.getConfiguration('csv', this.document.uri); if (!config.get('enabled', true)) { @@ -90,17 +110,20 @@ class CsvEditorController { } const proceed = await this.confirmLargeFileOpen(config, webviewPanel, _token); - if (!proceed) { + if (!proceed || this.isDisposed || _token.isCancellationRequested) { + return; + } + // csv.enabled may have changed while a remote stat or the size prompt was pending. + if (!vscode.workspace.getConfiguration('csv', document.uri).get('enabled', true)) { + await this.openWithDefaultEditorAndClose(webviewPanel, document.uri); return; } - this.currentWebviewPanel = webviewPanel; CsvEditorProvider.editors.push(this); webviewPanel.webview.options = { enableScripts: true, - // Use file path for compatibility with older VS Code types (no Uri.joinPath) - localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'media'))] + localResourceRoots: [this.getMediaUri()] }; this.refreshDiffContext(webviewPanel); @@ -124,6 +147,8 @@ class CsvEditorController { } e.webviewPanel.webview.postMessage({ type: 'focus' }); CsvEditorProvider.currentActive = this; + } else if (CsvEditorProvider.currentActive === this) { + CsvEditorProvider.currentActive = undefined; } }); @@ -190,7 +215,7 @@ class CsvEditorController { } })); - const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { + changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { if ( e.document.uri.toString() === document.uri.toString() && !this.isUpdatingDocument && @@ -200,7 +225,7 @@ class CsvEditorController { } }); - const saveDocumentSubscription = vscode.workspace.onWillSaveTextDocument(e => { + saveDocumentSubscription = vscode.workspace.onWillSaveTextDocument(e => { if (e.document.uri.toString() === document.uri.toString() && !this.isSaving) { // Native File > Save can run while the webview's blur edit is applying. // The custom save command is already in this queue and must not await itself. @@ -208,12 +233,6 @@ class CsvEditorController { } }); - webviewPanel.onDidDispose(() => { - changeDocumentSubscription.dispose(); - saveDocumentSubscription.dispose(); - CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); - this.currentWebviewPanel = undefined; - }); } private enqueueOperation(operation: () => void | Promise): Promise { @@ -252,16 +271,30 @@ class CsvEditorController { } private async openWithDefaultEditorAndClose(webviewPanel: vscode.WebviewPanel, uri: vscode.Uri): Promise { - try { - const opts: any = { - viewColumn: webviewPanel.viewColumn, - preserveFocus: !webviewPanel.active, - preview: webviewPanel.active ? webviewPanel.active : false - }; - await vscode.commands.executeCommand('vscode.openWith', uri, 'default', opts); - } finally { - try { webviewPanel.dispose(); } catch {} + if (this.isDisposed) { + return; } + if (!this.defaultEditorHandoff) { + this.defaultEditorHandoff = (async () => { + try { + // Search can already have revealed the text editor. Opening it again + // loses its destination, so leave an existing editor in this group alone. + const existing = vscode.window.visibleTextEditors.find(editor => + editor.document.uri.toString() === uri.toString() && + editor.viewColumn === webviewPanel.viewColumn + ); + if (!existing) { + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { + viewColumn: webviewPanel.viewColumn, + preserveFocus: !webviewPanel.active + }); + } + } finally { + try { webviewPanel.dispose(); } catch {} + } + })(); + } + await this.defaultEditorHandoff; } private async confirmLargeFileOpen( @@ -319,8 +352,12 @@ 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 (panel) { + void this.openWithDefaultEditorAndClose(panel, this.document.uri).catch(error => { + console.error('CSV: failed to reopen the text editor', error); + }); + } } else { if (this.currentWebviewPanel) { this.forceReload(); @@ -1690,6 +1727,12 @@ class CsvEditorController { return !matches; } + private getMediaUri(...segments: string[]): vscode.Uri { + return this.context.extensionUri.with({ + path: path.posix.join(this.context.extensionUri.path, 'media', ...segments) + }); + } + private wrapHtml(args: { webview: vscode.Webview; nonce: string; @@ -1707,9 +1750,8 @@ class CsvEditorController { }): string { const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert } = args; const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; - // Build script URI using file path for compatibility (older APIs may lack Uri.joinPath) const scriptUri = webview.asWebviewUri( - vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'main.js')) + this.getMediaUri('main.js') ); // Safe separator transport (assumes single character; see assumptions) @@ -2539,17 +2581,12 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { ): Promise { console.log(`CSV(reg): creating controller for ${document.uri.toString()}`); const controller = new CsvEditorController(this.context); - // Track active controller - webviewPanel.onDidChangeViewState(e => { - if (e.webviewPanel.active) { - CsvEditorProvider.currentActive = controller; - } - }); await controller.resolveCustomTextEditor(document, webviewPanel, _token); } public static getActiveProvider(): CsvEditorController | undefined { - return CsvEditorProvider.currentActive || CsvEditorProvider.editors.find(ed => ed.isActive()); + const current = CsvEditorProvider.currentActive; + return current?.isActive() ? current : CsvEditorProvider.editors.find(ed => ed.isActive()); } public static getHiddenRowsForUri(context: vscode.ExtensionContext, uri: vscode.Uri): number { diff --git a/src/commands.ts b/src/commands.ts index 6241b3d..912889a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,11 +19,38 @@ async function toggleBooleanConfig(key: string, defaultVal: boolean, messagePref CsvEditorProvider.editors.forEach(ed => ed.refresh()); } -export function registerCsvCommands(context: vscode.ExtensionContext) { +async function toggleCsvEnabled(synchronizeRouting: () => Promise): Promise { + const config = vscode.workspace.getConfiguration('csv'); + const inspected = config.inspect('enabled'); + const target = inspected?.workspaceValue !== undefined ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global; + const previous = target === vscode.ConfigurationTarget.Workspace ? inspected?.workspaceValue : inspected?.globalValue; + const next = !config.get('enabled', true); + await config.update('enabled', next, target); + try { + await synchronizeRouting(); + } catch (error) { + // Do not report Off while VS Code still routes searches into the custom view. + const current = config.inspect('enabled'); + const value = target === vscode.ConfigurationTarget.Workspace ? current?.workspaceValue : current?.globalValue; + if (value === next) { + await config.update('enabled', previous, target); + try { await synchronizeRouting(); } catch (restoreError) { console.error('CSV: routing rollback failed', restoreError); } + } + console.error('CSV: extension toggle was not applied', error); + return; + } + vscode.window.showInformationMessage(`CSV extension ${next ? 'enabled' : 'disabled'}.`); + CsvEditorProvider.editors.slice().forEach(ed => ed.refresh()); +} + +export function registerCsvCommands(context: vscode.ExtensionContext, synchronizeRouting: () => Promise = async () => {}) { + let pendingToggle: Promise = Promise.resolve(); context.subscriptions.push( - vscode.commands.registerCommand('csv.toggleExtension', () => - toggleBooleanConfig('enabled', true, 'CSV extension') - ), + vscode.commands.registerCommand('csv.toggleExtension', () => { + const next = pendingToggle.catch(() => {}).then(() => toggleCsvEnabled(synchronizeRouting)); + pendingToggle = next; + return next; + }), vscode.commands.registerCommand('csv.toggleClickableLinks', () => toggleBooleanConfig('clickableLinks', true, 'CSV clickable links') ), diff --git a/src/editor-associations.ts b/src/editor-associations.ts new file mode 100644 index 0000000..c98ed74 --- /dev/null +++ b/src/editor-associations.ts @@ -0,0 +1,390 @@ +import type * as vscode from 'vscode'; +import { randomBytes } from 'crypto'; +import { performance } from 'perf_hooks'; + +const journalPrefix = 'csv.nativeEditorRouting.'; +type Journal = { created: number; state: Ownership }; +export const csvEditorSelectors = ['*.csv', '*.tsv', '*.tab', '*.psv'] as const; +const csvEditor = 'csv.editor'; +type Associations = Record; +type OwnedEntry = { previous?: string; hadPrevious: boolean; applied: string; textView?: boolean; owner?: string }; +type Ownership = { version: 1; entries: Record; ignored: string[]; released: Record; containerAbsent: boolean }; +type Scope = 'global' | 'workspace'; + +const has = (object: object, key: string) => Object.prototype.hasOwnProperty.call(object, key); +const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const put = (object: Record, key: string, value: T): Record => ({ ...object, [key]: value }); +function dictionary(value: unknown): Associations { + if (value === undefined) { return {}; } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('workbench.editorAssociations must be an object before CSV can change its routing.'); + } + return { ...value as Associations }; +} +function ownership(raw: unknown): Ownership { + const empty: Ownership = { version: 1, entries: {}, ignored: [], released: {}, containerAbsent: false }; + if (!raw || typeof raw !== 'object') { return empty; } + const value = raw as Ownership; + if (value.version !== 1 || !value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries) || !Array.isArray(value.ignored)) { return empty; } + const entries: Record = {}; + for (const [pattern, entry] of Object.entries(value.entries)) { + if (!entry || typeof entry !== 'object' || typeof entry.hadPrevious !== 'boolean' || + (entry.previous !== undefined && typeof entry.previous !== 'string') || + (entry.applied !== 'default' && entry.applied !== csvEditor)) { continue; } + Object.defineProperty(entries, pattern, { value: { ...entry }, enumerable: true, configurable: true, writable: true }); + } + const released = Object.fromEntries(Object.entries(value.released || {}).filter(([, token]) => typeof token === 'string')); + return { version: 1, entries, ignored: value.ignored.filter(pattern => typeof pattern === 'string'), released, containerAbsent: value.containerAbsent === true }; +} +function recovery(storage: vscode.Memento): { state: Ownership; journals: Array<{ key: string; value: Journal }> } { + const journals = storage.keys().filter(key => key.startsWith(journalPrefix)).map(key => ({ key, value: storage.get(key)! })) + .filter(({ value }) => value && typeof value.created === 'number' && value.state?.version === 1) + .sort((a, b) => b.value.created - a.value.created || b.key.localeCompare(a.key)); + const state = ownership(undefined); + state.containerAbsent = journals[0]?.value.state.containerAbsent ?? false; + const decided = new Set(); + for (const journal of journals) { + const stored = ownership(journal.value.state); + for (const [pattern, entry] of Object.entries(stored.entries)) { + if (!decided.has(pattern)) { + Object.defineProperty(state.entries, pattern, { value: entry, enumerable: true, configurable: true, writable: true }); + decided.add(pattern); + } + } + for (const pattern of stored.ignored) { + if (!decided.has(pattern)) { state.ignored.push(pattern); decided.add(pattern); } + } + for (const [pattern, token] of Object.entries(stored.released)) { + if (!decided.has(pattern)) { state.released[pattern] = token; decided.add(pattern); } + } + } + return { state, journals }; +} +function observe(current: Associations, stored: Ownership): { original: Associations; state: Ownership } { + let original = { ...current }, entries = { ...stored.entries }; + const ignored = new Set(stored.ignored); + let containerAbsent = stored.containerAbsent; + for (const [pattern, entry] of Object.entries(entries)) { + if (current[pattern] !== entry.applied) { + // An intervening user edit owns this key for the rest of this disabled period. + delete entries[pattern]; + ignored.add(pattern); + containerAbsent = false; + continue; + } + if (entry.hadPrevious) { original = put(original, pattern, entry.previous); } + else { delete original[pattern]; } + } + return { original, state: { ...stored, entries, ignored: [...ignored], containerAbsent } }; +} +function desiredNative(original: Associations): Map { + const external = Object.entries(original).filter(([, editor]) => typeof editor === 'string' && editor !== csvEditor); + // These patterns cover every CSV-like basename. Their explicit editor choice + // already outranks the contributed editor, so adding *.csv would change it. + const universal = new Set(['*', '**', '**/*', '*.*', '**/*.*']); + const hasUniversal = external.some(([pattern]) => universal.has(pattern)); + const patterns = new Set(Object.keys(original).filter(pattern => original[pattern] === csvEditor)); + for (const selector of csvEditorSelectors) { + if (has(original, selector) || hasUniversal) { continue; } + const extension = selector.slice(1); + const overlaps = external.filter(([pattern]) => { + if (pattern.length > selector.length || !/[*?{[]/.test(pattern)) { return false; } + const suffix = pattern.split(/[*?}\]]/).pop() || ''; + return !suffix || extension.endsWith(suffix); + }); + if (overlaps.length) { + throw new Error(`CSV preserved overlapping editor associations (${overlaps.map(([pattern]) => pattern).join(', ')}). Add an explicit ${selector} choice before retrying the toggle.`); + } + patterns.add(selector); + } + return new Map([...patterns].map(pattern => [pattern, 'default'])); +} +function plan(current: Associations, absent: boolean, stored: Ownership, desired: Map, enabled: boolean) { + let next = { ...current }, entries = { ...stored.entries }; + const ignored = enabled ? new Set() : new Set(stored.ignored); + const released = { ...stored.released }; + if (enabled) { for (const pattern of stored.ignored) { released[pattern] = ''; } } + const beforeWrite = { ...stored, entries: { ...entries }, ignored: [...ignored], released: { ...released } }; + if (!Object.keys(entries).length) { beforeWrite.containerAbsent = absent; } + for (const [pattern, entry] of Object.entries(entries)) { + if (desired.get(pattern) === entry.applied) { continue; } + if (current[pattern] === entry.applied) { + if (entry.hadPrevious) { next = put(next, pattern, entry.previous); } + else { delete next[pattern]; } + } + delete entries[pattern]; + released[pattern] = ''; + } + for (const [pattern, value] of desired) { + if (ignored.has(pattern) || next[pattern] === value) { continue; } + // Existing associations to another editor belong to that editor/user. + if (has(next, pattern) && next[pattern] !== csvEditor && !stored.entries[pattern]) { continue; } + const previous = next[pattern]; + const entry: OwnedEntry = { hadPrevious: has(next, pattern), previous: typeof previous === 'string' ? previous : undefined, applied: value }; + entries = put(entries, pattern, entry); + delete released[pattern]; + delete beforeWrite.released[pattern]; + beforeWrite.entries = put(beforeWrite.entries, pattern, entry); + next = put(next, pattern, value); + } + const conflicts = [...desired].filter(([pattern, value]) => ignored.has(pattern) && current[pattern] !== value); + if (conflicts.length) { + throw new Error(`CSV preserved your changed editor associations (${conflicts.map(([pattern]) => pattern).join(', ')}). Remove or change those CSV editor choices before retrying the toggle.`); + } + const finalState: Ownership = { ...beforeWrite, entries, ignored: [...ignored], released }; + const removeContainer = !Object.keys(next).length && beforeWrite.containerAbsent; + return { next, value: removeContainer ? undefined : next, beforeWrite, finalState }; +} + +export type EditorSwitchRouting = { supported: boolean; limitation?: string; commit(): Promise; rollback(): Promise }; +export function exactFileEditorPattern(uri: vscode.Uri): string | undefined { + // The workbench matches scheme:path, case-insensitively, and omits authority. + // Do not turn a remote/virtual URI into a wider preference for other hosts. + if (uri.scheme !== 'file' || uri.authority || uri.query || uri.fragment) { return undefined; } + return `${uri.scheme}:${uri.path}`.replace(/[\[\]*?{}]/g, character => `[${character}]`); +} + +/** Reversible native routing uses the existing toggle; no document text is stored. */ +export class CsvEditorAssociationRouting implements vscode.Disposable { + private pending: Promise = Promise.resolve(); + private disposed = false; + constructor(private readonly context: vscode.ExtensionContext, private readonly api: typeof vscode) {} + dispose(): void { this.disposed = true; } + synchronize(): Promise { + const next = this.pending.catch(() => {}).then(() => this.synchronizeOnce()); + this.pending = next; + return next; + } + prepareEditorSwitch(uri: vscode.Uri, target: 'default' | 'csv.editor'): Promise { + if (target === csvEditor) { + // Keep Text routing until its replacement table is genuinely usable. + return Promise.resolve({ supported: true, rollback: async () => {}, commit: () => { + const commit = this.pending.catch(() => {}).then(async () => { + // A prior no-workspace choice can coexist with a workspace override. + for (let scope = 0; scope < 2; scope++) { await this.prepareEditorSwitchOnce(uri, csvEditor); } + }); + this.pending = commit; + return commit; + } }); + } + const next = this.pending.catch(() => {}).then(() => this.prepareEditorSwitchOnce(uri, target)); + this.pending = next.then(() => {}, () => {}); + return next; + } + private async prepareEditorSwitchOnce(uri: vscode.Uri, target: 'default' | 'csv.editor'): Promise { + const pattern = exactFileEditorPattern(uri); + const unsupported = (limitation: string): EditorSwitchRouting => ({ supported: false, limitation, commit: async () => {}, rollback: async () => {} }); + if (!pattern) { + return unsupported('CSV: This view can switch, but VS Code cannot isolate this URI in editor associations. Turn CSV off in this workspace before using Find in Files in its text view.'); + } + if (this.disposed) { throw new Error('CSV: Editor routing is no longer active.'); } + const hasWorkspace = !!this.api.workspace.workspaceFile || !!this.api.workspace.workspaceFolders?.length; + let scope: Scope = hasWorkspace ? 'workspace' : 'global'; + const setting = this.api.workspace.getConfiguration('workbench').inspect('editorAssociations'); + if (target === 'csv.editor' && !recovery(this.context.workspaceState).state.entries[pattern]?.textView && + recovery(this.context.globalState).state.entries[pattern]?.textView) { scope = 'global'; } + const storage = scope === 'global' ? this.context.globalState : this.context.workspaceState; + const raw = scope === 'global' ? setting?.globalValue : setting?.workspaceValue; + const current = dictionary(raw); + const stored = recovery(storage).state; + const observed = observe(current, stored); + if (!equal(stored, observed.state)) { await this.recordObservedOwnership(scope, current, raw === undefined, observed.state); } + const previousEntry = observed.state.entries[pattern] && { ...observed.state.entries[pattern] }; + const hadPrevious = has(current, pattern), previousValue = current[pattern]; + const token = randomBytes(12).toString('hex'); + const desired = new Map(Object.entries(observed.state.entries).map(([key, entry]) => [key, entry.applied])); + if (target === 'default') { + const inherited = { ...dictionary(setting?.defaultValue), ...(scope === 'workspace' ? dictionary(setting?.globalValue) : {}) }; + if (!has(current, pattern) && typeof inherited[pattern] === 'string' && inherited[pattern] !== csvEditor && inherited[pattern] !== 'default') { + return unsupported('CSV: Your inherited editor association has been preserved. The view can switch; turn CSV off before using Find in Files if that association opens another editor.'); + } + if (!previousEntry && current[pattern] === 'default') { return { supported: true, commit: async () => {}, rollback: async () => {} }; } + if (observed.state.ignored.includes(pattern) || (has(current, pattern) && current[pattern] !== csvEditor && !previousEntry)) { + return unsupported('CSV: Your editor association has been preserved. The view can switch; turn CSV off before using Find in Files if that association opens another editor.'); + } + desired.set(pattern, 'default'); + } else { + if (!previousEntry?.textView) { return { supported: true, commit: async () => {}, rollback: async () => {} }; } + desired.delete(pattern); + } + const change = plan(current, raw === undefined, observed.state, desired, false); + if (target === 'default') { + const entry = { ...change.finalState.entries[pattern], textView: true, owner: token }; + change.beforeWrite.entries[pattern] = { ...entry }; + change.finalState.entries[pattern] = entry; + } else { change.finalState.released[pattern] = token; } + const config = this.api.workspace.getConfiguration('csv'); + const enabled = config.get('enabled', true); + const stillCurrent = () => !this.disposed && this.api.workspace.getConfiguration('csv').get('enabled', true) === enabled; + const readCurrent = () => { + const latest = this.api.workspace.getConfiguration('workbench').inspect('editorAssociations'); + return dictionary(scope === 'global' ? latest?.globalValue : latest?.workspaceValue); + }; + const expectedHas = has(change.next, pattern), expectedValue = change.next[pattern]; + const rollbackOnce = async () => { + const latest = readCurrent(); + const stored = recovery(storage).state; + const recovered = observe(latest, stored); + if (!equal(stored, recovered.state)) { await this.recordObservedOwnership(scope, latest, false, recovered.state, true); } + const decision = recovered.state.entries[pattern]?.owner ?? recovered.state.released[pattern]; + if (decision !== token || has(latest, pattern) !== expectedHas || latest[pattern] !== expectedValue) { return; } + const nextMap = { ...latest }; + if (hadPrevious) { Object.defineProperty(nextMap, pattern, { value: previousValue, enumerable: true, configurable: true, writable: true }); } + else { delete nextMap[pattern]; } + const finalState: Ownership = { ...recovered.state, entries: { ...recovered.state.entries }, released: { ...recovered.state.released } }; + if (previousEntry) { finalState.entries[pattern] = previousEntry; delete finalState.released[pattern]; } + else { delete finalState.entries[pattern]; finalState.released[pattern] = randomBytes(12).toString('hex'); } + const beforeWrite = previousEntry ? finalState : recovered.state; + await this.apply(scope, latest, { + next: nextMap, value: !Object.keys(nextMap).length && change.beforeWrite.containerAbsent ? undefined : nextMap, + beforeWrite, finalState + }, () => true, true); + }; + try { + await this.apply(scope, current, change, stillCurrent); + if (!stillCurrent()) { throw new Error('CSV: Editor routing changed while switching views.'); } + if (has(readCurrent(), pattern) !== expectedHas || readCurrent()[pattern] !== expectedValue) { + throw new Error('CSV: Could not prepare the file’s editor routing.'); + } + } catch (error) { + await rollbackOnce(); + throw error; + } + return { + supported: true, commit: async () => {}, + rollback: () => { + const rollback = this.pending.catch(() => {}).then(rollbackOnce); + this.pending = rollback; + return rollback; + } + }; + } + private async recordObservedOwnership(scope: Scope, current: Associations, absent: boolean, observed: Ownership, cleanup = false): Promise { + const desired = new Map(Object.entries(observed.entries).map(([pattern, entry]) => [pattern, entry.applied])); + await this.apply(scope, current, plan(current, absent, observed, desired, false), () => cleanup || !this.disposed, cleanup); + } + private async synchronizeOnce(): Promise { + if (this.disposed) { return; } + const workbench = this.api.workspace.getConfiguration('workbench'); + const setting = workbench.inspect('editorAssociations'); + const enabledSetting = this.api.workspace.getConfiguration('csv').inspect('enabled'); + const globalEnabled = enabledSetting?.globalValue ?? enabledSetting?.defaultValue ?? true; + const effectiveEnabled = this.api.workspace.getConfiguration('csv').get('enabled', true); + const hasWorkspace = !!this.api.workspace.workspaceFile || !!this.api.workspace.workspaceFolders?.length; + const defaults = dictionary(setting?.defaultValue); + const globalCurrent = dictionary(setting?.globalValue); + const globalRecovery = recovery(this.context.globalState); + const globalObserved = observe(globalCurrent, globalRecovery.state); + const globalOriginal = { ...defaults, ...globalObserved.original }; + const globalDesired = (hasWorkspace ? globalEnabled : effectiveEnabled) ? new Map() : desiredNative(globalOriginal); + for (const [pattern, entry] of Object.entries(globalObserved.state.entries)) { + if (entry.textView) { globalDesired.set(pattern, 'default'); } + } + const globalPlan = plan(globalCurrent, setting?.globalValue === undefined, globalObserved.state, globalDesired, globalEnabled); + const stillCurrent = () => { + const config = this.api.workspace.getConfiguration('csv'); + const global = config.inspect('enabled'); + return (global?.globalValue ?? global?.defaultValue ?? true) === globalEnabled && config.get('enabled', true) === effectiveEnabled; + }; + await this.apply('global', globalCurrent, globalPlan, stillCurrent); + if (this.disposed || !stillCurrent()) { return; } + if (!hasWorkspace) { this.verifyEffectiveRouting(effectiveEnabled); return; } + + const fresh = workbench.inspect('editorAssociations'); + const workspaceCurrent = dictionary(fresh?.workspaceValue); + const observed = observe(workspaceCurrent, recovery(this.context.workspaceState).state); + const desired = new Map(); + if (!effectiveEnabled) { + const patterns = new Set(desiredNative({ ...globalOriginal, ...observed.original }).keys()); + for (const pattern of patterns) { + if (has(observed.original, pattern)) { + if (observed.original[pattern] === csvEditor) { desired.set(pattern, 'default'); } + } else if (({ ...defaults, ...globalPlan.next })[pattern] === undefined || ({ ...defaults, ...globalPlan.next })[pattern] === csvEditor) { + desired.set(pattern, 'default'); + } + } + } else { + // A workspace explicitly enabled above global Off must counter only our + // inherited temporary native bindings, leaving other editor choices alone. + for (const [pattern, entry] of Object.entries(globalPlan.finalState.entries)) { + if (!entry.textView && !has(observed.original, pattern) && globalPlan.next[pattern] === entry.applied) { + desired.set(pattern, entry.hadPrevious ? entry.previous! : csvEditor); + } + } + } + for (const [pattern, entry] of Object.entries(observed.state.entries)) { + if (entry.textView) { desired.set(pattern, 'default'); } + } + await this.apply('workspace', workspaceCurrent, + plan(workspaceCurrent, fresh?.workspaceValue === undefined, observed.state, desired, effectiveEnabled), stillCurrent); + if (!this.disposed && stillCurrent()) { this.verifyEffectiveRouting(effectiveEnabled); } + } + private verifyEffectiveRouting(enabled: boolean): void { + if (enabled) { return; } + const effective = this.api.workspace.getConfiguration('workbench').get('editorAssociations', {}); + const blocked = Object.keys(effective).filter(pattern => effective[pattern] === csvEditor); + if (blocked.length) { + throw new Error(`CSV could not disable its effective editor associations (${blocked.join(', ')}). A policy or another settings scope still selects CSV.`); + } + } + private async apply(scope: Scope, previous: Associations, change: ReturnType, stillCurrent: () => boolean, cleanup = false): Promise { + const storage = scope === 'global' ? this.context.globalState : this.context.workspaceState; + const target = scope === 'global' ? this.api.ConfigurationTarget.Global : this.api.ConfigurationTarget.Workspace; + const current = () => { + const setting = this.api.workspace.getConfiguration('workbench').inspect('editorAssociations'); + return dictionary(scope === 'global' ? setting?.globalValue : setting?.workspaceValue); + }; + if (!equal(previous, change.next)) { + // Each operation owns an immutable key. A delayed window cannot overwrite + // another window's backup of a more-specific user association. + const key = `${journalPrefix}${Date.now()}.${randomBytes(12).toString('hex')}`; + const journal: Journal = { created: performance.timeOrigin + performance.now(), state: change.beforeWrite }; + await storage.update(key, journal); + const discardUnappliedJournal = async () => { + if (equal(current(), previous) && equal(storage.get(key), journal)) { await storage.update(key, undefined); } + }; + if ((!cleanup && this.disposed) || !stillCurrent()) { await discardUnappliedJournal(); return; } + const fresh = current(); + if (!equal(fresh, previous)) { + // We have not changed preferences. Only the operation that actually + // installed this map owns its recovery; a coalesced stale plan must not + // supersede a newer user choice captured by that operation. + if (equal(storage.get(key), journal)) { await storage.update(key, undefined); } + if (!equal(fresh, change.next)) { + throw new Error('Editor associations changed while CSV was updating routing; retry the toggle.'); + } + return; // The actual writer also owns finalization of this map. + } + try { + if (!equal(fresh, change.next)) { + await this.api.workspace.getConfiguration('workbench').update('editorAssociations', change.value, target); + } + if (!equal(current(), change.next)) { + throw new Error('VS Code did not apply the routing preferences; a policy or read-only settings may prevent this change.'); + } + } catch (error) { + await discardUnappliedJournal(); + throw new Error(`CSV could not update ${scope} editor routing: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (!stillCurrent() || (!cleanup && this.disposed)) { return; } + const keep = Object.keys(change.finalState.entries).length || change.finalState.ignored.length; + const recovered = recovery(storage); + if (keep && !equal(recovered.state, change.finalState)) { + // A real observed user edit relinquishes ownership even if it needs no + // settings write. Choosing the old value later must remain user-owned. + const key = `${journalPrefix}${Date.now()}.${randomBytes(12).toString('hex')}`; + await storage.update(key, { created: performance.timeOrigin + performance.now(), state: change.finalState } as Journal); + } + if (!keep) { + const live = current(); + const relinquished = new Set([...recovered.state.ignored, ...Object.keys(recovered.state.released)]); + for (const { key, value } of recovered.journals) { + // Clear only the observed immutable record after its temporary values + // have gone. A concurrently added journal has a different key. + if (Object.entries(ownership(value.state).entries).some(([pattern, entry]) => !relinquished.has(pattern) && live[pattern] === entry.applied)) { continue; } + if (equal(storage.get(key), value)) { await storage.update(key, undefined); } + } + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 50ec42b..3480ca2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,17 +1,36 @@ import * as vscode from 'vscode'; import { CsvEditorProvider } from './CsvEditorProvider'; import { registerCsvCommands } from './commands'; +import { CsvEditorAssociationRouting } from './editor-associations'; -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { console.log('CSV: Extension activated'); - // Commands (toggle features, change separator/font) - registerCsvCommands(context); - + const routing = new CsvEditorAssociationRouting(context, vscode); + context.subscriptions.push(routing); + let lastRoutingError: string | undefined; + const synchronizeRouting = async () => { + try { + await routing.synchronize(); + lastRoutingError = undefined; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message !== lastRoutingError) { void vscode.window.showErrorMessage(message); } + lastRoutingError = message; + throw error; + } + }; + let routingGeneration = 0; // Auto-refresh all open CSV editors when relevant CSV settings change - const cfgListener = vscode.workspace.onDidChangeConfiguration(e => { + const cfgListener = vscode.workspace.onDidChangeConfiguration(async e => { + const affectsRouting = e.affectsConfiguration('csv.enabled') || e.affectsConfiguration('workbench.editorAssociations'); + if (affectsRouting) { ++routingGeneration; } + if (affectsRouting) { + try { await synchronizeRouting(); } catch (error) { console.error('CSV: editor routing update failed', error); return; } + } // If the user turned the extension on, immediately upgrade open CSV/TSV tabs if (e.affectsConfiguration('csv.enabled')) { + const generation = routingGeneration; const enabled = vscode.workspace.getConfiguration('csv').get('enabled', true); console.log(`[CSV(enable)]: configuration changed -> enabled=${enabled}`); if (enabled) { @@ -29,8 +48,8 @@ export function activate(context: vscode.ExtensionContext) { const path = uri?.fsPath || '(no-uri)'; console.log(`[CSV(enable)]: group[${gi}] tab[${ti}] vt=${viewType ?? '(text?)'} uri=${path} active=${tab.isActive} preview=${tab.isPreview}`); if (!input) return; - if (viewType === CsvEditorProvider.viewType) return; // already our editor - if (!uri) return; + if (viewType) return; // Never replace another custom editor (or ours). + if (!uri || uri.scheme === 'untitled') return; 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}`); @@ -40,49 +59,35 @@ export function activate(context: vscode.ExtensionContext) { }); console.log(`[CSV(enable)]: candidates=${candidates.length}`); - (async () => { - const processed = new Set(); + await (async () => { + const stillEnabled = () => generation === routingGeneration && vscode.workspace.getConfiguration('csv').get('enabled', true); for (const c of candidates) { + if (!stillEnabled()) { return; } + if (!vscode.window.tabGroups.all.some(group => group === c.group && group.tabs.includes(c.tab))) { continue; } try { - console.log(`[CSV(enable)]: closing group[${c.groupIndex}] tab[${c.tabIndex}] uri=${c.uri.fsPath}`); - await vscode.window.tabGroups.close(c.tab); - } catch (err) { - console.error(`[CSV(enable)]: close failed for ${c.uri.fsPath}`, err); - } - try { - const openOpts: any = { viewColumn: c.viewColumn, preserveFocus: !c.wasActive, preview: c.wasPreview }; - console.log(`[CSV(enable)]: opening custom editor for ${c.uri.fsPath} in column=${c.viewColumn}`); - await vscode.commands.executeCommand('vscode.openWith', c.uri, CsvEditorProvider.viewType, openOpts); - processed.add(c.uri.toString()); - } catch (err) { - console.error(`[CSV(enable)]: openWith failed for ${c.uri.fsPath}`, err); - } - } - - // Final sweep: ensure no stale non-custom tabs remain for processed URIs across all groups - try { - const stale: vscode.Tab[] = []; - vscode.window.tabGroups.all.forEach((g, gi) => { - g.tabs.forEach((t, ti) => { - const inp: any = (t as any).input; - const vt = inp?.viewType; - const u: vscode.Uri | undefined = inp?.uri instanceof vscode.Uri ? (inp.uri as vscode.Uri) : undefined; - const key = u?.toString(); - if (key && processed.has(key) && vt !== CsvEditorProvider.viewType) { - console.log(`[CSV(enable)]: final-sweep closing group[${gi}] tab[${ti}] uri=${u?.fsPath} vt=${vt}`); - stale.push(t); - } + const hadTable = c.group.tabs.some(tab => { + const input: any = tab.input; + return input?.viewType === CsvEditorProvider.viewType && input?.uri?.toString() === c.uri.toString(); }); - }); - if (stale.length) { - await vscode.window.tabGroups.close(stale); - } - } catch (err) { - console.error('[CSV(enable)]: final-sweep error', err); + // Let the resolver honor restored native/other-editor choices. + await vscode.commands.executeCommand('vscode.open', c.uri, { viewColumn: c.viewColumn, preserveFocus: !c.wasActive, preview: c.wasPreview }); + if (!stillEnabled()) { return; } + const group = vscode.window.tabGroups.all.find(group => group === c.group); + const hasTable = group?.tabs.some(tab => { + const input: any = tab.input; + return input?.viewType === CsvEditorProvider.viewType && input?.uri?.toString() === c.uri.toString(); + }); + // Keep dirty source owners and cancelled/failed resolutions intact. + if (!hadTable && hasTable && group?.tabs.includes(c.tab) && !c.tab.isDirty && stillEnabled()) { + await vscode.window.tabGroups.close(c.tab, true); + } + } catch (error) { console.error('[CSV(enable)]: editor upgrade failed', error); } } console.log(`[CSV(enable)]: done processing ${candidates.length} candidate(s)`); })(); + } else { + CsvEditorProvider.editors.slice().forEach(ed => ed.refresh()); } } @@ -105,6 +110,11 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push(cfgListener); + // Listen before the first await so settings changes during recovery are queued. + // Persisted Off routing must be installed before commands/provider handoffs. + try { await synchronizeRouting(); } catch (error) { console.error('CSV: editor routing initialization failed', error); } + registerCsvCommands(context, synchronizeRouting); + // Register the custom editor provider for CSV/TSV context.subscriptions.push( vscode.window.registerCustomEditorProvider( diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..fe987e2 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -8,6 +8,7 @@ class TestUri { get fsPath() { return this.path; } toString() { return `${this.scheme}://${this.path}`; } static file(filePath: string) { return new TestUri(filePath); } + with(changes: { path: string }) { return new TestUri(changes.path, this.scheme); } } let activeHarness: any; @@ -77,6 +78,7 @@ export async function createEditorHarness(options: { }; const context = { extensionPath: process.cwd(), + extensionUri: TestUri.file(process.cwd()), workspaceState: { values: {} as Record, get(key: string, fallback: unknown) { return this.values[key] ?? fallback; }, diff --git a/src/test/editor-associations.test.ts b/src/test/editor-associations.test.ts new file mode 100644 index 0000000..3541a53 --- /dev/null +++ b/src/test/editor-associations.test.ts @@ -0,0 +1,447 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { CsvEditorAssociationRouting, csvEditorSelectors, exactFileEditorPattern } from '../editor-associations'; + +type MapValue = Record | undefined; +const clone = (value: T): T => value === undefined ? value : JSON.parse(JSON.stringify(value)); +function fixture(options: { global?: MapValue; workspace?: MapValue; globalEnabled?: boolean; workspaceEnabled?: boolean; hasWorkspace?: boolean; defaults?: MapValue } = {}) { + const data = { + global: clone(options.global), workspace: clone(options.workspace), + globalEnabled: options.globalEnabled, workspaceEnabled: options.workspaceEnabled, + storeAfterWait: false, fail: false, ignoreWrite: false, policy: undefined as MapValue, beforeStorage: undefined as (() => Promise) | undefined, beforeWrite: undefined as (() => Promise) | undefined, + writes: [] as Array<{ target: number; value: MapValue }>, storageWrites: 0 + }; + const globalValues = new Map(), workspaceValues = new Map(); + const store = (values: Map) => ({ + get: (key: string) => clone(values.get(key)), + keys: () => [...values.keys()].filter(key => values.get(key) !== undefined), + update: async (key: string, value: unknown) => { data.storageWrites++; if (!data.storeAfterWait) { values.set(key, clone(value)); } await data.beforeStorage?.(); if (data.storeAfterWait) { values.set(key, clone(value)); } } + }); + const context = { globalState: store(globalValues), workspaceState: store(workspaceValues) } as any; + const api = { + ConfigurationTarget: { Global: 1, Workspace: 2 }, + workspace: { + workspaceFolders: options.hasWorkspace === false ? undefined : [{}], + workspaceFile: undefined, + getConfiguration: (section: string) => section === 'csv' ? { + get: () => data.workspaceEnabled ?? data.globalEnabled ?? true, + inspect: () => ({ defaultValue: true, globalValue: data.globalEnabled, workspaceValue: data.workspaceEnabled }) + } : { + inspect: () => ({ defaultValue: clone(options.defaults), globalValue: clone(data.global), workspaceValue: clone(data.workspace) }), + get: () => clone(data.policy ?? { ...(options.defaults || {}), ...(data.global || {}), ...(data.workspace || {}) }), + update: async (_key: string, value: MapValue, target: number) => { + await data.beforeWrite?.(); + if (data.fail) { throw new Error('settings are read-only'); } + data.writes.push({ target, value: clone(value) }); + if (!data.ignoreWrite) { if (target === 1) { data.global = clone(value); } else { data.workspace = clone(value); } } + } + } + } + } as any; + return { data, context, api, globalValues, workspaceValues, manager: () => new CsvEditorAssociationRouting(context, api) }; +} +const native = () => Object.fromEntries(csvEditorSelectors.map(pattern => [pattern, 'default'])); + +describe('Reversible disabled CSV editor associations', () => { + it('routes all four selectors and more-specific CSV choices natively while preserving other editors', async () => { + const original = { '**/special.csv': 'csv.editor', '*.tsv': 'another.editor', '*.tab': 'default', '*.md': 'markdown.preview' }; + const h = fixture({ global: original, globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, { ...native(), ...original, '**/special.csv': 'default', '*.csv': 'default', '*.psv': 'default' }); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, original); + }); + + for (const original of [undefined, {}]) { + it(`restores an originally ${original ? 'empty' : 'absent'} settings object exactly`, async () => { + const h = fixture({ global: original, globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, native()); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, original); + }); + } + + it('limits workspace Off to workspace settings, including inherited specific CSV choices', async () => { + const global = { '**/data.csv': 'csv.editor', '*.tsv': 'external.tsv' }; + const workspace = { '*.psv': 'external.psv' }; + const h = fixture({ global, workspace, workspaceEnabled: false }); + const routing = h.manager(); + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, global); + assert.deepStrictEqual(h.data.workspace, { ...workspace, '*.csv': 'default', '*.tab': 'default', '**/data.csv': 'default' }); + assert.ok(h.data.writes.every(write => write.target === 2)); + h.data.workspaceEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.workspace, workspace); + }); + + it('uses workspace On counterbindings only for this extension’s inherited global shadows', async () => { + const h = fixture({ globalEnabled: false, workspaceEnabled: true, global: { '**/data.csv': 'csv.editor', '*.tsv': 'external.tsv' }, workspace: { '*.psv': 'external.psv' } }); + const routing = h.manager(); + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, { '*.csv': 'default', '*.tab': 'default', '*.psv': 'default', '**/data.csv': 'default', '*.tsv': 'external.tsv' }); + assert.deepStrictEqual(h.data.workspace, { '*.psv': 'external.psv', '*.csv': 'csv.editor', '*.tab': 'csv.editor', '**/data.csv': 'csv.editor' }); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, { '**/data.csv': 'csv.editor', '*.tsv': 'external.tsv' }); + assert.deepStrictEqual(h.data.workspace, { '*.psv': 'external.psv' }); + }); + + it('does not add redundant workspace overrides when native global routing is inherited', async () => { + const h = fixture({ globalEnabled: false }); + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, native()); + assert.strictEqual(h.data.workspace, undefined); + }); + + it('restores preferences after restart and preserves unrelated additions made while disabled', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false, global: { '*.csv': 'csv.editor' } }); + await h.manager().synchronize(); + h.data.global = { ...h.data.global, '*.json': 'external.json' }; + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, { '*.csv': 'csv.editor', '*.json': 'external.json' }); + }); + + it('does not overwrite an intervening choice of another editor or native editor on re-enable', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + await routing.synchronize(); + h.data.global = { ...h.data.global, '*.csv': 'external.csv' }; + await routing.synchronize(); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, { '*.csv': 'external.csv' }); + }); + + it('preserves an intervening deletion or explicit CSV choice and reports the routing conflict', async () => { + for (const replacement of [undefined, 'csv.editor']) { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + await routing.synchronize(); + if (replacement === undefined) { delete h.data.global!['*.csv']; } + else { h.data.global!['*.csv'] = replacement; } + const current = clone(h.data.global); + await assert.rejects(routing.synchronize(), /preserved your changed editor associations/); + assert.deepStrictEqual(h.data.global, current); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, replacement === undefined ? {} : { '*.csv': replacement }); + } + }); + + it('fails clearly on a denied write and can retry without a poisoned ownership record', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + h.data.fail = true; + await assert.rejects(routing.synchronize(), /could not update global editor routing: settings are read-only/); + assert.strictEqual(h.data.global, undefined); + h.data.fail = false; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, native()); + h.data.globalEnabled = true; + await routing.synchronize(); + assert.strictEqual(h.data.global, undefined); + }); + + it('stores recovery metadata before settings change and avoids repeated writes when synchronized', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.beforeWrite = async () => { + assert.ok([...h.globalValues.values()].some((value: any) => Object.keys(value?.state?.entries || {}).length === 4)); + }; + const routing = h.manager(); + await routing.synchronize(); + const writes = h.data.writes.length; + await routing.synchronize(); + assert.strictEqual(h.data.writes.length, writes); + }); + it('preserves blanket and more-specific external choices while converting specific CSV overrides', async () => { + for (const blanket of ['*', '**/*', '*.*']) { + const original = { [blanket]: 'external.editor', '**/special.csv': 'csv.editor', '/path/*.csv': 'external.path' }; + const h = fixture({ global: original, globalEnabled: false, hasWorkspace: false }); + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, { ...original, '**/special.csv': 'default' }); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, original); + } + const h = fixture({ global: { 'a*': 'external.editor' }, globalEnabled: false, hasWorkspace: false }); + await assert.rejects(h.manager().synchronize(), /preserved overlapping editor associations/); + assert.deepStrictEqual(h.data.global, { 'a*': 'external.editor' }); + }); + + it('handles inherited blanket choices and more-specific CSV associations without global writes', async () => { + const global = { '**/*': 'external.editor', '**/special.csv': 'csv.editor' }; + const h = fixture({ global, workspaceEnabled: false }); + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, global); + assert.deepStrictEqual(h.data.workspace, { '**/special.csv': 'default' }); + }); + + it('shadows contributed default CSV associations and detects policy-valued routing', async () => { + const h = fixture({ defaults: { '**/special.csv': 'csv.editor', '*.tsv': 'external.editor' }, globalEnabled: false, hasWorkspace: false }); + await h.manager().synchronize(); + assert.strictEqual(h.data.global!['**/special.csv'], 'default'); + assert.strictEqual(h.data.global!['*.tsv'], undefined); + h.data.policy = { '*.csv': 'csv.editor' }; + await assert.rejects(h.manager().synchronize(), /policy or another settings scope/); + }); + + it('detects an ignored settings write and can recover without ownership debris', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.ignoreWrite = true; + await assert.rejects(h.manager().synchronize(), /did not apply the routing preferences/); + assert.strictEqual(h.data.global, undefined); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + h.data.ignoreWrite = false; + await h.manager().synchronize(); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.strictEqual(h.data.global, undefined); + assert.ok([...h.globalValues.values()].every(value => value === undefined), 'final state removes provisional ownership'); + }); + + it('does not write settings after disposal during recovery metadata persistence', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + const routing = h.manager(); + h.data.beforeStorage = async () => { h.data.beforeStorage = undefined; routing.dispose(); }; + await routing.synchronize(); + assert.strictEqual(h.data.writes.length, 0); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + + it('finishes recovery ownership if disposed during an in-flight settings write', async () => { + const h = fixture({ globalEnabled: false, workspaceEnabled: true }); + const routing = h.manager(); + h.data.beforeWrite = async () => { routing.dispose(); }; + await routing.synchronize(); + assert.deepStrictEqual(h.data.global, native()); + assert.strictEqual(h.data.workspace, undefined); + h.data.beforeWrite = undefined; + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.strictEqual(h.data.global, undefined); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + + it('does not overwrite a user map changed while recovery data was being persisted', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.beforeStorage = async () => { h.data.beforeStorage = undefined; h.data.global = { '*.csv': 'external.editor' }; }; + await assert.rejects(h.manager().synchronize(), /changed while CSV was updating/); + assert.deepStrictEqual(h.data.global, { '*.csv': 'external.editor' }); + }); + + it('keeps recovery when another window has already installed the same temporary routing', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.storeAfterWait = true; + let releaseFirst!: () => void, releaseSecond!: () => void; + let count = 0; + h.data.beforeStorage = async () => { + const operation = ++count; + if (operation === 1) { await new Promise(resolve => { releaseFirst = resolve; }); } + if (operation === 2) { await new Promise(resolve => { releaseSecond = resolve; }); } + }; + const first = h.manager().synchronize(); + while (!releaseFirst) { await new Promise(resolve => setImmediate(resolve)); } + const second = h.manager().synchronize(); + while (!releaseSecond) { await new Promise(resolve => setImmediate(resolve)); } + releaseFirst(); await first; + assert.deepStrictEqual(h.data.global, native()); + releaseSecond(); await second; + assert.deepStrictEqual(h.data.global, native()); + assert.strictEqual(h.data.writes.length, 1); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.strictEqual(h.data.global, undefined, 'last window retains the original absence for restoration'); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + + for (const original of [undefined, 'csv.editor']) { + it(`does not let an older coalesced plan replace a newer user's ${original ? 'explicit CSV choice' : 'deletion'}`, async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false, global: original ? undefined : { '*.csv': 'csv.editor' } }); + h.data.storeAfterWait = true; + let release!: () => void; + let held = false; + h.data.beforeStorage = async () => { + if (!held) { held = true; await new Promise(resolve => { release = resolve; }); } + }; + const pending = h.manager().synchronize(); + while (!release) { await new Promise(resolve => setImmediate(resolve)); } + h.data.global = original ? { '*.csv': original } : undefined; + await h.manager().synchronize(); + release(); await pending; + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, original ? { '*.csv': original } : undefined); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + } + + it('keeps a newer window’s extra original binding when a delayed older plan conflicts', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + h.data.storeAfterWait = true; + let release!: () => void; + let held = false; + h.data.beforeStorage = async () => { + if (!held) { held = true; await new Promise(resolve => { release = resolve; }); } + }; + const pending = h.manager().synchronize(); + while (!release) { await new Promise(resolve => setImmediate(resolve)); } + h.data.global = { '**/special.csv': 'csv.editor' }; + await h.manager().synchronize(); + release(); await assert.rejects(pending, /changed while CSV was updating/); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, { '**/special.csv': 'csv.editor' }); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + + it('durably relinquishes an observed user edit even when no settings write is necessary', async () => { + const h = fixture({ globalEnabled: false, hasWorkspace: false }); + await h.manager().synchronize(); + h.data.global!['*.csv'] = 'external.editor'; + const writeCount = h.data.writes.length; + await h.manager().synchronize(); + assert.strictEqual(h.data.writes.length, writeCount, 'external choice needs no preference mutation'); + h.data.global!['*.csv'] = 'default'; + await h.manager().synchronize(); + h.data.globalEnabled = true; + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.global, { '*.csv': 'default' }, 'later user native choice remains on restart/re-enable'); + assert.ok([...h.globalValues.values()].every(value => value === undefined)); + }); + +}); + + +const file = (path: string, fields: Partial> = {}) => ({ scheme: 'file', authority: '', query: '', fragment: '', path, ...fields } as any); +describe('Continuing per-file Text view routing', () => { + it('uses literal scheme:path patterns and rejects URI identities the workbench cannot isolate', () => { + assert.strictEqual(exactFileEditorPattern(file('/one [x]*?{y}/check.csv')), 'file:/one [[]x[]][*][?][{]y[}]/check.csv'); + for (const fields of [{ scheme: 'vscode-remote', authority: 'wsl+Ubuntu' }, { authority: 'server' }, { query: 'revision=1' }, { fragment: 'line50' }]) { + assert.strictEqual(exactFileEditorPattern(file('/check.csv', fields)), undefined); + } + }); + + it('keeps a workspace Text choice across synchronization, restart and Off→On', async () => { + const h = fixture(), uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + const text = await h.manager().prepareEditorSwitch(uri, 'default'); await text.commit(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: 'default' }); assert.strictEqual(h.data.global, undefined); + await h.manager().synchronize(); + h.data.globalEnabled = false; await h.manager().synchronize(); + h.data.globalEnabled = true; await h.manager().synchronize(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: 'default' }); assert.strictEqual(h.data.global, undefined); + const table = await h.manager().prepareEditorSwitch(uri, 'csv.editor'); + await table.rollback(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: 'default' }, 'cancelled Table does not change Text routing'); + const successfulTable = await h.manager().prepareEditorSwitch(uri, 'csv.editor'); await successfulTable.commit(); + assert.strictEqual(h.data.workspace, undefined); assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + }); + + it('releases only one file while another Text choice remains, without resurrecting older journals', async () => { + const h = fixture(), a = file('/one/check.csv'), b = file('/two/check.csv'); + await (await h.manager().prepareEditorSwitch(a, 'default')).commit(); + await (await h.manager().prepareEditorSwitch(b, 'default')).commit(); + await (await h.manager().prepareEditorSwitch(b, 'csv.editor')).commit(); + await h.manager().synchronize(); + assert.deepStrictEqual(h.data.workspace, { [exactFileEditorPattern(a)!]: 'default' }); + await (await h.manager().prepareEditorSwitch(a, 'csv.editor')).commit(); + assert.strictEqual(h.data.workspace, undefined); + }); + + for (const original of [undefined, {}]) { + it(`rolls a cancelled Text switch back to an originally ${original ? 'empty' : 'absent'} preference object`, async () => { + const h = fixture({ workspace: original }), uri = file('/one/check.tsv'); + const transaction = await h.manager().prepareEditorSwitch(uri, 'default'); + await transaction.rollback(); + assert.deepStrictEqual(h.data.workspace, original); + assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + }); + } + + it('preserves an unrelated concurrent preference and a user edit to the owned key on rollback/Table return', async () => { + const h = fixture(), uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + const transaction = await h.manager().prepareEditorSwitch(uri, 'default'); + h.data.workspace!['*.md'] = 'markdown.preview'; + h.data.workspace![pattern] = 'external.editor'; + await transaction.rollback(); + await (await h.manager().prepareEditorSwitch(uri, 'csv.editor')).commit(); + assert.deepStrictEqual(h.data.workspace, { '*.md': 'markdown.preview', [pattern]: 'external.editor' }); + }); + + it('restores an existing exact CSV choice and preserves a preexisting native or external choice', async () => { + const uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + for (const prior of ['csv.editor', 'default', 'external.editor']) { + const h = fixture({ workspace: { [pattern]: prior } }); + const transaction = await h.manager().prepareEditorSwitch(uri, 'default'); + assert.strictEqual(transaction.supported, prior !== 'external.editor'); + await transaction.commit(); + await (await h.manager().prepareEditorSwitch(uri, 'csv.editor')).commit(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: prior }); + } + }); + + it('internally rolls back a provisional Text write when enablement changes during preparation', async () => { + const h = fixture(), uri = file('/one/check.csv'); + h.data.beforeWrite = async () => { h.data.beforeWrite = undefined; h.data.globalEnabled = false; }; + await assert.rejects(h.manager().prepareEditorSwitch(uri, 'default'), /routing changed while switching/); + assert.strictEqual(h.data.workspace, undefined); + assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + }); + + it('internally rolls back a provisional Text write if disposed while it is applying', async () => { + const h = fixture(), uri = file('/one/check.csv'), manager = h.manager(); + h.data.beforeWrite = async () => { h.data.beforeWrite = undefined; manager.dispose(); }; + await assert.rejects(manager.prepareEditorSwitch(uri, 'default'), /routing changed while switching/); + assert.strictEqual(h.data.workspace, undefined); + assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + }); + + it('does not leave ownership after denied or ignored provisional writes', async () => { + for (const failure of ['fail', 'ignoreWrite'] as const) { + const h = fixture(); h.data[failure] = true; + await assert.rejects(h.manager().prepareEditorSwitch(file('/one/check.csv'), 'default')); + assert.strictEqual(h.data.workspace, undefined); + assert.ok([...h.workspaceValues.values()].every(value => value === undefined)); + } + }); + + it('leaves unsupported remote view switching available without changing any associations', async () => { + const h = fixture(); + const transaction = await h.manager().prepareEditorSwitch(file('/one/check.csv', { scheme: 'vscode-remote', authority: 'wsl+Ubuntu' }), 'default'); + assert.strictEqual(transaction.supported, false); assert.match(transaction.limitation!, /Turn CSV off/); + await transaction.commit(); await transaction.rollback(); + assert.strictEqual(h.data.writes.length, 0); + }); + it('does not shadow an inherited exact choice of another editor', async () => { + const uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + const h = fixture({ global: { [pattern]: 'external.editor' } }); + const transaction = await h.manager().prepareEditorSwitch(uri, 'default'); + assert.strictEqual(transaction.supported, false); + assert.strictEqual(h.data.workspace, undefined); + assert.deepStrictEqual(h.data.global, { [pattern]: 'external.editor' }); + }); + + for (const observedBy of ['table', 'rollback']) { + for (const userValue of ['external.editor', undefined]) { + it(`durably relinquishes a user ${userValue ? 'editor change' : 'deletion'} observed by ${observedBy} before the user chooses native again`, async () => { + const h = fixture(), uri = file('/one/check.csv'), pattern = exactFileEditorPattern(uri)!; + const text = await h.manager().prepareEditorSwitch(uri, 'default'); await text.commit(); + if (userValue === undefined) { delete h.data.workspace![pattern]; } + else { h.data.workspace![pattern] = userValue; } + if (observedBy === 'table') { await (await h.manager().prepareEditorSwitch(uri, 'csv.editor')).commit(); } + else { await text.rollback(); } + h.data.workspace![pattern] = 'default'; + await h.manager().synchronize(); + await (await h.manager().prepareEditorSwitch(uri, 'csv.editor')).commit(); + assert.deepStrictEqual(h.data.workspace, { [pattern]: 'default' }); + }); + } + } + +}); diff --git a/src/test/editor-lifecycle.test.ts b/src/test/editor-lifecycle.test.ts new file mode 100644 index 0000000..d3731d6 --- /dev/null +++ b/src/test/editor-lifecycle.test.ts @@ -0,0 +1,275 @@ +import assert from 'assert'; +import Module from 'module'; +import { beforeEach, describe, it } from 'node:test'; + +class Uri { + constructor(public scheme: string, public authority: string, public path: string) {} + get fsPath() { return this.path; } + toString() { return `${this.scheme}://${this.authority}${this.path}`; } + with(changes: { path: string }) { + return new Uri(this.scheme, this.authority, changes.path); + } +} + +const state = { + enabled: true, + visibleTextEditors: [] as any[], + commands: [] as any[][], + stat: async (): Promise<{ size: number }> => ({ size: 12 }), + executeCommand: async (..._args: any[]): Promise => {}, + documentListeners: new Set<(...args: any[]) => void>() +}; +const vscode = { + Uri, + ColorThemeKind: { Dark: 1 }, + window: { + activeColorTheme: { kind: 1 }, + tabGroups: { all: [] }, + get visibleTextEditors() { return state.visibleTextEditors; } + }, + workspace: { + getConfiguration: () => ({ + get: (key: string, fallback: any) => key === 'enabled' ? state.enabled : fallback + }), + fs: { stat: () => state.stat() }, + onWillSaveTextDocument: () => ({ dispose() {} }), + onDidChangeTextDocument: (callback: (...args: any[]) => void) => { + state.documentListeners.add(callback); + return { dispose: () => state.documentListeners.delete(callback) }; + } + }, + commands: { + executeCommand: async (...args: any[]) => { + state.commands.push(args); + await state.executeCommand(...args); + } + } +}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return vscode; + } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +const context = { + extensionUri: new Uri('vscode-remote', 'wsl+Ubuntu', '/home/user/.vscode-server/extensions/csv'), + // A path alone cannot carry the remote authority of extensionUri. + extensionPath: '/home/user/.vscode-server/extensions/csv', + workspaceState: { get: (_key: string, fallback: any) => fallback } +}; + +function makePanel(viewColumn = 1) { + const disposeListeners: Array<() => void> = []; + const viewStateListeners: Array<(event: any) => void> = []; + const messages: any[] = []; + const resources: Uri[] = []; + let html = ''; + let disposed = false; + const panel = { + active: true, + viewColumn, + title: 'test.csv', + get disposed() { return disposed; }, + webview: { + options: {} as { localResourceRoots?: Uri[] }, + cspSource: 'vscode-webview:', + get html() { return html; }, + set html(value: string) { + assert.strictEqual(disposed, false, 'must not render into a disposed webview'); + html = value; + }, + asWebviewUri: (uri: Uri) => { resources.push(uri); return uri; }, + postMessage: (message: any) => { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage: () => ({ dispose() {} }) + }, + onDidDispose: (callback: () => void) => { + disposeListeners.push(callback); + return { dispose() {} }; + }, + onDidChangeViewState: (callback: (event: any) => void) => { + viewStateListeners.push(callback); + return { dispose() {} }; + }, + dispose: () => { + if (!disposed) { + disposed = true; + disposeListeners.forEach(callback => callback()); + } + }, + setActive: (active: boolean) => { + panel.active = active; + viewStateListeners.forEach(callback => callback({ webviewPanel: panel })); + } + }; + return { panel, messages, resources }; +} + +function makeDocument(uri: Uri, text = 'name,value\nfirst,1') { + return { uri, version: 1, getText: () => text }; +} + +async function resolve(document: ReturnType, panel: ReturnType['panel'], token = { isCancellationRequested: false }) { + const provider = new CsvEditorProvider(context as any); + await provider.resolveCustomTextEditor(document as any, panel as any, token as any); +} + +beforeEach(() => { + state.enabled = true; + state.visibleTextEditors = []; + state.commands = []; + state.stat = async () => ({ size: 12 }); + state.executeCommand = async () => {}; + state.documentListeners.clear(); + CsvEditorProvider.editors = []; + CsvEditorProvider.currentActive = undefined; +}); + +describe('CSV editor lifecycle and native text handoff', () => { + for (const [extension, text, separatorCode] of [ + ['csv', 'name,value\nfirst,1', 44], + ['tsv', 'name\tvalue\nfirst\t1', 9] + ] as const) { + for (const scheme of ['file', 'vscode-remote']) { + it(`preserves the ${scheme} ${extension.toUpperCase()} controller and resources across tab changes`, async () => { + const uri = new Uri(scheme, scheme === 'file' ? '' : 'wsl+Ubuntu', `/workspace/data.${extension}`); + const document = makeDocument(uri, text); + const { panel, messages, resources } = makePanel(); + await resolve(document, panel); + assert.ok(panel.webview.html.includes(`data-sepcode="${separatorCode}"`)); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.strictEqual(state.documentListeners.size, 1); + const controller = CsvEditorProvider.getActiveProvider(); + assert.ok(controller); + + panel.setActive(false); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + panel.setActive(true); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), controller); + assert.deepStrictEqual(messages[messages.length - 1], { type: 'focus' }); + assert.strictEqual(state.commands.length, 0, 'tab changes must not reopen or replace editors'); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.strictEqual(resources[0].toString(), `${context.extensionUri}/media/main.js`); + assert.strictEqual(panel.webview.options.localResourceRoots?.[0].toString(), `${context.extensionUri}/media`); + panel.dispose(); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + assert.strictEqual(state.documentListeners.size, 0); + }); + } + } + + it('does not register or render a preview closed during a slow remote file check', async () => { + let finishStat!: (value: { size: number }) => void; + state.stat = () => new Promise(resolveStat => { finishStat = resolveStat; }); + const { panel } = makePanel(); + const pending = resolve(makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', '/data.csv')), panel); + panel.dispose(); + finishStat({ size: 12 }); + await pending; + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(state.documentListeners.size, 0); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + + state.stat = async () => ({ size: 12 }); + const replacement = makePanel(); + await resolve(makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', '/data.csv')), replacement.panel); + assert.ok(replacement.panel.webview.html.includes('')); + assert.strictEqual(CsvEditorProvider.editors.length, 1); + assert.ok(CsvEditorProvider.getActiveProvider()); + replacement.panel.dispose(); + }); + + it('honors cancellation during asynchronous initialization', async () => { + let finishStat!: (value: { size: number }) => void; + state.stat = () => new Promise(resolveStat => { finishStat = resolveStat; }); + const { panel } = makePanel(); + const token = { isCancellationRequested: false }; + const pending = resolve(makeDocument(new Uri('file', '', '/data.csv')), panel, token); + token.isCancellationRequested = true; + finishStat({ size: 12 }); + await pending; + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(state.documentListeners.size, 0); + }); + + for (const extension of ['csv', 'tsv']) { + it(`keeps an already visible ${extension.toUpperCase()} search destination when disabled`, async () => { + state.enabled = false; + const document = makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', `/data.${extension}`)); + const selection = { start: { line: 87, character: 4 }, end: { line: 87, character: 10 } }; + const editor = { document, viewColumn: 2, selection }; + state.visibleTextEditors.push(editor); + const { panel } = makePanel(2); + await resolve(document, panel); + assert.strictEqual(state.commands.length, 0, 'must not reopen a text editor already at the requested result'); + assert.strictEqual(editor.selection, selection); + assert.strictEqual(panel.disposed, true); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + }); + } + + it('reopens the correct group once when disabling with no visible text editor', async () => { + const document = makeDocument(new Uri('file', '', '/data.csv')); + const { panel } = makePanel(2); + await resolve(document, panel); + const controller = CsvEditorProvider.getActiveProvider()!; + panel.setActive(false); + let finishOpen!: () => void; + state.executeCommand = () => new Promise(resolveOpen => { finishOpen = resolveOpen; }); + state.enabled = false; + controller.refresh(); + controller.refresh(); + assert.deepStrictEqual(state.commands, [[ + 'vscode.openWith', document.uri, 'default', { viewColumn: 2, preserveFocus: true } + ]]); + finishOpen(); + await Promise.resolve(); + await Promise.resolve(); + controller.refresh(); + assert.strictEqual(state.commands.length, 1, 'a disposed controller cannot reopen the file later'); + assert.strictEqual(panel.disposed, true); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + }); + + it('does not reuse a matching text editor from another group during fallback', async () => { + state.enabled = false; + const document = makeDocument(new Uri('file', '', '/data.tsv')); + state.visibleTextEditors.push({ document, viewColumn: 1 }); + const { panel } = makePanel(2); + await resolve(document, panel); + assert.deepStrictEqual(state.commands, [[ + 'vscode.openWith', document.uri, 'default', { viewColumn: 2, preserveFocus: false } + ]]); + assert.strictEqual(panel.disposed, true); + }); + + it('honors disabling while the remote size check is pending', async () => { + let finishStat!: (value: { size: number }) => void; + state.stat = () => new Promise(resolveStat => { finishStat = resolveStat; }); + const { panel } = makePanel(); + const document = makeDocument(new Uri('vscode-remote', 'wsl+Ubuntu', '/data.csv')); + const pending = resolve(document, panel); + state.enabled = false; + finishStat({ size: 12 }); + await pending; + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(state.commands.length, 1); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(panel.disposed, true); + }); + + it('cleans up a disabled panel even if the native fallback fails', async () => { + state.enabled = false; + state.executeCommand = async () => { throw new Error('editor unavailable'); }; + const { panel } = makePanel(); + await assert.rejects(resolve(makeDocument(new Uri('file', '', '/data.csv')), panel), /editor unavailable/); + assert.strictEqual(panel.disposed, true); + assert.strictEqual(CsvEditorProvider.editors.length, 0); + assert.strictEqual(CsvEditorProvider.getActiveProvider(), undefined); + }); +}); diff --git a/src/test/editor-routing-commands.test.ts b/src/test/editor-routing-commands.test.ts new file mode 100644 index 0000000..2a639f9 --- /dev/null +++ b/src/test/editor-routing-commands.test.ts @@ -0,0 +1,152 @@ +import assert from 'assert'; +import Module from 'module'; +import { describe, it } from 'node:test'; + +class Uri { + readonly scheme = 'file'; + constructor(readonly fsPath: string) {} + toString() { return `file://${this.fsPath}`; } +} +const clone = (value: T): T => value === undefined ? value : JSON.parse(JSON.stringify(value)); +function fixture(initial: { enabled?: boolean; workspaceEnabled?: boolean; associations?: Record } = {}) { + const data = { enabled: initial.enabled, workspaceEnabled: initial.workspaceEnabled, associations: clone(initial.associations), workspaceAssociations: undefined as Record | undefined, + failRouting: false, updates: [] as any[], opened: [] as any[], closed: [] as any[], errors: [] as string[], infos: [] as string[], beforeOpen: undefined as (() => Promise) | undefined }; + const listeners: Array<(e: any) => Promise> = [], events = new Set>(), handlers = new Map any>(); + const emit = (key: string) => { + for (const listener of listeners) { + const next = Promise.resolve(listener({ affectsConfiguration: (candidate: string) => candidate === key })); + events.add(next); void next.finally(() => events.delete(next)); + } + }; + const store = () => { + const values = new Map(); + return { keys: () => [...values.keys()].filter(key => values.get(key) !== undefined), get: (key: string) => clone(values.get(key)), update: async (key: string, value: unknown) => { values.set(key, clone(value)); } }; + }; + const group: any = { viewColumn: 1, tabs: [] }; + const api: any = { + Uri, ConfigurationTarget: { Global: 1, Workspace: 2 }, + workspace: { + workspaceFolders: [{}], workspaceFile: undefined, + getConfiguration: (section: string) => ({ + get: (_key: string, fallback: unknown) => section === 'csv' ? (data.workspaceEnabled ?? data.enabled ?? fallback) : { ...data.associations, ...data.workspaceAssociations }, + inspect: () => section === 'csv' ? { defaultValue: true, globalValue: data.enabled, workspaceValue: data.workspaceEnabled } : { globalValue: clone(data.associations), workspaceValue: clone(data.workspaceAssociations) }, + update: async (key: string, value: any, target: number) => { + data.updates.push({ section, key, value: clone(value), target }); + if (section === 'csv') { + if (target === 2) { data.workspaceEnabled = value; } else { data.enabled = value; } + } else { + if (data.failRouting) { throw new Error('read-only test settings'); } + if (target === 2) { data.workspaceAssociations = clone(value); } else { data.associations = clone(value); } + } + emit(`${section}.${key}`); + } + }), + onDidChangeConfiguration: (listener: (e: any) => Promise) => { listeners.push(listener); return { dispose() {} }; } + }, + window: { + tabGroups: { all: [group], close: async (tab: any) => { data.closed.push(tab); group.tabs = group.tabs.filter((candidate: any) => candidate !== tab); return true; } }, + showErrorMessage: (message: string) => { data.errors.push(message); }, + showInformationMessage: (message: string) => { data.infos.push(message); }, + registerCustomEditorProvider: () => ({ dispose() {} }) + }, + commands: { + registerCommand: (id: string, handler: (...args: any[]) => any) => { handlers.set(id, handler); return { dispose() {} }; }, + executeCommand: async (...args: any[]) => { + data.opened.push(args); + await data.beforeOpen?.(); + if (args[0] === 'vscode.open' && args[1].fsPath !== '/keep-native.csv') { + group.tabs.push({ input: { uri: args[1], viewType: 'csv.editor' } }); + } + } + } + }; + class Provider { + static viewType = 'csv.editor'; + static editors: any[] = []; + constructor(_context: unknown) {} + } + const original = Module.prototype.require; + Module.prototype.require = function (id: string) { + if (id === 'vscode') { return api; } + if (id === './CsvEditorProvider') { return { CsvEditorProvider: Provider }; } + return original.apply(this, arguments as any); + }; + let activate: (context: any) => Promise; + try { + delete require.cache[require.resolve('../extension')]; + delete require.cache[require.resolve('../commands')]; + activate = require('../extension').activate; + } finally { Module.prototype.require = original; } + const context = { subscriptions: [], globalState: store(), workspaceState: store() }; + const idle = async () => { + for (let n = 0; n < 100; n++) { + if (!events.size) { return; } + await Promise.all([...events]); + } + throw new Error('configuration events did not settle'); + }; + const addText = (path: string, dirty = false) => { + const tab = { input: { uri: new Uri(path) }, isActive: true, isPreview: false, isDirty: dirty }; + group.tabs.push(tab); return tab; + }; + return { data, group, handlers, api, idle, addText, activate: () => activate(context), updateEnabled: (value: boolean) => api.workspace.getConfiguration('csv').update('enabled', value, 1) }; +} + +describe('CSV routing toggle and native resolver handoff', () => { + it('serializes complete rapid toggles and restores the original association scope', async () => { + const h = fixture(); await h.activate(); + const toggle = h.handlers.get('csv.toggleExtension')!; + await Promise.all([toggle(), toggle()]); await h.idle(); + assert.strictEqual(h.data.enabled, true); + assert.strictEqual(h.data.associations, undefined); + assert.deepStrictEqual(h.data.updates.filter(update => update.key === 'enabled').map(update => update.value), [false, true]); + }); + + it('toggles an existing workspace override without changing global enablement or associations', async () => { + const h = fixture({ enabled: true, workspaceEnabled: true, associations: { '*.md': 'default' } }); await h.activate(); + await h.handlers.get('csv.toggleExtension')!(); await h.idle(); + assert.strictEqual(h.data.workspaceEnabled, false); assert.strictEqual(h.data.enabled, true); + assert.deepStrictEqual(h.data.associations, { '*.md': 'default' }); + assert.ok(h.data.updates.every(update => update.target === 2)); + }); + + it('reports denied routing and rolls the toggle back without a disabled success notice', async () => { + const h = fixture(); await h.activate(); h.data.failRouting = true; + await h.handlers.get('csv.toggleExtension')!(); await h.idle(); + assert.strictEqual(h.data.enabled, undefined); + assert.ok(h.data.errors.some(message => message.includes('read-only test settings'))); + assert.ok(h.data.infos.every(message => !message.includes('disabled'))); + }); + + it('uses native resolution and preserves explicit native, external custom, and dirty source tabs', async () => { + const h = fixture({ enabled: false, associations: { '**/keep-native.csv': 'default' } }); await h.activate(); + const native = h.addText('/keep-native.csv'), clean = h.addText('/normal.csv'), dirty = h.addText('/dirty.tsv', true); + const external = { input: { uri: new Uri('/external.csv'), viewType: 'other.editor' } }; h.group.tabs.push(external); + await h.updateEnabled(true); await h.idle(); + assert.ok(h.data.opened.every(args => args[0] === 'vscode.open')); + assert.deepStrictEqual(h.data.closed, [clean]); + assert.ok(h.group.tabs.includes(native)); assert.ok(h.group.tabs.includes(dirty)); assert.ok(h.group.tabs.includes(external)); + assert.ok(h.data.opened.every(args => args[1].fsPath !== '/external.csv')); + }); + + it('does not close a captured source after Off supersedes an awaited On upgrade', async () => { + const h = fixture({ enabled: false }); await h.activate(); const source = h.addText('/normal.csv'); + let release!: () => void; + h.data.beforeOpen = () => new Promise(resolve => { release = resolve; }); + await h.updateEnabled(true); + while (!release) { await new Promise(resolve => setImmediate(resolve)); } + await h.updateEnabled(false); + release(); await h.idle(); + assert.strictEqual(h.data.enabled, false); + assert.ok(h.group.tabs.includes(source)); assert.deepStrictEqual(h.data.closed, []); + }); + it('keeps an explicit native source when an older counterpart table was already open', async () => { + const h = fixture({ enabled: false, associations: { '**/keep-native.csv': 'default' } }); await h.activate(); + const source = h.addText('/keep-native.csv'); + const table = { input: { uri: source.input.uri, viewType: 'csv.editor' } }; h.group.tabs.push(table); + await h.updateEnabled(true); await h.idle(); + assert.ok(h.group.tabs.includes(source)); assert.ok(h.group.tabs.includes(table)); + assert.deepStrictEqual(h.data.closed, []); + }); + +});