Skip to content

Commit 9ee87a3

Browse files
committed
Implement single editor mode for Manage Solution UI and text editor
1 parent e2df23c commit 9ee87a3

5 files changed

Lines changed: 275 additions & 1 deletion

File tree

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,11 @@
13571357
"title": "Show CMSIS View",
13581358
"description": "Automatically shows the CMSIS view in the Side Bar when a CMSIS solution is loaded."
13591359
},
1360+
"cmsis-csolution.manageSolutionSingleEditor": {
1361+
"type": "boolean",
1362+
"default": true,
1363+
"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."
1364+
},
13601365
"cmsis-csolution.probe-id": {
13611366
"type": "string",
13621367
"default": "",

src/desktop/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ export const activate = async (context: ExtensionContext): Promise<CsolutionExte
240240
supportsMultipleEditorsPerDocument: false,
241241
}
242242
));
243-
context.subscriptions.push(registerManageSolutionCommand(commandsProvider, solutionManager));
243+
context.subscriptions.push(registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider));
244244
const getBinaryFile = new BinaryFileLocator(solutionManager, commandsProvider);
245245
const compileCommandsParser = new CompileCommandsParser(workspaceFsProvider);
246246
const clangdManager = new ClangdManager(solutionManager, configurationProvider, armclangDefineGetter, compileCommandsParser, workspaceFsProvider, commandsProvider);

src/manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const CONFIG_DOWNLOAD_MISSING_PACKS = 'downloadPacks';
4343
export const OUTPUT_DIRECTORY = 'outputDirectory';
4444
export const CONFIG_AUTO_DEBUG_LAUNCH = 'autoDebugLaunch';
4545
export const CONFIG_AUTO_SHOW_CMSIS_VIEW = 'autoShowCMSISView';
46+
export const CONFIG_MANAGE_SOLUTION_SINGLE_EDITOR = 'manageSolutionSingleEditor';
4647
export const CONFIG_BUILD_OUTPUT_VERBOSITY = 'buildOutputVerbosity';
4748
export const MANAGE_COMPONENTS_PACKS_COMMAND_ID = `${PACKAGE_NAME}.manageComponentsPacks`;
4849
export const MERGE_FILE_COMMAND_ID = `${PACKAGE_NAME}.mergeFile`;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Copyright 2026 Arm Limited
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import 'jest';
18+
import * as vscode from 'vscode';
19+
import * as manifest from '../../manifest';
20+
import { csolutionFactory } from '../../solutions/csolution.factory';
21+
import { solutionManagerFactory } from '../../solutions/solution-manager.factories';
22+
import { commandsProviderFactory } from '../../vscode-api/commands-provider.factories';
23+
import { configurationProviderFactory } from '../../vscode-api/configuration-provider.factories';
24+
import { ManageSolutionCustomEditorProvider, registerManageSolutionCommand } from './manage-solution-custom-editor';
25+
26+
type MockTabGroups = {
27+
all: Array<{ tabs: vscode.Tab[] }>;
28+
close: jest.Mock;
29+
onDidChangeTabs: jest.Mock;
30+
};
31+
32+
describe('registerManageSolutionCommand', () => {
33+
const solutionUri = vscode.Uri.file('/workspace/folder/solution.csolution.yml');
34+
const commandOpenUiEditor = `${manifest.PACKAGE_NAME}.openManageSolutionUiEditor`;
35+
const commandOpenTextEditor = `${manifest.PACKAGE_NAME}.openManageSolutionTextEditor`;
36+
37+
let tabGroups: MockTabGroups;
38+
let onDidChangeTabsHandler: ((event: vscode.TabChangeEvent) => void) | undefined;
39+
40+
beforeEach(() => {
41+
onDidChangeTabsHandler = undefined;
42+
tabGroups = {
43+
all: [],
44+
close: jest.fn().mockResolvedValue(undefined),
45+
onDidChangeTabs: jest.fn().mockImplementation((cb: (event: vscode.TabChangeEvent) => void) => {
46+
onDidChangeTabsHandler = cb;
47+
return { dispose: jest.fn() };
48+
}),
49+
};
50+
51+
(vscode.window as unknown as { tabGroups: MockTabGroups }).tabGroups = tabGroups;
52+
});
53+
54+
afterEach(() => {
55+
jest.clearAllMocks();
56+
});
57+
58+
it('closes text tab for same solution when opening UI editor in single-editor mode', async () => {
59+
const commandsProvider = commandsProviderFactory();
60+
const solutionManager = solutionManagerFactory();
61+
const configurationProvider = configurationProviderFactory();
62+
configurationProvider.getConfigVariableOrDefault.mockReturnValue(true);
63+
solutionManager.getCsolution.mockReturnValue(csolutionFactory({ solutionPath: solutionUri.fsPath }));
64+
65+
const textTab = { input: { uri: solutionUri } } as unknown as vscode.Tab;
66+
const customTab = { input: { uri: solutionUri, viewType: ManageSolutionCustomEditorProvider.viewType } } as unknown as vscode.Tab;
67+
tabGroups.all = [{ tabs: [textTab, customTab] }];
68+
69+
registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider);
70+
71+
await commandsProvider.mockRunRegistered(commandOpenUiEditor, solutionUri);
72+
73+
expect(tabGroups.close).toHaveBeenCalledWith([textTab], true);
74+
expect(commandsProvider.executeCommand).toHaveBeenCalledWith(
75+
'vscode.openWith',
76+
solutionUri,
77+
ManageSolutionCustomEditorProvider.viewType
78+
);
79+
});
80+
81+
it('closes manage solution custom tab for same solution when opening text editor in single-editor mode', async () => {
82+
const commandsProvider = commandsProviderFactory();
83+
const solutionManager = solutionManagerFactory();
84+
const configurationProvider = configurationProviderFactory();
85+
configurationProvider.getConfigVariableOrDefault.mockReturnValue(true);
86+
solutionManager.getCsolution.mockReturnValue(csolutionFactory({ solutionPath: solutionUri.fsPath }));
87+
88+
const textTab = { input: { uri: solutionUri } } as unknown as vscode.Tab;
89+
const customTab = { input: { uri: solutionUri, viewType: ManageSolutionCustomEditorProvider.viewType } } as unknown as vscode.Tab;
90+
tabGroups.all = [{ tabs: [textTab, customTab] }];
91+
92+
registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider);
93+
94+
await commandsProvider.mockRunRegistered(commandOpenTextEditor, solutionUri);
95+
96+
expect(tabGroups.close).toHaveBeenCalledWith([customTab], true);
97+
expect(commandsProvider.executeCommand).toHaveBeenCalledWith('vscode.openWith', solutionUri, 'default');
98+
});
99+
100+
it('closes newly opened text tab from explorer when webview tab already exists', async () => {
101+
const commandsProvider = commandsProviderFactory();
102+
const solutionManager = solutionManagerFactory();
103+
const configurationProvider = configurationProviderFactory();
104+
configurationProvider.getConfigVariableOrDefault.mockReturnValue(true);
105+
solutionManager.getCsolution.mockReturnValue(csolutionFactory({ solutionPath: solutionUri.fsPath }));
106+
107+
const textTab = { input: { uri: solutionUri } } as unknown as vscode.Tab;
108+
const customTab = { input: { uri: solutionUri, viewType: ManageSolutionCustomEditorProvider.viewType } } as unknown as vscode.Tab;
109+
tabGroups.all = [{ tabs: [customTab, textTab] }];
110+
111+
registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider);
112+
expect(onDidChangeTabsHandler).toBeDefined();
113+
114+
onDidChangeTabsHandler!({ opened: [textTab], closed: [], changed: [] });
115+
await Promise.resolve();
116+
117+
expect(tabGroups.close).toHaveBeenCalledWith([textTab], true);
118+
});
119+
120+
it('does not close tabs when single-editor mode is disabled', async () => {
121+
const commandsProvider = commandsProviderFactory();
122+
const solutionManager = solutionManagerFactory();
123+
const configurationProvider = configurationProviderFactory();
124+
configurationProvider.getConfigVariableOrDefault.mockReturnValue(false);
125+
solutionManager.getCsolution.mockReturnValue(csolutionFactory({ solutionPath: solutionUri.fsPath }));
126+
127+
const textTab = { input: { uri: solutionUri } } as unknown as vscode.Tab;
128+
const customTab = { input: { uri: solutionUri, viewType: ManageSolutionCustomEditorProvider.viewType } } as unknown as vscode.Tab;
129+
tabGroups.all = [{ tabs: [customTab, textTab] }];
130+
131+
registerManageSolutionCommand(commandsProvider, solutionManager, configurationProvider);
132+
133+
await commandsProvider.mockRunRegistered(commandOpenUiEditor, solutionUri);
134+
expect(onDidChangeTabsHandler).toBeDefined();
135+
onDidChangeTabsHandler!({ opened: [textTab], closed: [], changed: [] });
136+
await Promise.resolve();
137+
138+
expect(tabGroups.close).not.toHaveBeenCalled();
139+
});
140+
});

src/views/manage-solution/manage-solution-custom-editor.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,47 @@ export class ManageSolutionCustomEditorProvider implements vscode.CustomEditorPr
245245
export function registerManageSolutionCommand(
246246
commandsProvider: CommandsProvider,
247247
solutionManager: SolutionManager,
248+
configurationProvider: ConfigurationProvider,
248249
): vscode.Disposable {
250+
type TabInputLike = {
251+
uri?: vscode.Uri;
252+
viewType?: string;
253+
};
254+
255+
const getTabInputLike = (input: unknown): TabInputLike | undefined => {
256+
if (!input || typeof input !== 'object') {
257+
return undefined;
258+
}
259+
260+
return input as TabInputLike;
261+
};
262+
263+
const hasSameUri = (left: vscode.Uri, right: vscode.Uri): boolean => left.toString() === right.toString();
264+
265+
const isCsolutionUri = (uri: vscode.Uri): boolean => uri.fsPath.endsWith('.csolution.yml');
266+
267+
const isTextTabForSolution = (input: unknown, solutionUri: vscode.Uri): boolean => {
268+
const tabInput = getTabInputLike(input);
269+
if (!tabInput?.uri) {
270+
return false;
271+
}
272+
273+
// Text tabs have URI but no custom editor viewType.
274+
return tabInput.viewType === undefined && hasSameUri(tabInput.uri, solutionUri);
275+
};
276+
277+
const isManageSolutionCustomTabForSolution = (input: unknown, solutionUri: vscode.Uri): boolean => {
278+
const tabInput = getTabInputLike(input);
279+
if (!tabInput?.uri || !tabInput.viewType) {
280+
return false;
281+
}
282+
283+
return tabInput.viewType === ManageSolutionCustomEditorProvider.viewType && hasSameUri(tabInput.uri, solutionUri);
284+
};
285+
286+
const isSingleEditorModeEnabled = (): boolean =>
287+
configurationProvider.getConfigVariableOrDefault<boolean>(manifest.CONFIG_MANAGE_SOLUTION_SINGLE_EDITOR, true);
288+
249289
const getSolutionUri = (resource?: vscode.Uri): vscode.Uri | undefined => {
250290
if (resource && resource.fsPath.endsWith('.csolution.yml')) {
251291
return resource;
@@ -264,13 +304,99 @@ export function registerManageSolutionCommand(
264304
return undefined;
265305
};
266306

307+
const closeOtherEditorModes = async (solutionUri: vscode.Uri, desiredMode: 'ui' | 'text'): Promise<void> => {
308+
if (!isSingleEditorModeEnabled()) {
309+
return;
310+
}
311+
312+
const tabGroups = vscode.window.tabGroups;
313+
if (!tabGroups) {
314+
return;
315+
}
316+
317+
const tabsToClose: vscode.Tab[] = [];
318+
319+
for (const group of tabGroups.all) {
320+
for (const tab of group.tabs) {
321+
const input = tab.input;
322+
if (desiredMode === 'ui' && isTextTabForSolution(input, solutionUri)) {
323+
tabsToClose.push(tab);
324+
}
325+
326+
if (desiredMode === 'text' && isManageSolutionCustomTabForSolution(input, solutionUri)) {
327+
tabsToClose.push(tab);
328+
}
329+
}
330+
}
331+
332+
if (tabsToClose.length > 0) {
333+
await tabGroups.close(tabsToClose, true);
334+
}
335+
};
336+
337+
const enforceSingleEditorModeForUri = async (solutionUri: vscode.Uri): Promise<void> => {
338+
if (!isSingleEditorModeEnabled()) {
339+
return;
340+
}
341+
342+
const tabGroups = vscode.window.tabGroups;
343+
if (!tabGroups) {
344+
return;
345+
}
346+
347+
const textTabs: vscode.Tab[] = [];
348+
let hasCustomTab = false;
349+
350+
for (const group of tabGroups.all) {
351+
for (const tab of group.tabs) {
352+
const input = tab.input;
353+
if (isManageSolutionCustomTabForSolution(input, solutionUri)) {
354+
hasCustomTab = true;
355+
continue;
356+
}
357+
358+
if (isTextTabForSolution(input, solutionUri)) {
359+
textTabs.push(tab);
360+
}
361+
}
362+
}
363+
364+
if (hasCustomTab && textTabs.length > 0) {
365+
await tabGroups.close(textTabs, true);
366+
}
367+
};
368+
369+
const watchTabChangesForSingleEditorMode = (): vscode.Disposable => {
370+
const tabGroups = vscode.window.tabGroups;
371+
if (!tabGroups || !('onDidChangeTabs' in tabGroups) || typeof tabGroups.onDidChangeTabs !== 'function') {
372+
return { dispose: () => { return; } };
373+
}
374+
375+
return tabGroups.onDidChangeTabs((event: vscode.TabChangeEvent) => {
376+
void (async () => {
377+
const candidates = new Map<string, vscode.Uri>();
378+
for (const tab of [...event.opened, ...event.changed]) {
379+
const tabInput = getTabInputLike(tab.input);
380+
if (tabInput?.uri && isCsolutionUri(tabInput.uri)) {
381+
candidates.set(tabInput.uri.toString(), tabInput.uri);
382+
}
383+
}
384+
385+
for (const solutionUri of candidates.values()) {
386+
await enforceSingleEditorModeForUri(solutionUri);
387+
}
388+
})();
389+
});
390+
};
391+
267392
const openUiEditor = async (resource?: vscode.Uri): Promise<void> => {
268393
const solutionUri = getSolutionUri(resource);
269394
if (!solutionUri) {
270395
void vscode.window.showWarningMessage('No active solution. Open a .csolution.yml first.');
271396
return;
272397
}
273398

399+
await closeOtherEditorModes(solutionUri, 'ui');
274400
await commandsProvider.executeCommand('vscode.openWith', solutionUri, ManageSolutionCustomEditorProvider.viewType);
275401
};
276402

@@ -281,10 +407,12 @@ export function registerManageSolutionCommand(
281407
return;
282408
}
283409

410+
await closeOtherEditorModes(solutionUri, 'text');
284411
await commandsProvider.executeCommand('vscode.openWith', solutionUri, 'default');
285412
};
286413

287414
return vscode.Disposable.from(
415+
watchTabChangesForSingleEditorMode(),
288416
commandsProvider.registerCommand(
289417
`${manifest.PACKAGE_NAME}.manageSolution`,
290418
async () => {

0 commit comments

Comments
 (0)