From 9ee87a3cc3fdc84195584b9df4220b6e23471347 Mon Sep 17 00:00:00 2001 From: Arne Schmid Date: Thu, 23 Apr 2026 08:38:58 +0200 Subject: [PATCH] Implement single editor mode for Manage Solution UI and text editor --- package.json | 5 + src/desktop/extension.ts | 2 +- src/manifest.ts | 1 + .../manage-solution-custom-editor.test.ts | 140 ++++++++++++++++++ .../manage-solution-custom-editor.ts | 128 ++++++++++++++++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 src/views/manage-solution/manage-solution-custom-editor.test.ts diff --git a/package.json b/package.json index 2bfa0270..3f693d4c 100644 --- a/package.json +++ b/package.json @@ -1357,6 +1357,11 @@ "title": "Show CMSIS View", "description": "Automatically shows the CMSIS view in the Side Bar when a CMSIS solution is loaded." }, + "cmsis-csolution.manageSolutionSingleEditor": { + "type": "boolean", + "default": true, + "markdownDescription": "When enabled, only one editor mode is kept open for the same `.csolution.yml`: opening the Manage Solution UI closes its text editor tab, and opening text mode closes the Manage Solution UI tab." + }, "cmsis-csolution.probe-id": { "type": "string", "default": "", diff --git a/src/desktop/extension.ts b/src/desktop/extension.ts index db638f72..ace9b1e0 100644 --- a/src/desktop/extension.ts +++ b/src/desktop/extension.ts @@ -240,7 +240,7 @@ export const activate = async (context: ExtensionContext): Promise; + close: jest.Mock; + onDidChangeTabs: jest.Mock; +}; + +describe('registerManageSolutionCommand', () => { + const solutionUri = vscode.Uri.file('/workspace/folder/solution.csolution.yml'); + const commandOpenUiEditor = `${manifest.PACKAGE_NAME}.openManageSolutionUiEditor`; + const commandOpenTextEditor = `${manifest.PACKAGE_NAME}.openManageSolutionTextEditor`; + + let tabGroups: MockTabGroups; + let onDidChangeTabsHandler: ((event: vscode.TabChangeEvent) => void) | undefined; + + beforeEach(() => { + onDidChangeTabsHandler = undefined; + tabGroups = { + all: [], + close: jest.fn().mockResolvedValue(undefined), + onDidChangeTabs: jest.fn().mockImplementation((cb: (event: vscode.TabChangeEvent) => void) => { + onDidChangeTabsHandler = cb; + return { dispose: jest.fn() }; + }), + }; + + (vscode.window as unknown as { tabGroups: MockTabGroups }).tabGroups = tabGroups; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('closes text tab for same solution when opening UI editor in single-editor mode', async () => { + const commandsProvider = commandsProviderFactory(); + const solutionManager = solutionManagerFactory(); + const configurationProvider = configurationProviderFactory(); + configurationProvider.getConfigVariableOrDefault.mockReturnValue(true); + solutionManager.getCsolution.mockReturnValue(csolutionFactory({ solutionPath: solutionUri.fsPath })); + + const textTab = { input: { uri: solutionUri } } as unknown as vscode.Tab; + const customTab = { input: { uri: solutionUri, viewType: ManageSolutionCustomEditorProvider.viewType } } as unknown as vscode.Tab; + tabGroups.all = [{ tabs: [textTab, customTab] }]; + + registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider); + + await commandsProvider.mockRunRegistered(commandOpenUiEditor, solutionUri); + + expect(tabGroups.close).toHaveBeenCalledWith([textTab], true); + expect(commandsProvider.executeCommand).toHaveBeenCalledWith( + 'vscode.openWith', + solutionUri, + ManageSolutionCustomEditorProvider.viewType + ); + }); + + it('closes manage solution custom tab for same solution when opening text editor in single-editor mode', async () => { + const commandsProvider = commandsProviderFactory(); + const solutionManager = solutionManagerFactory(); + const configurationProvider = configurationProviderFactory(); + configurationProvider.getConfigVariableOrDefault.mockReturnValue(true); + solutionManager.getCsolution.mockReturnValue(csolutionFactory({ solutionPath: solutionUri.fsPath })); + + const textTab = { input: { uri: solutionUri } } as unknown as vscode.Tab; + const customTab = { input: { uri: solutionUri, viewType: ManageSolutionCustomEditorProvider.viewType } } as unknown as vscode.Tab; + tabGroups.all = [{ tabs: [textTab, customTab] }]; + + registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider); + + await commandsProvider.mockRunRegistered(commandOpenTextEditor, solutionUri); + + expect(tabGroups.close).toHaveBeenCalledWith([customTab], true); + expect(commandsProvider.executeCommand).toHaveBeenCalledWith('vscode.openWith', solutionUri, 'default'); + }); + + it('closes newly opened text tab from explorer when webview tab already exists', async () => { + const commandsProvider = commandsProviderFactory(); + const solutionManager = solutionManagerFactory(); + const configurationProvider = configurationProviderFactory(); + configurationProvider.getConfigVariableOrDefault.mockReturnValue(true); + solutionManager.getCsolution.mockReturnValue(csolutionFactory({ solutionPath: solutionUri.fsPath })); + + const textTab = { input: { uri: solutionUri } } as unknown as vscode.Tab; + const customTab = { input: { uri: solutionUri, viewType: ManageSolutionCustomEditorProvider.viewType } } as unknown as vscode.Tab; + tabGroups.all = [{ tabs: [customTab, textTab] }]; + + registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider); + expect(onDidChangeTabsHandler).toBeDefined(); + + onDidChangeTabsHandler!({ opened: [textTab], closed: [], changed: [] }); + await Promise.resolve(); + + expect(tabGroups.close).toHaveBeenCalledWith([textTab], true); + }); + + it('does not close tabs when single-editor mode is disabled', async () => { + const commandsProvider = commandsProviderFactory(); + const solutionManager = solutionManagerFactory(); + const configurationProvider = configurationProviderFactory(); + configurationProvider.getConfigVariableOrDefault.mockReturnValue(false); + solutionManager.getCsolution.mockReturnValue(csolutionFactory({ solutionPath: solutionUri.fsPath })); + + const textTab = { input: { uri: solutionUri } } as unknown as vscode.Tab; + const customTab = { input: { uri: solutionUri, viewType: ManageSolutionCustomEditorProvider.viewType } } as unknown as vscode.Tab; + tabGroups.all = [{ tabs: [customTab, textTab] }]; + + registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider); + + await commandsProvider.mockRunRegistered(commandOpenUiEditor, solutionUri); + expect(onDidChangeTabsHandler).toBeDefined(); + onDidChangeTabsHandler!({ opened: [textTab], closed: [], changed: [] }); + await Promise.resolve(); + + expect(tabGroups.close).not.toHaveBeenCalled(); + }); +}); diff --git a/src/views/manage-solution/manage-solution-custom-editor.ts b/src/views/manage-solution/manage-solution-custom-editor.ts index 42b70fff..3a920060 100644 --- a/src/views/manage-solution/manage-solution-custom-editor.ts +++ b/src/views/manage-solution/manage-solution-custom-editor.ts @@ -245,7 +245,47 @@ export class ManageSolutionCustomEditorProvider implements vscode.CustomEditorPr export function registerManageSolutionCommand( commandsProvider: CommandsProvider, solutionManager: SolutionManager, + configurationProvider: ConfigurationProvider, ): vscode.Disposable { + type TabInputLike = { + uri?: vscode.Uri; + viewType?: string; + }; + + const getTabInputLike = (input: unknown): TabInputLike | undefined => { + if (!input || typeof input !== 'object') { + return undefined; + } + + return input as TabInputLike; + }; + + const hasSameUri = (left: vscode.Uri, right: vscode.Uri): boolean => left.toString() === right.toString(); + + const isCsolutionUri = (uri: vscode.Uri): boolean => uri.fsPath.endsWith('.csolution.yml'); + + const isTextTabForSolution = (input: unknown, solutionUri: vscode.Uri): boolean => { + const tabInput = getTabInputLike(input); + if (!tabInput?.uri) { + return false; + } + + // Text tabs have URI but no custom editor viewType. + return tabInput.viewType === undefined && hasSameUri(tabInput.uri, solutionUri); + }; + + const isManageSolutionCustomTabForSolution = (input: unknown, solutionUri: vscode.Uri): boolean => { + const tabInput = getTabInputLike(input); + if (!tabInput?.uri || !tabInput.viewType) { + return false; + } + + return tabInput.viewType === ManageSolutionCustomEditorProvider.viewType && hasSameUri(tabInput.uri, solutionUri); + }; + + const isSingleEditorModeEnabled = (): boolean => + configurationProvider.getConfigVariableOrDefault(manifest.CONFIG_MANAGE_SOLUTION_SINGLE_EDITOR, true); + const getSolutionUri = (resource?: vscode.Uri): vscode.Uri | undefined => { if (resource && resource.fsPath.endsWith('.csolution.yml')) { return resource; @@ -264,6 +304,91 @@ export function registerManageSolutionCommand( return undefined; }; + const closeOtherEditorModes = async (solutionUri: vscode.Uri, desiredMode: 'ui' | 'text'): Promise => { + if (!isSingleEditorModeEnabled()) { + return; + } + + const tabGroups = vscode.window.tabGroups; + if (!tabGroups) { + return; + } + + const tabsToClose: vscode.Tab[] = []; + + for (const group of tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input; + if (desiredMode === 'ui' && isTextTabForSolution(input, solutionUri)) { + tabsToClose.push(tab); + } + + if (desiredMode === 'text' && isManageSolutionCustomTabForSolution(input, solutionUri)) { + tabsToClose.push(tab); + } + } + } + + if (tabsToClose.length > 0) { + await tabGroups.close(tabsToClose, true); + } + }; + + const enforceSingleEditorModeForUri = async (solutionUri: vscode.Uri): Promise => { + if (!isSingleEditorModeEnabled()) { + return; + } + + const tabGroups = vscode.window.tabGroups; + if (!tabGroups) { + return; + } + + const textTabs: vscode.Tab[] = []; + let hasCustomTab = false; + + for (const group of tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input; + if (isManageSolutionCustomTabForSolution(input, solutionUri)) { + hasCustomTab = true; + continue; + } + + if (isTextTabForSolution(input, solutionUri)) { + textTabs.push(tab); + } + } + } + + if (hasCustomTab && textTabs.length > 0) { + await tabGroups.close(textTabs, true); + } + }; + + const watchTabChangesForSingleEditorMode = (): vscode.Disposable => { + const tabGroups = vscode.window.tabGroups; + if (!tabGroups || !('onDidChangeTabs' in tabGroups) || typeof tabGroups.onDidChangeTabs !== 'function') { + return { dispose: () => { return; } }; + } + + return tabGroups.onDidChangeTabs((event: vscode.TabChangeEvent) => { + void (async () => { + const candidates = new Map(); + for (const tab of [...event.opened, ...event.changed]) { + const tabInput = getTabInputLike(tab.input); + if (tabInput?.uri && isCsolutionUri(tabInput.uri)) { + candidates.set(tabInput.uri.toString(), tabInput.uri); + } + } + + for (const solutionUri of candidates.values()) { + await enforceSingleEditorModeForUri(solutionUri); + } + })(); + }); + }; + const openUiEditor = async (resource?: vscode.Uri): Promise => { const solutionUri = getSolutionUri(resource); if (!solutionUri) { @@ -271,6 +396,7 @@ export function registerManageSolutionCommand( return; } + await closeOtherEditorModes(solutionUri, 'ui'); await commandsProvider.executeCommand('vscode.openWith', solutionUri, ManageSolutionCustomEditorProvider.viewType); }; @@ -281,10 +407,12 @@ export function registerManageSolutionCommand( return; } + await closeOtherEditorModes(solutionUri, 'text'); await commandsProvider.executeCommand('vscode.openWith', solutionUri, 'default'); }; return vscode.Disposable.from( + watchTabChangesForSingleEditorMode(), commandsProvider.registerCommand( `${manifest.PACKAGE_NAME}.manageSolution`, async () => {