Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
2 changes: 1 addition & 1 deletion src/desktop/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export const activate = async (context: ExtensionContext): Promise<CsolutionExte
supportsMultipleEditorsPerDocument: false,
}
));
context.subscriptions.push(registerManageSolutionCommand(commandsProvider, solutionManager));
context.subscriptions.push(registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider));
const getBinaryFile = new BinaryFileLocator(solutionManager, commandsProvider);
const compileCommandsParser = new CompileCommandsParser(workspaceFsProvider);
const clangdManager = new ClangdManager(solutionManager, configurationProvider, armclangDefineGetter, compileCommandsParser, workspaceFsProvider, commandsProvider);
Expand Down
1 change: 1 addition & 0 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const CONFIG_DOWNLOAD_MISSING_PACKS = 'downloadPacks';
export const OUTPUT_DIRECTORY = 'outputDirectory';
export const CONFIG_AUTO_DEBUG_LAUNCH = 'autoDebugLaunch';
export const CONFIG_AUTO_SHOW_CMSIS_VIEW = 'autoShowCMSISView';
export const CONFIG_MANAGE_SOLUTION_SINGLE_EDITOR = 'manageSolutionSingleEditor';
export const CONFIG_BUILD_OUTPUT_VERBOSITY = 'buildOutputVerbosity';
export const MANAGE_COMPONENTS_PACKS_COMMAND_ID = `${PACKAGE_NAME}.manageComponentsPacks`;
export const MERGE_FILE_COMMAND_ID = `${PACKAGE_NAME}.mergeFile`;
Expand Down
140 changes: 140 additions & 0 deletions src/views/manage-solution/manage-solution-custom-editor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Copyright 2026 Arm Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import 'jest';
import * as vscode from 'vscode';
import * as manifest from '../../manifest';
import { csolutionFactory } from '../../solutions/csolution.factory';
import { solutionManagerFactory } from '../../solutions/solution-manager.factories';
import { commandsProviderFactory } from '../../vscode-api/commands-provider.factories';
import { configurationProviderFactory } from '../../vscode-api/configuration-provider.factories';
import { ManageSolutionCustomEditorProvider, registerManageSolutionCommand } from './manage-solution-custom-editor';

type MockTabGroups = {
all: Array<{ tabs: vscode.Tab[] }>;
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();
});
});
128 changes: 128 additions & 0 deletions src/views/manage-solution/manage-solution-custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(manifest.CONFIG_MANAGE_SOLUTION_SINGLE_EDITOR, true);

const getSolutionUri = (resource?: vscode.Uri): vscode.Uri | undefined => {
if (resource && resource.fsPath.endsWith('.csolution.yml')) {
return resource;
Expand All @@ -264,13 +304,99 @@ export function registerManageSolutionCommand(
return undefined;
};

const closeOtherEditorModes = async (solutionUri: vscode.Uri, desiredMode: 'ui' | 'text'): Promise<void> => {
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<void> => {
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<string, vscode.Uri>();
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<void> => {
const solutionUri = getSolutionUri(resource);
if (!solutionUri) {
void vscode.window.showWarningMessage('No active solution. Open a .csolution.yml first.');
return;
}

await closeOtherEditorModes(solutionUri, 'ui');
await commandsProvider.executeCommand('vscode.openWith', solutionUri, ManageSolutionCustomEditorProvider.viewType);
};

Expand All @@ -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 () => {
Expand Down
Loading