Skip to content

Commit 47a92b4

Browse files
maribelguzmanmmguzmanm
authored andcommitted
Add edit to file context menu
1 parent 6af2a32 commit 47a92b4

3 files changed

Lines changed: 179 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1155,7 +1155,7 @@
11551155
},
11561156
{
11571157
"command": "cmsis-csolution.edit",
1158-
"when": "view == cmsis-csolution.outline && viewItem =~ /group/",
1158+
"when": "view == cmsis-csolution.outline && viewItem =~ /group|file/",
11591159
"group": "contextMenu"
11601160
},
11611161
{
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 fs from 'node:fs';
18+
import os from 'node:os';
19+
import path from 'node:path';
20+
import * as vscode from 'vscode';
21+
import { EditCommand } from './edit-command';
22+
import { extensionContextFactory } from '../../../vscode-api/extension-context.factories';
23+
import { commandsProviderFactory, MockCommandsProvider } from '../../../vscode-api/commands-provider.factories';
24+
import { COutlineItem } from '../tree-structure/solution-outline-item';
25+
26+
describe('EditCommand', () => {
27+
let commandsProvider: MockCommandsProvider;
28+
let tmpDir: string;
29+
let cprojectPath: string;
30+
let yamlContent: string;
31+
let positionAt: jest.Mock;
32+
33+
beforeEach(() => {
34+
commandsProvider = commandsProviderFactory();
35+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-command-'));
36+
cprojectPath = path.join(tmpDir, 'test.cproject.yml');
37+
38+
yamlContent = `project:
39+
groups:
40+
- group: App
41+
files:
42+
- file: src/main.c
43+
groups:
44+
- group: Drivers
45+
files:
46+
- file: drv.c
47+
`;
48+
49+
fs.writeFileSync(cprojectPath, yamlContent, 'utf8');
50+
51+
positionAt = jest.fn().mockReturnValue(new vscode.Position(0, 0));
52+
jest.spyOn(vscode.workspace, 'openTextDocument').mockResolvedValue({ positionAt } as unknown as vscode.TextDocument);
53+
jest.spyOn(vscode.window, 'showTextDocument').mockResolvedValue({} as vscode.TextEditor);
54+
});
55+
56+
afterEach(() => {
57+
jest.restoreAllMocks();
58+
fs.rmSync(tmpDir, { recursive: true, force: true });
59+
});
60+
61+
it('registers the edit command on activation', async () => {
62+
const editCommand = new EditCommand(commandsProvider);
63+
64+
await editCommand.activate(extensionContextFactory());
65+
66+
expect(commandsProvider.registerCommand).toHaveBeenCalledWith(
67+
EditCommand.editCommandId,
68+
expect.any(Function),
69+
editCommand,
70+
);
71+
});
72+
73+
it('opens cproject.yml at the selected group entry', async () => {
74+
const editCommand = new EditCommand(commandsProvider);
75+
await editCommand.activate(extensionContextFactory());
76+
77+
const groupNode = new COutlineItem('group');
78+
groupNode.setAttribute('groupPath', 'App;Drivers');
79+
groupNode.setAttribute('projectUri', cprojectPath);
80+
81+
await commandsProvider.mockRunRegistered(EditCommand.editCommandId, groupNode);
82+
83+
expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(vscode.Uri.file(cprojectPath));
84+
expect(positionAt).toHaveBeenCalledTimes(1);
85+
const actualOffset = positionAt.mock.calls[0][0] as number;
86+
expect(actualOffset).toBeGreaterThanOrEqual(0);
87+
expect(yamlContent.slice(actualOffset)).toContain('group: Drivers');
88+
expect(vscode.window.showTextDocument).toHaveBeenCalled();
89+
});
90+
91+
it('opens cproject.yml at the selected file entry', async () => {
92+
const editCommand = new EditCommand(commandsProvider);
93+
await editCommand.activate(extensionContextFactory());
94+
95+
const groupNode = new COutlineItem('group');
96+
groupNode.setAttribute('groupPath', 'App;Drivers');
97+
groupNode.setAttribute('projectUri', cprojectPath);
98+
99+
const fileNode = groupNode.createChild('file');
100+
fileNode.setAttribute('projectUri', cprojectPath);
101+
fileNode.setAttribute('fileUri', 'drv.c');
102+
103+
await commandsProvider.mockRunRegistered(EditCommand.editCommandId, fileNode);
104+
105+
expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(vscode.Uri.file(cprojectPath));
106+
expect(positionAt).toHaveBeenCalledTimes(1);
107+
const actualOffset = positionAt.mock.calls[0][0] as number;
108+
expect(actualOffset).toBeGreaterThanOrEqual(0);
109+
expect(yamlContent.slice(actualOffset)).toContain('file: drv.c');
110+
expect(vscode.window.showTextDocument).toHaveBeenCalled();
111+
});
112+
113+
it('ignores file nodes outside editable groups', async () => {
114+
const editCommand = new EditCommand(commandsProvider);
115+
await editCommand.activate(extensionContextFactory());
116+
117+
const nonEditableNode = new COutlineItem('file');
118+
nonEditableNode.setAttribute('fileUri', 'out/test.map');
119+
120+
await commandsProvider.mockRunRegistered(EditCommand.editCommandId, nonEditableNode);
121+
122+
expect(vscode.workspace.openTextDocument).not.toHaveBeenCalled();
123+
expect(vscode.window.showTextDocument).not.toHaveBeenCalled();
124+
});
125+
});

src/views/solution-outline/commands/edit-command.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,14 @@ export class EditCommand {
3434
public async activate(context: Pick<vscode.ExtensionContext, 'subscriptions'>) {
3535
context.subscriptions.push(
3636
this.commandsProvider.registerCommand(EditCommand.editCommandId, async (node: COutlineItem) => {
37-
await this.editGroup(node);
37+
await this.editNode(node);
3838
}, this),
3939
);
4040
}
4141

42-
private async editGroup(node: COutlineItem): Promise<void> {
43-
if (node.getTag() !== 'group') {
42+
private async editNode(node: COutlineItem): Promise<void> {
43+
const tag = node.getTag();
44+
if (tag !== 'group' && tag !== 'file') {
4445
return;
4546
}
4647

@@ -49,13 +50,19 @@ export class EditCommand {
4950
return;
5051
}
5152

52-
const groupPath = getGroupPathArray(node);
53+
const groupPath = this.getGroupPathForNode(node);
5354
if (groupPath.length === 0) {
5455
return;
5556
}
5657

5758
const parentType = this.getParentTypeFromNode(node);
58-
const offset = this.findGroupOffset(filePath, parentType, groupPath) ?? 0;
59+
const offset = tag === 'group'
60+
? this.findGroupOffset(filePath, parentType, groupPath)
61+
: this.findFileOffset(filePath, parentType, groupPath, node.getAttribute('fileUri'));
62+
63+
if (offset === undefined) {
64+
return;
65+
}
5966

6067
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath));
6168
const position = document.positionAt(offset);
@@ -65,6 +72,19 @@ export class EditCommand {
6572
});
6673
}
6774

75+
private getGroupPathForNode(node: COutlineItem): string[] {
76+
if (node.getTag() === 'group') {
77+
return getGroupPathArray(node);
78+
}
79+
80+
const parent = node.getParent();
81+
if (!parent || !(parent instanceof COutlineItem) || parent.getTag() !== 'group') {
82+
return [];
83+
}
84+
85+
return getGroupPathArray(parent);
86+
}
87+
6888
private findGroupOffset(filePath: string, parentType: 'project' | 'layer', groupPath: string[]): number | undefined {
6989
const input = readTextFile(filePath);
7090
if (!input) {
@@ -95,6 +115,34 @@ export class EditCommand {
95115
: undefined;
96116
}
97117

118+
private findFileOffset(filePath: string, parentType: 'project' | 'layer', groupPath: string[], fileUri?: string): number | undefined {
119+
if (!fileUri) {
120+
return undefined;
121+
}
122+
123+
const input = readTextFile(filePath);
124+
if (!input) {
125+
return undefined;
126+
}
127+
128+
const yamlDocument = yaml.parseDocument(input);
129+
const contents = yamlDocument.contents;
130+
if (!contents) {
131+
return undefined;
132+
}
133+
134+
const pathToTargetFile = [
135+
...buildPathFromContentToGroup(groupPath, [mapKey(parentType)]),
136+
mapKey('files'),
137+
listItem(item => yaml.isMap(item) && item.get('file') === fileUri),
138+
];
139+
140+
const targetFileNode = getYamlNodeAtPath(contents, pathToTargetFile);
141+
return (targetFileNode && yaml.isNode(targetFileNode) && targetFileNode.range)
142+
? targetFileNode.range[0]
143+
: undefined;
144+
}
145+
98146
private getParentTypeFromNode(node: COutlineItem): 'project' | 'layer' {
99147
return node.getAttribute('layerUri') ? 'layer' : 'project';
100148
}

0 commit comments

Comments
 (0)