Skip to content

Commit 04d1bf1

Browse files
committed
Open source file with vscode editor or conf Wizard view
1 parent c8f27ed commit 04d1bf1

8 files changed

Lines changed: 320 additions & 17 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 path from 'path';
18+
import { TestDataHandler } from '../__test__/test-data';
19+
import { writeTextFile } from './fs-utils';
20+
import { configWizardAnnotationChecker } from './config-wizard-annotation-checker';
21+
22+
describe('configWizardAnnotationChecker', () => {
23+
const testDataHandler = new TestDataHandler();
24+
let testFolder: string;
25+
26+
beforeEach(() => {
27+
testFolder = testDataHandler.tmpDir;
28+
});
29+
30+
afterEach(() => {
31+
testDataHandler.rmTmpDir();
32+
});
33+
34+
afterAll(() => {
35+
testDataHandler.dispose();
36+
});
37+
38+
it('returns true when start marker is within first 100 lines', async () => {
39+
const filePath = path.join(testFolder, 'within-100.h');
40+
const lines = Array.from({ length: 99 }, () => 'int x = 0;');
41+
lines.push('// <<< Use Configuration Wizard in Context Menu >>>');
42+
writeTextFile(filePath, lines.join('\n'));
43+
44+
await expect(configWizardAnnotationChecker.hasAnnotations(filePath)).resolves.toBe(true);
45+
});
46+
47+
it('returns false when start marker appears after first 100 lines', async () => {
48+
const filePath = path.join(testFolder, 'after-100.h');
49+
const lines = Array.from({ length: 100 }, () => 'int y = 1;');
50+
lines.push('// <<< Use Configuration Wizard in Context Menu >>>');
51+
writeTextFile(filePath, lines.join('\n'));
52+
53+
await expect(configWizardAnnotationChecker.hasAnnotations(filePath)).resolves.toBe(false);
54+
});
55+
56+
it('returns true when start marker is within first 100 lines and end marker is after 100 lines', async () => {
57+
const filePath = path.join(testFolder, 'end-after-100.h');
58+
const lines = Array.from({ length: 39 }, () => 'int cfg = 3;');
59+
lines.push('// <<< Use Configuration Wizard in Context Menu >>>');
60+
lines.push(...Array.from({ length: 90 }, () => 'int body = 4;'));
61+
lines.push('// <<< end of configuration section >>>');
62+
writeTextFile(filePath, lines.join('\n'));
63+
64+
await expect(configWizardAnnotationChecker.hasAnnotations(filePath)).resolves.toBe(true);
65+
});
66+
67+
it('returns false when only end marker is present', async () => {
68+
const filePath = path.join(testFolder, 'end-only.h');
69+
writeTextFile(filePath, '// <<< end of configuration section >>>\nint z = 2;\n');
70+
71+
await expect(configWizardAnnotationChecker.hasAnnotations(filePath)).resolves.toBe(false);
72+
});
73+
74+
it('returns true for decorated marker lines used in template headers', async () => {
75+
const filePath = path.join(testFolder, 'decorated-marker.h');
76+
const lines = [
77+
'/* header */',
78+
'//-------- <<< Use Configuration Wizard in Context Menu >>> --------------------',
79+
'int cfg = 0;'
80+
];
81+
writeTextFile(filePath, lines.join('\n'));
82+
83+
await expect(configWizardAnnotationChecker.hasAnnotations(filePath)).resolves.toBe(true);
84+
});
85+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 { readTextFile } from './fs-utils';
18+
19+
export interface ConfigWizardAnnotationChecker {
20+
hasAnnotations(filePath: string): Promise<boolean>;
21+
}
22+
23+
class ConfigWizardAnnotationCheckerImpl implements ConfigWizardAnnotationChecker {
24+
private static readonly MAX_LINES_TO_SCAN = 100;
25+
private static readonly wizardStartMarkerRegex = /^\s*\/\/.*<<<\s*use configuration wizard in context menu\s*>>>.*$/i;
26+
27+
public async hasAnnotations(filePath: string): Promise<boolean> {
28+
const fileContent = readTextFile(filePath);
29+
const lines = fileContent.split(/\r?\n/);
30+
31+
const maxLines = Math.min(ConfigWizardAnnotationCheckerImpl.MAX_LINES_TO_SCAN, lines.length);
32+
for (let i = 0; i < maxLines; i++) {
33+
if (ConfigWizardAnnotationCheckerImpl.wizardStartMarkerRegex.test(lines[i])) {
34+
return true;
35+
}
36+
}
37+
38+
return false;
39+
}
40+
}
41+
42+
export const configWizardAnnotationChecker: ConfigWizardAnnotationChecker =
43+
new ConfigWizardAnnotationCheckerImpl();

src/utils/config-wizard-checker.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
export type { ConfigWizardAnnotationChecker } from './config-wizard-annotation-checker';
18+
export { configWizardAnnotationChecker } from './config-wizard-annotation-checker';

src/views/solution-outline/commands/open-command.test.ts

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,20 @@ import type { CTreeItem } from '../../../generic/tree-item';
3030
import type { CSolution } from '../../../solutions/csolution';
3131
import { README_FILE_PATH } from '../../../manifest';
3232
import { faker } from '@faker-js/faker';
33+
import { ConfigWizardAnnotationChecker } from '../../../utils/config-wizard-checker';
3334

3435
describe('OpenCommand', () => {
3536
let commandsProvider: MockCommandsProvider;
3637
const mockOpenFileExternal: IOpenFileExternal = openFileExternalFactory();
38+
let mockAnnotationChecker: ConfigWizardAnnotationChecker;
3739
let testFolder: string;
3840
let testFile: string;
3941

4042
beforeEach(async () => {
4143
commandsProvider = commandsProviderFactory();
44+
mockAnnotationChecker = {
45+
hasAnnotations: jest.fn().mockResolvedValue(false),
46+
};
4247
testFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'jest-'));
4348
testFile = `${testFolder}/jtest.txt`;
4449
fs.writeFileSync(testFile, 'test content');
@@ -50,12 +55,12 @@ describe('OpenCommand', () => {
5055
});
5156

5257
it('registers the command on activation', async () => {
53-
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal);
58+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
5459

5560
await openCommand.activate(extensionContextFactory());
5661

5762
const expectedCommands = [OpenCommand.openSolutionCommandId, OpenCommand.openProjectCommandId, OpenCommand.openPrjConfCommandId,
58-
OpenCommand.openLayerCommandId, OpenCommand.openLinkerCommandId, OpenCommand.openDocCommandId, OpenCommand.openHelpCommandId,
63+
OpenCommand.openLayerCommandId, OpenCommand.openLinkerCommandId, OpenCommand.openDocCommandId, OpenCommand.openSourceSmartCommandId, OpenCommand.openHelpCommandId,
5964
OpenCommand.openZephyrTerminalCommandId];
6065

6166
expect(commandsProvider.registerCommand).toHaveBeenCalledTimes(expectedCommands.length);
@@ -69,7 +74,7 @@ describe('OpenCommand', () => {
6974
const mockSolutionManager = solutionManagerFactory({
7075
loadState: activeSolutionLoadStateFactory({ solutionPath: solutionPath }),
7176
});
72-
const openCommand = new OpenCommand(mockSolutionManager, commandsProvider, mockOpenFileExternal);
77+
const openCommand = new OpenCommand(mockSolutionManager, commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
7378
await openCommand.activate(extensionContextFactory());
7479

7580
await commandsProvider.mockRunRegistered(OpenCommand.openSolutionCommandId, solutionPath);
@@ -78,7 +83,7 @@ describe('OpenCommand', () => {
7883
});
7984

8085
it('opens and shows the cproject file from the path', async () => {
81-
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal);
86+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
8287
await openCommand.activate(extensionContextFactory());
8388
const TEST_PROJECT_PATH = path.join('some', 'path', 'to', 'Test.cproject.yml');
8489

@@ -93,7 +98,7 @@ describe('OpenCommand', () => {
9398
});
9499

95100
it('opens and shows the clayer file from the path', async () => {
96-
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal);
101+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
97102
await openCommand.activate(extensionContextFactory());
98103
const TEST_LAYER_PATH = path.join('some', 'path', 'to', 'Test.clayer.yml');
99104

@@ -108,7 +113,7 @@ describe('OpenCommand', () => {
108113
});
109114

110115
it('opens and shows the linker map file from the path', async () => {
111-
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal);
116+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
112117
await openCommand.activate(extensionContextFactory());
113118
const TEST_LINKER_PATH = path.join('some', 'path', 'to', 'myFile.axf.map');
114119

@@ -123,7 +128,7 @@ describe('OpenCommand', () => {
123128
});
124129

125130
it('opens and shows the prj.conf file for West project', async () => {
126-
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal);
131+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
127132
await openCommand.activate(extensionContextFactory());
128133
const TEST_PRJ_CONF_PATH = path.join('some', 'path', 'to', 'zephyr-app', 'prj.conf');
129134

@@ -138,7 +143,7 @@ describe('OpenCommand', () => {
138143
});
139144

140145
it('opens and shows a doc file from the path', async () => {
141-
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal);
146+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
142147
await openCommand.activate(extensionContextFactory());
143148

144149
const fileItem = new COutlineItem('file');
@@ -209,7 +214,7 @@ describe('OpenCommand', () => {
209214
jest.spyOn(vscode.extensions, 'getExtension').mockReturnValue({ extensionPath } as unknown as vscode.Extension<void>);
210215
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
211216

212-
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal);
217+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
213218
await openCommand.activate(extensionContextFactory());
214219

215220
jest.spyOn(mockOpenFileExternal, 'openFile');
@@ -228,7 +233,7 @@ describe('OpenCommand', () => {
228233
const mockSolutionManager = solutionManagerFactory();
229234
jest.spyOn(mockSolutionManager, 'getCsolution').mockReturnValue({ cbuildYmlRoot: mockCbuildMap } as Partial<CSolution> as CSolution);
230235

231-
const openCommand = new OpenCommand(mockSolutionManager, commandsProvider, mockOpenFileExternal);
236+
const openCommand = new OpenCommand(mockSolutionManager, commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
232237
await openCommand.activate(extensionContextFactory());
233238

234239
const node = new COutlineItem('project');
@@ -242,4 +247,57 @@ describe('OpenCommand', () => {
242247
});
243248
expect(mockTerminal.show).toHaveBeenCalled();
244249
});
250+
251+
it('opens candidate source file in configuration wizard when annotations are found', async () => {
252+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
253+
await openCommand.activate(extensionContextFactory());
254+
(mockAnnotationChecker.hasAnnotations as jest.Mock).mockResolvedValue(true);
255+
256+
const uri = Uri.file(path.join(testFolder, 'config.h'));
257+
await commandsProvider.mockRunRegistered(OpenCommand.openSourceSmartCommandId, uri);
258+
259+
const lastCall = commandsProvider.executeCommand.mock.lastCall;
260+
expect(lastCall?.[0]).toBe('vscode.openWith');
261+
expect((lastCall?.[1] as Uri).fsPath).toBe(uri.fsPath);
262+
expect(lastCall?.[2]).toBe('cmsis-csolution.configWizard');
263+
});
264+
265+
it('opens candidate source file in text editor when annotations are missing', async () => {
266+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
267+
await openCommand.activate(extensionContextFactory());
268+
(mockAnnotationChecker.hasAnnotations as jest.Mock).mockResolvedValue(false);
269+
270+
const uri = Uri.file(path.join(testFolder, 'config.h'));
271+
await commandsProvider.mockRunRegistered(OpenCommand.openSourceSmartCommandId, uri);
272+
273+
const lastCall = commandsProvider.executeCommand.mock.lastCall;
274+
expect(lastCall?.[0]).toBe('vscode.open');
275+
expect((lastCall?.[1] as Uri).fsPath).toBe(uri.fsPath);
276+
});
277+
278+
it('falls back to text editor when annotation detection fails', async () => {
279+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
280+
await openCommand.activate(extensionContextFactory());
281+
(mockAnnotationChecker.hasAnnotations as jest.Mock).mockRejectedValue(new Error('parse failure'));
282+
283+
const uri = Uri.file(path.join(testFolder, 'config.h'));
284+
await commandsProvider.mockRunRegistered(OpenCommand.openSourceSmartCommandId, uri);
285+
286+
const lastCall = commandsProvider.executeCommand.mock.lastCall;
287+
expect(lastCall?.[0]).toBe('vscode.open');
288+
expect((lastCall?.[1] as Uri).fsPath).toBe(uri.fsPath);
289+
});
290+
291+
it('opens non-candidate source file in text editor without annotation checks', async () => {
292+
const openCommand = new OpenCommand(solutionManagerFactory(), commandsProvider, mockOpenFileExternal, mockAnnotationChecker);
293+
await openCommand.activate(extensionContextFactory());
294+
295+
const uri = Uri.file(path.join(testFolder, 'build.txt'));
296+
await commandsProvider.mockRunRegistered(OpenCommand.openSourceSmartCommandId, uri);
297+
298+
expect(mockAnnotationChecker.hasAnnotations).not.toHaveBeenCalled();
299+
const lastCall = commandsProvider.executeCommand.mock.lastCall;
300+
expect(lastCall?.[0]).toBe('vscode.open');
301+
expect((lastCall?.[1] as Uri).fsPath).toBe(uri.fsPath);
302+
});
245303
});

0 commit comments

Comments
 (0)