Skip to content

Commit 7634898

Browse files
mguzmanmmaribelguzmanmedriouk
authored
Add Edit command to navigate from outline to specific entries in .cproject.yaml (#141)
* Add edit to group context menu * Add edit to file context menu * Remove openProjectFile * Add Edit command for components * Update unit test --------- Co-authored-by: Maribel <maribel.guzmanm@gmail.com> Co-authored-by: Evgueni Driouk <edriouk@arm.com>
1 parent 9b44ed2 commit 7634898

7 files changed

Lines changed: 438 additions & 41 deletions

File tree

package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,10 @@
784784
"command": "cmsis-csolution.list.find",
785785
"title": "Search"
786786
},
787+
{
788+
"command": "cmsis-csolution.edit",
789+
"title": "Edit"
790+
},
787791
{
788792
"command": "cmsis-csolution.showSolutionOutline",
789793
"title": "Show Solution Outline"
@@ -929,6 +933,10 @@
929933
"command": "cmsis-csolution.addToGroup",
930934
"when": "false"
931935
},
936+
{
937+
"command": "cmsis-csolution.edit",
938+
"when": "false"
939+
},
932940
{
933941
"command": "cmsis-csolution.showConfigWizardPreview",
934942
"when": "false"
@@ -1145,6 +1153,11 @@
11451153
"when": "view == cmsis-csolution.outline && viewItem =~ /group|file/",
11461154
"group": "contextMenu"
11471155
},
1156+
{
1157+
"command": "cmsis-csolution.edit",
1158+
"when": "view == cmsis-csolution.outline && viewItem =~ /(^|;)(group|file|component)(;|$)/",
1159+
"group": "contextMenu"
1160+
},
11481161
{
11491162
"command": "cmsis-csolution.runGenerator",
11501163
"when": "view == cmsis-csolution.outline && viewItem =~ /component-gen/",

src/desktop/extension.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { CreateSolutionWebviewMain } from '../views/create-solutions/create-solu
5555
import { ManageLayersWebviewMain } from '../views/manage-layers/manage-layers-webview-main';
5656
import { AddToGroupCommand } from '../views/solution-outline/commands/add-to-group-command';
5757
import { DeleteCommand } from '../views/solution-outline/commands/delete-command';
58+
import { EditCommand } from '../views/solution-outline/commands/edit-command';
5859
import { OpenCommand } from '../views/solution-outline/commands/open-command';
5960
import { FindCommand } from '../views/solution-outline/commands/find-command';
6061
import { MergeCommand } from '../views/solution-outline/commands/merge-command';
@@ -210,6 +211,7 @@ export const activate = async (context: ExtensionContext): Promise<CsolutionExte
210211

211212
const addToGroupCommand = new AddToGroupCommand(workspaceFsProvider, commandsProvider, solutionManager);
212213
const deleteCommand = new DeleteCommand(commandsProvider, workspaceFsProvider);
214+
const editCommand = new EditCommand(commandsProvider);
213215
const copyHeaderCommand = new CopyHeaderCommand(commandsProvider);
214216
const openCommand = new OpenCommand(solutionManager, commandsProvider, externalFileOpener);
215217
const findCommand = new FindCommand(commandsProvider);
@@ -273,6 +275,7 @@ export const activate = async (context: ExtensionContext): Promise<CsolutionExte
273275
solutionLanguageFeatures.activate(context),
274276
addToGroupCommand.activate(context),
275277
deleteCommand.activate(context),
278+
editCommand.activate(context),
276279
copyHeaderCommand.activate(context),
277280
openCommand.activate(context),
278281
findCommand.activate(context),
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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 'node:path';
18+
import * as vscode from 'vscode';
19+
import { EditCommand } from './edit-command';
20+
import { extensionContextFactory } from '../../../vscode-api/extension-context.factories';
21+
import { commandsProviderFactory, MockCommandsProvider } from '../../../vscode-api/commands-provider.factories';
22+
import { COutlineItem } from '../tree-structure/solution-outline-item';
23+
import { TestDataHandler } from '../../../__test__/test-data';
24+
import * as fsUtils from '../../../utils/fs-utils';
25+
26+
describe('EditCommand', () => {
27+
let commandsProvider: MockCommandsProvider;
28+
let cprojectPath: string;
29+
let yamlContent: string;
30+
let positionAt: jest.Mock;
31+
const testDataHandler = new TestDataHandler();
32+
33+
beforeEach(() => {
34+
commandsProvider = commandsProviderFactory();
35+
cprojectPath = path.join(testDataHandler.tmpDir, 'test.cproject.yml');
36+
37+
yamlContent = `project:
38+
groups:
39+
- group: App
40+
files:
41+
- file: src/main.c
42+
groups:
43+
- group: Drivers
44+
files:
45+
- file: drv.c
46+
components:
47+
- component: ARM::CMSIS:RTOS2
48+
`;
49+
50+
fsUtils.writeTextFile(cprojectPath, yamlContent);
51+
52+
positionAt = jest.fn().mockReturnValue(new vscode.Position(0, 0));
53+
jest.spyOn(vscode.workspace, 'openTextDocument').mockResolvedValue({ positionAt } as unknown as vscode.TextDocument);
54+
jest.spyOn(vscode.window, 'showTextDocument').mockResolvedValue({} as vscode.TextEditor);
55+
});
56+
57+
afterEach(() => {
58+
jest.restoreAllMocks();
59+
testDataHandler.rmFile(cprojectPath);
60+
});
61+
62+
afterAll(() => {
63+
testDataHandler.dispose();
64+
});
65+
66+
it('registers the edit command on activation', async () => {
67+
const editCommand = new EditCommand(commandsProvider);
68+
69+
await editCommand.activate(extensionContextFactory());
70+
71+
expect(commandsProvider.registerCommand).toHaveBeenCalledWith(
72+
EditCommand.editCommandId,
73+
expect.any(Function),
74+
editCommand,
75+
);
76+
});
77+
78+
it('opens cproject.yml at the selected group entry', async () => {
79+
const editCommand = new EditCommand(commandsProvider);
80+
await editCommand.activate(extensionContextFactory());
81+
82+
const groupNode = new COutlineItem('group');
83+
groupNode.setAttribute('groupPath', 'App;Drivers');
84+
groupNode.setAttribute('projectUri', cprojectPath);
85+
86+
await commandsProvider.mockRunRegistered(EditCommand.editCommandId, groupNode);
87+
88+
expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(vscode.Uri.file(cprojectPath));
89+
expect(positionAt).toHaveBeenCalledTimes(1);
90+
const actualOffset = positionAt.mock.calls[0][0] as number;
91+
expect(actualOffset).toBeGreaterThanOrEqual(0);
92+
expect(yamlContent.slice(actualOffset)).toContain('group: Drivers');
93+
expect(vscode.window.showTextDocument).toHaveBeenCalled();
94+
});
95+
96+
it('opens cproject.yml at the selected file entry', async () => {
97+
const editCommand = new EditCommand(commandsProvider);
98+
await editCommand.activate(extensionContextFactory());
99+
100+
const groupNode = new COutlineItem('group');
101+
groupNode.setAttribute('groupPath', 'App;Drivers');
102+
groupNode.setAttribute('projectUri', cprojectPath);
103+
104+
const fileNode = groupNode.createChild('file');
105+
fileNode.setAttribute('projectUri', cprojectPath);
106+
fileNode.setAttribute('fileUri', 'drv.c');
107+
108+
await commandsProvider.mockRunRegistered(EditCommand.editCommandId, fileNode);
109+
110+
expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(vscode.Uri.file(cprojectPath));
111+
expect(positionAt).toHaveBeenCalledTimes(1);
112+
const actualOffset = positionAt.mock.calls[0][0] as number;
113+
expect(actualOffset).toBeGreaterThanOrEqual(0);
114+
expect(yamlContent.slice(actualOffset)).toContain('file: drv.c');
115+
expect(vscode.window.showTextDocument).toHaveBeenCalled();
116+
});
117+
118+
it('opens cproject.yml at the selected component entry', async () => {
119+
const editCommand = new EditCommand(commandsProvider);
120+
await editCommand.activate(extensionContextFactory());
121+
122+
const componentNode = new COutlineItem('component');
123+
componentNode.setAttribute('label', 'ARM::CMSIS:RTOS2');
124+
componentNode.setAttribute('projectUri', cprojectPath);
125+
126+
await commandsProvider.mockRunRegistered(EditCommand.editCommandId, componentNode);
127+
128+
expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(vscode.Uri.file(cprojectPath));
129+
expect(positionAt).toHaveBeenCalledTimes(1);
130+
const actualOffset = positionAt.mock.calls[0][0] as number;
131+
expect(actualOffset).toBeGreaterThanOrEqual(0);
132+
expect(yamlContent.slice(actualOffset)).toContain('component: ARM::CMSIS:RTOS2');
133+
expect(vscode.window.showTextDocument).toHaveBeenCalled();
134+
});
135+
136+
it('ignores file nodes outside editable groups', async () => {
137+
const editCommand = new EditCommand(commandsProvider);
138+
await editCommand.activate(extensionContextFactory());
139+
140+
const nonEditableNode = new COutlineItem('file');
141+
nonEditableNode.setAttribute('fileUri', 'out/test.map');
142+
143+
await commandsProvider.mockRunRegistered(EditCommand.editCommandId, nonEditableNode);
144+
145+
expect(vscode.workspace.openTextDocument).not.toHaveBeenCalled();
146+
expect(vscode.window.showTextDocument).not.toHaveBeenCalled();
147+
});
148+
149+
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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 * as vscode from 'vscode';
18+
import * as yaml from 'yaml';
19+
import { CommandsProvider } from '../../../vscode-api/commands-provider';
20+
import * as manifest from '../../../manifest';
21+
import { COutlineItem } from '../tree-structure/solution-outline-item';
22+
import { getGroupPathArray } from '../utils';
23+
import { buildPathFromContentToGroup } from '../../../solutions/edit/manage-group-items';
24+
import { getYamlNodeAtPath, listItem, mapKey } from '../../../solutions/edit/edit-yaml';
25+
import { readTextFile } from '../../../utils/fs-utils';
26+
27+
export class EditCommand {
28+
public static readonly editCommandId = `${manifest.PACKAGE_NAME}.edit`;
29+
30+
constructor(
31+
private readonly commandsProvider: CommandsProvider,
32+
) { }
33+
34+
public async activate(context: Pick<vscode.ExtensionContext, 'subscriptions'>) {
35+
context.subscriptions.push(
36+
this.commandsProvider.registerCommand(EditCommand.editCommandId, async (node: COutlineItem) => {
37+
await this.editNode(node);
38+
}, this),
39+
);
40+
}
41+
42+
private async editNode(node: COutlineItem): Promise<void> {
43+
const tag = node.getTag();
44+
if (tag !== 'group' && tag !== 'file' && tag !== 'component') {
45+
return;
46+
}
47+
48+
const filePath = node.originFilePath;
49+
if (!filePath) {
50+
return;
51+
}
52+
53+
const groupPath = this.getGroupPathForNode(node);
54+
if ((tag === 'group' || tag === 'file') && groupPath.length === 0) {
55+
return;
56+
}
57+
58+
const parentType = this.getParentTypeFromNode(node);
59+
const offset = tag === 'group'
60+
? this.findGroupOffset(filePath, parentType, groupPath)
61+
: tag === 'file'
62+
? this.findFileOffset(filePath, parentType, groupPath, node.getAttribute('fileUri'))
63+
: this.findComponentOffset(filePath, parentType, node.getAttribute('label'));
64+
65+
if (offset === undefined) {
66+
return;
67+
}
68+
69+
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath));
70+
const position = document.positionAt(offset);
71+
await vscode.window.showTextDocument(document, {
72+
selection: new vscode.Range(position, position),
73+
preview: false,
74+
});
75+
}
76+
77+
private getGroupPathForNode(node: COutlineItem): string[] {
78+
if (node.getTag() === 'group') {
79+
return getGroupPathArray(node);
80+
}
81+
82+
const parent = node.getParent();
83+
if (!parent || !(parent instanceof COutlineItem) || parent.getTag() !== 'group') {
84+
return [];
85+
}
86+
87+
return getGroupPathArray(parent);
88+
}
89+
90+
private findGroupOffset(filePath: string, parentType: 'project' | 'layer', groupPath: string[]): number | undefined {
91+
const input = readTextFile(filePath);
92+
if (!input) {
93+
return undefined;
94+
}
95+
96+
const yamlDocument = yaml.parseDocument(input);
97+
const contents = yamlDocument.contents;
98+
if (!contents) {
99+
return undefined;
100+
}
101+
102+
const targetGroupName = groupPath[groupPath.length - 1];
103+
if (!targetGroupName) {
104+
return undefined;
105+
}
106+
107+
const pathToParentGroup = buildPathFromContentToGroup(groupPath.slice(0, -1), [mapKey(parentType)]);
108+
const pathToTargetGroup = [
109+
...pathToParentGroup,
110+
mapKey('groups'),
111+
listItem(item => yaml.isMap(item) && item.get('group') === targetGroupName),
112+
];
113+
114+
const targetGroupNode = getYamlNodeAtPath(contents, pathToTargetGroup);
115+
return (targetGroupNode && yaml.isNode(targetGroupNode) && targetGroupNode.range)
116+
? targetGroupNode.range[0]
117+
: undefined;
118+
}
119+
120+
private findFileOffset(filePath: string, parentType: 'project' | 'layer', groupPath: string[], fileUri?: string): number | undefined {
121+
if (!fileUri) {
122+
return undefined;
123+
}
124+
125+
const input = readTextFile(filePath);
126+
if (!input) {
127+
return undefined;
128+
}
129+
130+
const yamlDocument = yaml.parseDocument(input);
131+
const contents = yamlDocument.contents;
132+
if (!contents) {
133+
return undefined;
134+
}
135+
136+
const pathToTargetFile = [
137+
...buildPathFromContentToGroup(groupPath, [mapKey(parentType)]),
138+
mapKey('files'),
139+
listItem(item => yaml.isMap(item) && item.get('file') === fileUri),
140+
];
141+
142+
const targetFileNode = getYamlNodeAtPath(contents, pathToTargetFile);
143+
return (targetFileNode && yaml.isNode(targetFileNode) && targetFileNode.range)
144+
? targetFileNode.range[0]
145+
: undefined;
146+
}
147+
148+
private findComponentOffset(filePath: string, parentType: 'project' | 'layer', componentId?: string): number | undefined {
149+
if (!componentId) {
150+
return undefined;
151+
}
152+
153+
const input = readTextFile(filePath);
154+
if (!input) {
155+
return undefined;
156+
}
157+
158+
const yamlDocument = yaml.parseDocument(input);
159+
const contents = yamlDocument.contents;
160+
if (!contents) {
161+
return undefined;
162+
}
163+
164+
const pathToTargetComponent = [
165+
mapKey(parentType),
166+
mapKey('components'),
167+
listItem(item => yaml.isMap(item) && item.get('component') === componentId),
168+
];
169+
170+
const targetComponentNode = getYamlNodeAtPath(contents, pathToTargetComponent);
171+
return (targetComponentNode && yaml.isNode(targetComponentNode) && targetComponentNode.range)
172+
? targetComponentNode.range[0]
173+
: undefined;
174+
}
175+
176+
private getParentTypeFromNode(node: COutlineItem): 'project' | 'layer' {
177+
return node.getAttribute('layerUri') ? 'layer' : 'project';
178+
}
179+
}

0 commit comments

Comments
 (0)